diff --git a/app/api/__init__.py b/app/api/__init__.py index dcee1703..acba63d4 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -30,6 +30,8 @@ from app.api.queue import router as queue_router from app.api.saved_searches import router as saved_searches_router from app.api.search import router as search_router from app.api.settings import router as settings_router +from app.api.shared_links import public_router as shared_links_public_router +from app.api.shared_links import router as shared_links_router from app.api.similarity import router as similarity_router from app.api.subscriptions import router as subscriptions_router from app.api.url_upload import router as url_upload_router @@ -64,6 +66,8 @@ router.include_router(search_router) router.include_router(queue_router) router.include_router(saved_searches_router) router.include_router(similarity_router) +router.include_router(shared_links_router) +router.include_router(shared_links_public_router) router.include_router(duplicates_router) router.include_router(webhooks_router) router.include_router(database_router) diff --git a/app/api/shared_links.py b/app/api/shared_links.py new file mode 100644 index 00000000..d452e6f6 --- /dev/null +++ b/app/api/shared_links.py @@ -0,0 +1,442 @@ +"""API endpoints for document sharing via expiring links. + +Authenticated users can create time-limited or view-limited shareable +links for their documents. Each link has a cryptographically random +token that forms a public ``/share/`` URL. Optional password +protection is supported; only a PBKDF2-HMAC-SHA256 hash is stored. + +Public consumers access files through the ``/share//download`` +and ``/share//info`` endpoints — no authentication required. +""" + +import hashlib +import logging +import os +import secrets +from datetime import datetime, timezone +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field, field_validator +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import FileRecord, SharedLink +from app.utils.user_scope import apply_owner_filter, get_current_owner_id + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/shared-links", tags=["shared-links"]) +public_router = APIRouter(tags=["shared-links-public"]) + +DbSession = Annotated[Session, Depends(get_db)] + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +#: PBKDF2 salt for shared-link password hashing (not secret, but fixed). +_PWD_HASH_SALT = b"shared-link-v1" +#: PBKDF2 iteration count. +_PWD_HASH_ITERATIONS = 100_000 + +# Valid expiry durations (in hours) presented in the UI. +EXPIRY_OPTIONS: dict[str, int] = { + "1h": 1, + "6h": 6, + "12h": 12, + "24h": 24, + "3d": 72, + "7d": 168, + "14d": 336, + "30d": 720, +} + + +# --------------------------------------------------------------------------- +# Auth helper +# --------------------------------------------------------------------------- + + +def _get_owner_id(request: Request) -> str: + """Return the current user's owner ID, raising 401 if unauthenticated.""" + owner_id = get_current_owner_id(request) + if not owner_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + return owner_id + + +CurrentOwner = Annotated[str, Depends(_get_owner_id)] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _generate_token() -> str: + """Generate a 43-character URL-safe random token.""" + return secrets.token_urlsafe(32) + + +def _hash_password(password: str) -> str: + """Return a PBKDF2-HMAC-SHA256 hex digest of *password*. + + Args: + password: Plaintext password string. + + Returns: + 128-character lowercase hex string. + """ + dk = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + _PWD_HASH_SALT, + _PWD_HASH_ITERATIONS, + ) + return dk.hex() + + +def _verify_password(password: str, stored_hash: str) -> bool: + """Check *password* against *stored_hash* using constant-time comparison.""" + return secrets.compare_digest(_hash_password(password), stored_hash) + + +def _is_link_valid(link: SharedLink) -> bool: + """Return True when *link* is active, unexpired, and within view limit.""" + if not link.is_active: + return False + now = datetime.now(timezone.utc) + if link.expires_at is not None: + exp = link.expires_at + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + if now > exp: + return False + if link.max_views is not None and link.view_count >= link.max_views: + return False + return True + + +def _resolve_file_path(file_record: FileRecord) -> str | None: + """Return the best available file path for *file_record*. + + Checks processed path first, then original, then local (tmp) path. + Returns ``None`` when no file exists on disk. + """ + from app.config import settings + + workdir = os.path.realpath(settings.workdir) + candidates = [ + file_record.processed_file_path, + file_record.original_file_path, + file_record.local_filename, + ] + for path in candidates: + if not path: + continue + # Guard against path traversal in DB values. + real = os.path.realpath(path) + if not real.startswith(workdir + os.sep) and real != workdir: + logger.warning("Shared link file path outside workdir rejected: %s", path) + continue + if os.path.exists(real): + return real + return None + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class SharedLinkCreate(BaseModel): + """Schema for creating a new shared link.""" + + file_id: int = Field(..., description="ID of the file to share") + expires_in_hours: int | None = Field( + None, + ge=1, + le=720, + description="Expiry in hours (1–720). NULL means the link never expires.", + ) + max_views: int | None = Field( + None, + ge=1, + le=10_000, + description="Maximum number of downloads/views. NULL means unlimited.", + ) + password: str | None = Field( + None, + min_length=1, + max_length=128, + description="Optional password protecting the link.", + ) + label: str | None = Field( + None, + max_length=255, + description="Optional human-readable label for the link.", + ) + + @field_validator("expires_in_hours") + @classmethod + def validate_expiry(cls, v: int | None) -> int | None: + if v is not None and v not in range(1, 721): + raise ValueError("expires_in_hours must be between 1 and 720") + return v + + +class SharedLinkResponse(BaseModel): + """Shared link info returned to the authenticated owner.""" + + id: int + token: str + file_id: int + label: str | None + expires_at: datetime | None + max_views: int | None + view_count: int + has_password: bool + is_active: bool + created_at: datetime | None + revoked_at: datetime | None + # Filled in by the endpoint, not stored in DB. + share_url: str = "" + original_filename: str | None = None + + model_config = {"from_attributes": True} + + +class SharedLinkInfoResponse(BaseModel): + """Public metadata about a shared link (used on the share landing page).""" + + token: str + label: str | None + original_filename: str | None + expires_at: datetime | None + max_views: int | None + view_count: int + has_password: bool + is_valid: bool + + +# --------------------------------------------------------------------------- +# Private (authenticated) endpoints +# --------------------------------------------------------------------------- + + +@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SharedLinkResponse) +async def create_shared_link( + body: SharedLinkCreate, + request: Request, + owner_id: CurrentOwner, + db: DbSession, +) -> dict[str, Any]: + """Create a new shareable link for a document. + + The caller must own the file (or be in single-user mode). + Returns the full link metadata including the generated token. + """ + # Verify the file exists and belongs to the caller. + q = db.query(FileRecord).filter(FileRecord.id == body.file_id) + q = apply_owner_filter(q, request) + file_record = q.first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + token = _generate_token() + expires_at = None + if body.expires_in_hours is not None: + expires_at = datetime.now(timezone.utc).replace(microsecond=0) + from datetime import timedelta + + expires_at = expires_at + timedelta(hours=body.expires_in_hours) + + password_hash = _hash_password(body.password) if body.password else None + + db_link = SharedLink( + token=token, + file_id=body.file_id, + owner_id=owner_id, + label=body.label, + expires_at=expires_at, + max_views=body.max_views, + view_count=0, + password_hash=password_hash, + ) + try: + db.add(db_link) + db.commit() + db.refresh(db_link) + except Exception: + db.rollback() + raise + + logger.info("Shared link created: id=%s owner=%s file_id=%s", db_link.id, owner_id, body.file_id) + + base_url = str(request.base_url).rstrip("/") + return _link_to_dict(db_link, base_url, file_record.original_filename) + + +@router.get("/", response_model=list[SharedLinkResponse]) +async def list_shared_links( + request: Request, + owner_id: CurrentOwner, + db: DbSession, + active_only: bool = Query(False, description="When true, only return active (non-revoked) links"), +) -> list[dict[str, Any]]: + """List all shared links created by the authenticated user.""" + q = db.query(SharedLink).filter(SharedLink.owner_id == owner_id) + if active_only: + q = q.filter(SharedLink.is_active.is_(True)) + links = q.order_by(SharedLink.created_at.desc()).all() + + base_url = str(request.base_url).rstrip("/") + result = [] + for link in links: + file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first() + filename = file_record.original_filename if file_record else None + result.append(_link_to_dict(link, base_url, filename)) + return result + + +@router.delete("/{link_id}", status_code=status.HTTP_200_OK) +async def revoke_shared_link( + link_id: int, + owner_id: CurrentOwner, + db: DbSession, +) -> dict[str, str]: + """Revoke (soft-delete) a shared link. + + The record is kept for audit purposes but the link immediately + stops working for recipients. + """ + db_link = db.query(SharedLink).filter(SharedLink.id == link_id, SharedLink.owner_id == owner_id).first() + if not db_link: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shared link not found") + + if not db_link.is_active: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Link is already revoked") + + try: + db_link.is_active = False + db_link.revoked_at = datetime.now(timezone.utc) + db.commit() + except Exception: + db.rollback() + raise + + logger.info("Shared link revoked: id=%s owner=%s", link_id, owner_id) + return {"detail": "Link revoked"} + + +# --------------------------------------------------------------------------- +# Public endpoints (no authentication required) +# --------------------------------------------------------------------------- + + +@public_router.get("/share/{token}/info", response_model=SharedLinkInfoResponse) +def get_shared_link_info( + token: str, + db: DbSession, +) -> dict[str, Any]: + """Return public metadata about a shared link. + + Used by the share landing page to decide whether to show a password + prompt or a direct download button. Never returns sensitive data. + """ + link = db.query(SharedLink).filter(SharedLink.token == token).first() + if not link: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Link not found") + + file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first() + filename = file_record.original_filename if file_record else None + + return { + "token": link.token, + "label": link.label, + "original_filename": filename, + "expires_at": link.expires_at, + "max_views": link.max_views, + "view_count": link.view_count, + "has_password": link.password_hash is not None, + "is_valid": _is_link_valid(link), + } + + +@public_router.get("/share/{token}/download") +def download_via_shared_link( + token: str, + db: DbSession, + password: str | None = Query(None, description="Password (if the link is password-protected)"), +) -> FileResponse: + """Download a file via a shared link (no authentication required). + + Increments the view counter and validates expiry / view limit before + serving the file. + """ + link = db.query(SharedLink).filter(SharedLink.token == token).first() + if not link: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Link not found or expired") + + if not _is_link_valid(link): + raise HTTPException(status_code=status.HTTP_410_GONE, detail="Link has expired or reached its view limit") + + # Password check + if link.password_hash is not None: + if not password: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="This link requires a password", + ) + if not _verify_password(password, link.password_hash): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Incorrect password") + + file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first() + if not file_record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + file_path = _resolve_file_path(file_record) + if not file_path: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not available on disk") + + # Increment view count (best-effort — don't fail the request if this errors). + try: + link.view_count = (link.view_count or 0) + 1 + db.commit() + except Exception: + db.rollback() + logger.warning("Failed to increment view_count for shared link id=%s", link.id) + + return FileResponse( + path=file_path, + media_type=file_record.mime_type or "application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{file_record.original_filename or "document"}"', + }, + ) + + +# --------------------------------------------------------------------------- +# Internal helper +# --------------------------------------------------------------------------- + + +def _link_to_dict(link: SharedLink, base_url: str, original_filename: str | None) -> dict[str, Any]: + """Serialise a ``SharedLink`` ORM row to a plain dict.""" + return { + "id": link.id, + "token": link.token, + "file_id": link.file_id, + "label": link.label, + "expires_at": link.expires_at, + "max_views": link.max_views, + "view_count": link.view_count, + "has_password": link.password_hash is not None, + "is_active": link.is_active, + "created_at": link.created_at, + "revoked_at": link.revoked_at, + "share_url": f"{base_url}/share/{link.token}", + "original_filename": original_filename, + } diff --git a/app/models.py b/app/models.py index 687bf3da..cc0c2bd5 100644 --- a/app/models.py +++ b/app/models.py @@ -637,3 +637,50 @@ class ApiToken(Base): is_active = Column(Boolean, nullable=False, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) revoked_at = Column(DateTime(timezone=True), nullable=True) + + +class SharedLink(Base): + """Shareable, time-limited or view-limited document link. + + A ``SharedLink`` grants unauthenticated access to one ``FileRecord`` + via a cryptographically random URL token. The link may optionally + expire after a given datetime, be limited to a fixed number of views, + and require a password. Only a PBKDF2-HMAC-SHA256 hash of the + password is stored. + + Owners can view and revoke their active links from the management UI. + """ + + __tablename__ = "shared_links" + + id = Column(Integer, primary_key=True, index=True) + + # Unique URL-safe token — forms the public /share/ URL. + token = Column(String(64), nullable=False, unique=True, index=True) + + # File this link grants access to. + file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True) + + # Owner who created the link (matches FileRecord.owner_id). + owner_id = Column(String, nullable=False, index=True) + + # Optional human-readable description chosen by the creator. + label = Column(String(255), nullable=True) + + # Time-based expiration (NULL = never expires). + expires_at = Column(DateTime(timezone=True), nullable=True) + + # View-count limit (NULL = unlimited). + max_views = Column(Integer, nullable=True) + + # Cumulative view count (incremented on every successful access). + view_count = Column(Integer, nullable=False, default=0) + + # Optional password protection — stores PBKDF2-HMAC-SHA256 hex digest. + password_hash = Column(String(128), nullable=True) + + # Whether the link is still valid (set to False to revoke immediately). + is_active = Column(Boolean, nullable=False, default=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + revoked_at = Column(DateTime(timezone=True), nullable=True) diff --git a/app/views/__init__.py b/app/views/__init__.py index b8fb9b0d..975fa4dd 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -25,6 +25,8 @@ from app.views.plans import router as plans_router # Admin Plan Designer from app.views.queue import router as queue_router from app.views.search import router as search_router from app.views.settings import router as settings_router +from app.views.share import router as share_router +from app.views.shared_links import router as shared_links_router from app.views.status import router as status_router from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages from app.views.wizard import router as wizard_router @@ -35,6 +37,8 @@ router.include_router(wizard_router) # Wizard first (for /setup) router.include_router(db_wizard_router) # Database wizard router.include_router(admin_users_router) # Admin user management router.include_router(api_tokens_router) # API token management +router.include_router(shared_links_router) # Shared links management +router.include_router(share_router) # Public share landing pages (no auth) router.include_router(backup_router) # Backup dashboard router.include_router(general_router) router.include_router(status_router) diff --git a/app/views/share.py b/app/views/share.py new file mode 100644 index 00000000..118e0ee9 --- /dev/null +++ b/app/views/share.py @@ -0,0 +1,28 @@ +"""Public view for accessing a shared document link. + +This route does NOT require authentication — it is the landing page +that link recipients visit. The page fetches link metadata via the +public ``/api/share/{token}/info`` JSON endpoint and then renders the +appropriate download UI (password gate or direct download button). +""" + +import logging +import pathlib + +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +logger = logging.getLogger(__name__) +router = APIRouter() + +_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates" +templates = Jinja2Templates(directory=str(_templates_dir)) + + +@router.get("/share/{token}") +async def shared_link_view(request: Request, token: str): + """Render the public share landing page for a given token.""" + return templates.TemplateResponse( + "shared_link_view.html", + {"request": request, "token": token}, + ) diff --git a/app/views/shared_links.py b/app/views/shared_links.py new file mode 100644 index 00000000..ccde4b1b --- /dev/null +++ b/app/views/shared_links.py @@ -0,0 +1,24 @@ +"""View route for the Shared Links management page. + +Renders the ``shared_links.html`` template where authenticated users can +create, view, and revoke their document share links. +""" + +import logging + +from fastapi import APIRouter, Request + +from app.views.base import require_login, templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/shared-links") +@require_login +async def shared_links_page(request: Request): + """Render the Shared Links management page.""" + return templates.TemplateResponse( + "shared_links.html", + {"request": request, "page_title": "Shared Links"}, + ) diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 7ad1776d..65033632 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -240,6 +240,55 @@ can create multiple tokens, each with a descriptive name. > **Security tip:** Create a dedicated token for each integration and revoke it > immediately if compromised. Never share tokens or commit them to source control. +## Sharing Documents + +DocuElevate lets you share individual documents with anyone via a secure, +time-limited link. Recipients do not need a DocuElevate account. + +### Creating a Shared Link + +1. Open the document detail page (`/files/{id}`) and click the **Share** button + in the Actions panel — this opens the Shared Links page with the file ID + pre-filled. Alternatively, go to **Avatar → Shared Links** in the navigation. +2. Enter the **File ID** you want to share (shown in the document detail URL). +3. Choose an optional **label** (e.g. "For Alice") to identify the link later. +4. Set an **expiry** — choose from 1 hour up to 30 days, or leave blank for a + link that never expires. +5. Optionally set a **max downloads** limit. Once that number is reached the + link is automatically invalidated. +6. Optionally set a **password**. Recipients will be prompted to enter it before + they can download the file. +7. Click **Create Link** and copy the generated URL to share with the recipient. + +### What Recipients See + +When a recipient visits the `/share/{token}` URL they see a simple landing page: + +- The document filename and optional label. +- Expiry date and remaining download count (if set). +- If the link is password-protected they must enter the password first. +- A **Download** button that serves the file directly. + +If the link has expired, been revoked, or reached its download limit, the +recipient sees a clear "Link Unavailable" message. + +### Managing Your Shared Links + +The **Shared Links** management page (`/shared-links`) shows all links you have +created with their current status: + +| Status | Meaning | +|--------|---------| +| **Active** | Link is valid and can be used by recipients | +| **Expired** | Link's expiry date has passed | +| **Limit reached** | View/download count has been exhausted | +| **Revoked** | You manually deactivated the link | + +Click **Revoke** next to any active link to immediately invalidate it. + +> **Security tip:** Revoke shared links as soon as they are no longer needed. +> Use short expiry times and download limits for sensitive documents. + ## Managing Documents The **Files** page provides access to all processed documents: diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 3436cb5c..573cc098 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -212,6 +212,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { linksDiv.appendChild( _makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700') ); + linksDiv.appendChild( + _makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', 'Shared Links', 'text-gray-700') + ); // Divider + Sign Out const divider = document.createElement('div'); @@ -293,6 +296,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { tokensLink.appendChild(document.createTextNode('API Tokens')); mobileAuthSection.appendChild(tokensLink); + // Shared Links link + const sharedLinksLink = document.createElement('a'); + sharedLinksLink.href = '/shared-links'; + sharedLinksLink.className = + 'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50'; + const sharedLinksIcon = document.createElement('i'); + sharedLinksIcon.className = 'fas fa-share-alt mr-2 text-blue-400'; + sharedLinksIcon.setAttribute('aria-hidden', 'true'); + sharedLinksLink.appendChild(sharedLinksIcon); + sharedLinksLink.appendChild(document.createTextNode('Shared Links')); + mobileAuthSection.appendChild(sharedLinksLink); + // Logout link const logoutLink = document.createElement('a'); logoutLink.href = '/logout'; diff --git a/frontend/templates/file_view.html b/frontend/templates/file_view.html index cf5a4024..3489ab2a 100644 --- a/frontend/templates/file_view.html +++ b/frontend/templates/file_view.html @@ -341,6 +341,9 @@ Open original {% endif %} + + Share + diff --git a/frontend/templates/shared_link_view.html b/frontend/templates/shared_link_view.html new file mode 100644 index 00000000..fdae448a --- /dev/null +++ b/frontend/templates/shared_link_view.html @@ -0,0 +1,232 @@ + + + + + Shared Document – DocuElevate + + + + + + + + + +
+ +
+ + +
+ + DocuElevate +
+ + +
+ +

Loading link…

+
+ + + + + + + +
+ +

+ Shared via DocuElevate +

+
+ + + + diff --git a/frontend/templates/shared_links.html b/frontend/templates/shared_links.html new file mode 100644 index 00000000..48d269bd --- /dev/null +++ b/frontend/templates/shared_links.html @@ -0,0 +1,451 @@ +{% extends "base.html" %} + +{% block title %}Shared Links – DocuElevate{% endblock %} + +{% block content %} +
+
+

+ + Shared Links +

+

+ Share documents with anyone via a time-limited or view-limited link. + Recipients do not need a DocuElevate account. Links can be password-protected + and revoked at any time. +

+
+ + +
+ + +
+ +
+
+ + +

+ Find the file ID on the Files page or in the document detail URL. +

+
+ + +
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + + + +
+ +
+
+ + + +
+ + +
+
+ + +
+ + + + + + + + + +
+
+ + +{% endblock %} diff --git a/migrations/versions/025_add_shared_links.py b/migrations/versions/025_add_shared_links.py new file mode 100644 index 00000000..e79493d0 --- /dev/null +++ b/migrations/versions/025_add_shared_links.py @@ -0,0 +1,50 @@ +"""Add shared_links table for document sharing with expiring links + +Revision ID: 025_add_shared_links +Revises: 024_add_api_tokens +Create Date: 2026-03-08 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "025_add_shared_links" +down_revision: Union[str, None] = "024_add_api_tokens" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create shared_links table.""" + op.create_table( + "shared_links", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(64), nullable=False), + sa.Column("file_id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.String(), nullable=False), + sa.Column("label", sa.String(255), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("max_views", sa.Integer(), nullable=True), + sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("password_hash", sa.String(128), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["file_id"], ["files.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + ) + op.create_index("ix_shared_links_id", "shared_links", ["id"]) + op.create_index("ix_shared_links_token", "shared_links", ["token"]) + op.create_index("ix_shared_links_file_id", "shared_links", ["file_id"]) + op.create_index("ix_shared_links_owner_id", "shared_links", ["owner_id"]) + + +def downgrade() -> None: + """Drop shared_links table.""" + op.drop_index("ix_shared_links_owner_id", "shared_links") + op.drop_index("ix_shared_links_file_id", "shared_links") + op.drop_index("ix_shared_links_token", "shared_links") + op.drop_index("ix_shared_links_id", "shared_links") + op.drop_table("shared_links") diff --git a/tests/test_shared_links.py b/tests/test_shared_links.py new file mode 100644 index 00000000..d0237d6f --- /dev/null +++ b/tests/test_shared_links.py @@ -0,0 +1,821 @@ +"""Tests for document sharing via expiring links (app/api/shared_links.py).""" + +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.models import FileRecord, SharedLink + +# --------------------------------------------------------------------------- +# Test data +# --------------------------------------------------------------------------- + +_OWNER = "shareuser@example.com" +_OTHER_OWNER = "other@example.com" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def sl_engine(): + """In-memory SQLite engine with all tables created.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def sl_session(sl_engine): + """DB session scoped to one test.""" + Session = sessionmaker(bind=sl_engine) + session = Session() + yield session + session.close() + + +def _make_file(session, owner_id: str = _OWNER, filename: str = "test.pdf") -> FileRecord: + """Insert a minimal FileRecord and return it.""" + record = FileRecord( + owner_id=owner_id, + filehash="abc123", + original_filename=filename, + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + ) + session.add(record) + session.commit() + session.refresh(record) + return record + + +def _make_client(sl_engine, owner_id: str = _OWNER) -> TestClient: + """Return a TestClient with *owner_id* injected as the authenticated user.""" + from app.api.shared_links import _get_owner_id + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_get_db(): + session = Session() + try: + yield session + finally: + session.close() + + def _override_owner(): + return owner_id + + app.dependency_overrides[get_db] = _override_get_db + app.dependency_overrides[_get_owner_id] = _override_owner + + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + return client + + +def _cleanup(app): + """Remove dependency overrides after test.""" + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Tests – Link CRUD (authenticated) +# --------------------------------------------------------------------------- + + +class TestCreateSharedLink: + """Tests for POST /api/shared-links/.""" + + @pytest.mark.unit + def test_create_link_basic(self, sl_engine, sl_session): + """Creating a link returns the token and share URL.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + resp = client.post("/api/shared-links/", json={"file_id": file_record.id}) + assert resp.status_code == 201, resp.text + data = resp.json() + assert "token" in data + assert data["share_url"].startswith("http") + assert data["token"] in data["share_url"] + assert data["is_active"] is True + assert data["has_password"] is False + assert data["expires_at"] is None + finally: + _cleanup(app) + + @pytest.mark.unit + def test_create_link_with_expiry(self, sl_engine, sl_session): + """Creating a link with expires_in_hours sets expires_at.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "expires_in_hours": 24}) + assert resp.status_code == 201 + data = resp.json() + assert data["expires_at"] is not None + finally: + _cleanup(app) + + @pytest.mark.unit + def test_create_link_with_password(self, sl_engine, sl_session): + """Creating a link with a password stores a hash (not plaintext).""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "password": "secret"}) + assert resp.status_code == 201 + data = resp.json() + assert data["has_password"] is True + # Verify plaintext is not stored. + from app.models import SharedLink + + Session = sessionmaker(bind=sl_engine) + with Session() as sess: + db_link = sess.query(SharedLink).filter(SharedLink.token == data["token"]).first() + assert db_link is not None + assert db_link.password_hash != "secret" + assert len(db_link.password_hash) == 64 # hex digest length + finally: + _cleanup(app) + + @pytest.mark.unit + def test_create_link_with_max_views(self, sl_engine, sl_session): + """Creating a link with max_views stores the limit.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "max_views": 5}) + assert resp.status_code == 201 + assert resp.json()["max_views"] == 5 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_create_link_wrong_file_returns_404(self, sl_engine, sl_session): + """Creating a link for a non-existent file returns 404.""" + from app.main import app + + client = _make_client(sl_engine) + try: + resp = client.post("/api/shared-links/", json={"file_id": 99999}) + assert resp.status_code == 404 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_create_link_other_users_file_returns_404(self, sl_engine, sl_session): + """Creating a link for another user's file returns 404.""" + from app.main import app + + other_file = _make_file(sl_session, owner_id=_OTHER_OWNER) + client = _make_client(sl_engine, _OWNER) + try: + from app.config import settings + + original = settings.multi_user_enabled + settings.multi_user_enabled = True + try: + resp = client.post("/api/shared-links/", json={"file_id": other_file.id}) + assert resp.status_code == 404 + finally: + settings.multi_user_enabled = original + finally: + _cleanup(app) + + @pytest.mark.unit + def test_create_link_with_label(self, sl_engine, sl_session): + """A label is returned when provided.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "label": "For Alice"}) + assert resp.status_code == 201 + assert resp.json()["label"] == "For Alice" + finally: + _cleanup(app) + + +class TestListSharedLinks: + """Tests for GET /api/shared-links/.""" + + @pytest.mark.unit + def test_list_empty(self, sl_engine): + """Listing when no links exist returns an empty list.""" + from app.main import app + + client = _make_client(sl_engine) + try: + resp = client.get("/api/shared-links/") + assert resp.status_code == 200 + assert resp.json() == [] + finally: + _cleanup(app) + + @pytest.mark.unit + def test_list_returns_own_links(self, sl_engine, sl_session): + """Listing returns all links created by the current user.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + client.post("/api/shared-links/", json={"file_id": file_record.id, "label": "Link A"}) + client.post("/api/shared-links/", json={"file_id": file_record.id, "label": "Link B"}) + resp = client.get("/api/shared-links/") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_list_isolation(self, sl_engine, sl_session): + """Users can only list their own links.""" + from app.main import app + + file_a = _make_file(sl_session, owner_id=_OWNER) + client_a = _make_client(sl_engine, _OWNER) + try: + client_a.post("/api/shared-links/", json={"file_id": file_a.id}) + finally: + _cleanup(app) + + file_b = _make_file(sl_session, owner_id=_OTHER_OWNER) + client_b = _make_client(sl_engine, _OTHER_OWNER) + try: + resp = client_b.get("/api/shared-links/") + assert resp.status_code == 200 + assert resp.json() == [] + finally: + _cleanup(app) + + +class TestRevokeSharedLink: + """Tests for DELETE /api/shared-links/{id}.""" + + @pytest.mark.unit + def test_revoke_link(self, sl_engine, sl_session): + """Revoking a link marks it inactive.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + create_resp = client.post("/api/shared-links/", json={"file_id": file_record.id}) + link_id = create_resp.json()["id"] + + resp = client.delete(f"/api/shared-links/{link_id}") + assert resp.status_code == 200 + + list_resp = client.get("/api/shared-links/") + revoked = [link for link in list_resp.json() if link["id"] == link_id][0] + assert revoked["is_active"] is False + assert revoked["revoked_at"] is not None + finally: + _cleanup(app) + + @pytest.mark.unit + def test_revoke_already_revoked(self, sl_engine, sl_session): + """Revoking an already-revoked link returns 400.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + create_resp = client.post("/api/shared-links/", json={"file_id": file_record.id}) + link_id = create_resp.json()["id"] + client.delete(f"/api/shared-links/{link_id}") + resp = client.delete(f"/api/shared-links/{link_id}") + assert resp.status_code == 400 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_revoke_nonexistent(self, sl_engine): + """Revoking a non-existent link returns 404.""" + from app.main import app + + client = _make_client(sl_engine) + try: + resp = client.delete("/api/shared-links/99999") + assert resp.status_code == 404 + finally: + _cleanup(app) + + @pytest.mark.unit + def test_revoke_other_users_link(self, sl_engine, sl_session): + """A user cannot revoke another user's link.""" + from app.main import app + + file_a = _make_file(sl_session, owner_id=_OWNER) + client_a = _make_client(sl_engine, _OWNER) + try: + create_resp = client_a.post("/api/shared-links/", json={"file_id": file_a.id}) + link_id = create_resp.json()["id"] + finally: + _cleanup(app) + + client_b = _make_client(sl_engine, _OTHER_OWNER) + try: + resp = client_b.delete(f"/api/shared-links/{link_id}") + assert resp.status_code == 404 + finally: + _cleanup(app) + + +# --------------------------------------------------------------------------- +# Tests – Public endpoints +# --------------------------------------------------------------------------- + + +class TestPublicInfo: + """Tests for GET /api/share/{token}/info.""" + + @pytest.mark.unit + def test_info_for_valid_link(self, sl_engine, sl_session): + """Public info endpoint returns metadata for a valid link.""" + from app.main import app + + file_record = _make_file(sl_session, filename="report.pdf") + client = _make_client(sl_engine) + try: + create_resp = client.post( + "/api/shared-links/", + json={"file_id": file_record.id, "label": "For Bob"}, + ) + token = create_resp.json()["token"] + finally: + _cleanup(app) + + # Public client (no auth override) + from app.main import app as main_app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + main_app.dependency_overrides[get_db] = _override_db + pub_client = TestClient(main_app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = pub_client.get(f"/api/share/{token}/info") + assert resp.status_code == 200 + data = resp.json() + assert data["token"] == token + assert data["is_valid"] is True + assert data["original_filename"] == "report.pdf" + assert data["label"] == "For Bob" + assert data["has_password"] is False + finally: + main_app.dependency_overrides.clear() + + @pytest.mark.unit + def test_info_for_unknown_token(self, sl_engine): + """Unknown token returns 404.""" + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/nonexistenttoken/info") + assert resp.status_code == 404 + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_info_shows_has_password(self, sl_engine, sl_session): + """Info for a password-protected link shows has_password=True.""" + from app.main import app + + file_record = _make_file(sl_session) + client = _make_client(sl_engine) + try: + create_resp = client.post( + "/api/shared-links/", + json={"file_id": file_record.id, "password": "hunter2"}, + ) + token = create_resp.json()["token"] + finally: + _cleanup(app) + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + pub_client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = pub_client.get(f"/api/share/{token}/info") + assert resp.status_code == 200 + assert resp.json()["has_password"] is True + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_expired_link_shows_invalid(self, sl_engine, sl_session): + """An expired link reports is_valid=False in info response.""" + now = datetime.now(timezone.utc) + past = now - timedelta(hours=1) + link = SharedLink( + token="expiredtoken123", + file_id=_make_file(sl_session).id, + owner_id=_OWNER, + expires_at=past, + view_count=0, + is_active=True, + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/expiredtoken123/info") + assert resp.status_code == 200 + assert resp.json()["is_valid"] is False + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_view_limit_reached_shows_invalid(self, sl_engine, sl_session): + """A link that has reached its view limit reports is_valid=False.""" + link = SharedLink( + token="limitedtoken456", + file_id=_make_file(sl_session).id, + owner_id=_OWNER, + max_views=3, + view_count=3, + is_active=True, + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/limitedtoken456/info") + assert resp.status_code == 200 + assert resp.json()["is_valid"] is False + finally: + app.dependency_overrides.clear() + + +class TestPublicDownload: + """Tests for GET /api/share/{token}/download.""" + + @pytest.mark.unit + def test_download_no_file_on_disk_returns_404(self, sl_engine, sl_session): + """Download endpoint returns 404 when file not on disk.""" + link = SharedLink( + token="dltoken001", + file_id=_make_file(sl_session, filename="missing.pdf").id, + owner_id=_OWNER, + view_count=0, + is_active=True, + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/dltoken001/download") + assert resp.status_code == 404 + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_download_expired_returns_410(self, sl_engine, sl_session): + """Download endpoint returns 410 when link has expired.""" + past = datetime.now(timezone.utc) - timedelta(hours=1) + link = SharedLink( + token="expiredlink999", + file_id=_make_file(sl_session).id, + owner_id=_OWNER, + expires_at=past, + view_count=0, + is_active=True, + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/expiredlink999/download") + assert resp.status_code == 410 + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_download_revoked_returns_410(self, sl_engine, sl_session): + """Download endpoint returns 410 when link has been revoked.""" + link = SharedLink( + token="revokedlink777", + file_id=_make_file(sl_session).id, + owner_id=_OWNER, + view_count=0, + is_active=False, + revoked_at=datetime.now(timezone.utc), + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/revokedlink777/download") + assert resp.status_code == 410 + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_download_password_required(self, sl_engine, sl_session): + """Download endpoint returns 401 when password is required but not supplied.""" + from app.api.shared_links import _hash_password + + link = SharedLink( + token="pwdlink111", + file_id=_make_file(sl_session).id, + owner_id=_OWNER, + password_hash=_hash_password("correct"), + view_count=0, + is_active=True, + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/pwdlink111/download") + assert resp.status_code == 401 + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_download_wrong_password(self, sl_engine, sl_session): + """Download endpoint returns 403 for wrong password.""" + from app.api.shared_links import _hash_password + + link = SharedLink( + token="pwdlink222", + file_id=_make_file(sl_session).id, + owner_id=_OWNER, + password_hash=_hash_password("correct"), + view_count=0, + is_active=True, + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/pwdlink222/download?password=wrong") + assert resp.status_code == 403 + finally: + app.dependency_overrides.clear() + + @pytest.mark.unit + def test_download_view_limit_reached(self, sl_engine, sl_session): + """Download endpoint returns 410 when view limit is already reached.""" + link = SharedLink( + token="limitedlink333", + file_id=_make_file(sl_session).id, + owner_id=_OWNER, + max_views=2, + view_count=2, + is_active=True, + ) + sl_session.add(link) + sl_session.commit() + + from app.main import app + + Session = sessionmaker(bind=sl_engine) + + def _override_db(): + session = Session() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = _override_db + client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False) + try: + resp = client.get("/api/share/limitedlink333/download") + assert resp.status_code == 410 + finally: + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Tests – Helper utilities +# --------------------------------------------------------------------------- + + +class TestHelpers: + """Tests for internal helper functions.""" + + @pytest.mark.unit + def test_generate_token_unique(self): + """Generated tokens should be unique.""" + from app.api.shared_links import _generate_token + + tokens = {_generate_token() for _ in range(100)} + assert len(tokens) == 100 + + @pytest.mark.unit + def test_hash_password_deterministic(self): + """Hashing the same password always produces the same hex digest.""" + from app.api.shared_links import _hash_password + + h = _hash_password("mysecret") + assert h == _hash_password("mysecret") + assert len(h) == 64 + + @pytest.mark.unit + def test_verify_password_correct(self): + """_verify_password returns True for a matching password.""" + from app.api.shared_links import _hash_password, _verify_password + + h = _hash_password("correct") + assert _verify_password("correct", h) is True + + @pytest.mark.unit + def test_verify_password_wrong(self): + """_verify_password returns False for a wrong password.""" + from app.api.shared_links import _hash_password, _verify_password + + h = _hash_password("correct") + assert _verify_password("wrong", h) is False + + @pytest.mark.unit + def test_is_link_valid_active(self): + """_is_link_valid returns True for a basic active link.""" + from app.api.shared_links import _is_link_valid + + link = SharedLink(is_active=True, view_count=0) + assert _is_link_valid(link) is True + + @pytest.mark.unit + def test_is_link_valid_revoked(self): + """_is_link_valid returns False for a revoked link.""" + from app.api.shared_links import _is_link_valid + + link = SharedLink(is_active=False, view_count=0) + assert _is_link_valid(link) is False + + @pytest.mark.unit + def test_is_link_valid_expired(self): + """_is_link_valid returns False when expires_at is in the past.""" + from app.api.shared_links import _is_link_valid + + past = datetime.now(timezone.utc) - timedelta(seconds=1) + link = SharedLink(is_active=True, expires_at=past, view_count=0) + assert _is_link_valid(link) is False + + @pytest.mark.unit + def test_is_link_valid_not_yet_expired(self): + """_is_link_valid returns True when expires_at is in the future.""" + from app.api.shared_links import _is_link_valid + + future = datetime.now(timezone.utc) + timedelta(hours=1) + link = SharedLink(is_active=True, expires_at=future, view_count=0) + assert _is_link_valid(link) is True + + @pytest.mark.unit + def test_is_link_valid_view_limit_hit(self): + """_is_link_valid returns False when view_count >= max_views.""" + from app.api.shared_links import _is_link_valid + + link = SharedLink(is_active=True, max_views=3, view_count=3) + assert _is_link_valid(link) is False + + @pytest.mark.unit + def test_is_link_valid_view_limit_not_hit(self): + """_is_link_valid returns True when view_count < max_views.""" + from app.api.shared_links import _is_link_valid + + link = SharedLink(is_active=True, max_views=3, view_count=2) + assert _is_link_valid(link) is True