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("")