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:
parent
12e610659e
commit
d672583830
6 changed files with 381 additions and 1 deletions
|
|
@ -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("")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue