feat(downloads): public share-by-link + watch page backend

Share a download by a capability URL (/watch/{token}) that anyone can play on a login-free page —
distinct from the registered-user ACL share. Per-link controls: optional expiry, allow-download
toggle (stream-only vs downloadable), optional argon2 password. Revoke = delete.
- migration 0042 + DownloadLink model; app/downloads/links.py (token, HMAC grant sign/verify)
- owner endpoints (POST/GET/PATCH/DELETE links) + /recipients for the internal user picker
- public router (no auth): watch meta / password unlock→signed grant / range-aware file serve
  (inline vs attachment); unlock is rate-limited; meta verifies the file exists on disk
Verified end-to-end: 206 range, inline vs attachment, wrong-password 403, tampered-grant 403.
This commit is contained in:
npeter83 2026-07-04 04:19:29 +02:00
parent 12e610659e
commit d672583830
6 changed files with 381 additions and 1 deletions

View file

@ -6,7 +6,7 @@ worker is a separate process); this module never downloads — it enqueues, mana
serves finished files (range-aware, with the user's custom display name), and shares them.
"""
import re
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlparse
@ -19,15 +19,18 @@ from app.auth import admin_user, require_human
from app.config import settings
from app.db import get_db
from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.models import (
DownloadJob,
DownloadLink,
DownloadProfile,
DownloadQuota,
DownloadShare,
MediaAsset,
User,
)
from app.security import hash_password
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
@ -616,6 +619,119 @@ def unshare_download(
return {"ok": True}
# --- recipients (for the internal "share with a user" picker) ------------------------------
@router.get("/recipients")
def share_recipients(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
"""Registered users this user can share a download with (excludes self + the demo account)."""
rows = (
db.execute(
select(User)
.where(User.id != user.id, User.is_demo.is_(False))
.order_by(func.lower(User.email))
)
.scalars()
.all()
)
return [{"id": u.id, "email": u.email, "display_name": u.display_name} for u in rows]
# --- public watch links (share by link) ----------------------------------------------------
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
link = db.get(DownloadLink, link_id)
if link is None:
raise HTTPException(status_code=404, detail="Unknown link")
job = db.get(DownloadJob, link.job_id)
if job is None or job.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown link")
return link
def _link_expiry(payload: dict) -> datetime | None:
days = payload.get("expires_days")
if days in (None, "", 0, "0"):
return None
try:
d = int(days)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="expires_days must be a number")
if d <= 0:
return None
return datetime.now(timezone.utc) + timedelta(days=min(d, 3650))
@router.get("/{job_id}/links")
def list_links(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
_own_job(db, user, job_id)
rows = (
db.execute(
select(DownloadLink).where(DownloadLink.job_id == job_id).order_by(DownloadLink.id.desc())
)
.scalars()
.all()
)
return [linksmod.owner_view(l) for l in rows]
@router.post("/{job_id}/links")
def create_link(
job_id: int,
payload: dict,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
job = _own_job(db, user, job_id)
if job.status != "done" or job.asset_id is None:
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
pw = (payload.get("password") or "").strip()
link = DownloadLink(
job_id=job_id,
token=linksmod.new_token(),
created_by=user.id,
allow_download=bool(payload.get("allow_download")),
password_hash=hash_password(pw) if pw else None,
expires_at=_link_expiry(payload),
)
db.add(link)
db.commit()
db.refresh(link)
return linksmod.owner_view(link)
@router.patch("/links/{link_id}")
def update_link(
link_id: int,
payload: dict,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
link = _own_link(db, user, link_id)
if "allow_download" in payload:
link.allow_download = bool(payload["allow_download"])
if "expires_days" in payload:
link.expires_at = _link_expiry(payload)
if "password" in payload:
pw = (payload.get("password") or "").strip()
link.password_hash = hash_password(pw) if pw else None
db.commit()
return linksmod.owner_view(link)
@router.delete("/links/{link_id}")
def revoke_link(
link_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
link = _own_link(db, user, link_id)
db.delete(link)
db.commit()
return {"deleted": link_id}
# --- admin ---------------------------------------------------------------------------------
@admin_router.get("")

View file

@ -0,0 +1,109 @@
"""Public, login-free surface for shared download links (the `/watch/{token}` player page).
These routes are deliberately OUTSIDE the auth/`require_human` gate the unguessable token IS the
credential (a capability URL). They only ever expose one file's stream + minimal metadata, never
the app or any account data. Password-protected links exchange the password once at `/unlock` for
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
"""
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset
from app.ratelimit import RateLimiter
from app.security import verify_password
router = APIRouter(prefix="/api/public", tags=["public"])
# Slow password guessing per link (the token itself is 192-bit, so it needs no throttle).
_unlock_limit = RateLimiter(max_events=10, window_seconds=300)
def _link_or_404(db: Session, token: str) -> DownloadLink:
link = db.execute(
select(DownloadLink).where(DownloadLink.token == token)
).scalar_one_or_none()
if link is None or linksmod.is_expired(link):
raise HTTPException(status_code=404, detail="This link is no longer available.")
return link
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=404, detail="This video is no longer available.")
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
# loads a player for a missing file — meta and file agree.
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.")
return asset
def _file_url(token: str, grant: str | None = None) -> str:
base = f"/api/public/watch/{token}/file"
return f"{base}?g={grant}" if grant else base
@router.get("/watch/{token}")
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
"""Public metadata for the watch page. For a password link, returns only `needs_password`
until the viewer unlocks it (no title/thumbnail leak)."""
link = _link_or_404(db, token)
if link.password_hash:
return {"needs_password": True}
asset = _ready_asset(db, link)
link.view_count += 1
db.commit()
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))}
@router.post("/watch/{token}/unlock")
def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> dict:
"""Exchange the link password for a signed grant carried by the media URL."""
if not _unlock_limit.allow(token):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
link = _link_or_404(db, token)
if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta.
asset = _ready_asset(db, link)
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))}
if not verify_password((payload.get("password") or ""), link.password_hash):
raise HTTPException(status_code=403, detail="Wrong password.")
asset = _ready_asset(db, link)
link.view_count += 1
db.commit()
grant = linksmod.make_grant(token)
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token, grant))}
@router.get("/watch/{token}/file")
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment. Password links require a valid grant."""
link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g):
raise HTTPException(status_code=403, detail="This link needs a password.")
asset = _ready_asset(db, link)
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.")
ext = asset.container or path.suffix.lstrip(".")
base = storage.display_filename(asset.title or "video")
if base.lower().endswith(f".{ext.lower()}"):
base = base[: -(len(ext) + 1)]
filename = f"{base}.{ext}"
disposition = "attachment" if link.allow_download else "inline"
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
return FileResponse(path, filename=filename, content_disposition_type=disposition)