Files
siftlode/backend/app/routes/public.py
T
peter e9c60f72cd refactor(capture): the HTTP layer reads through queries and writes through the service
Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call
the session or set a model's fields: reads are downloads/queries, writes the unit's service (service,
folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes
through userscope (owned_by, owned_row), which owned_or_404 now uses too.

The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes
no other unit's, filters owners through userscope and commits only in its jobs and the upload
ingest. A test per service use case, among them that the tray lists only the caller's files.
2026-10-19 00:19:03 +02:00

292 lines
12 KiB
Python

"""Public, login-free surface for shared download links (the `/watch/{token}` player page).
These routes are deliberately OUTSIDE the auth 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 typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
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 queries, storage
from app.downloads.status import JobStatus
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
from app.refusal import refused
from app.routes._common import not_found
if TYPE_CHECKING:
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
router = APIRouter(prefix="/api/public", tags=["public"])
# Slow password guessing on a link (the token itself is 192-bit, so it needs no throttle). Counted per
# viewer — the link AND the client (`client_key`) — so a client guessing wrong shuts out only itself, and a
# busy link's own unlocks don't count against its next viewer.
_unlock_limit = RateLimiter(max_events=10, window_seconds=300)
# ...under a ceiling on WRONG passwords for the link from every client together, so guessing spread over
# many addresses stays slow too; unlocking with the password never adds to it. The price of the ceiling:
# someone with the link but not its password, guessing from 50 / 10 = 5 networks, can keep the link shut
# for everyone until the window passes. It is checked before the password and counted after it, so guesses
# already running when it fills are still checked — as many as the route's threadpool runs at once.
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
def _link_or_404(db: Session, token: str) -> "DownloadLink":
link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link):
raise refused(404, "link_gone")
return link
def _being_made(db: Session, job: "DownloadJob") -> bool:
"""The job is running, or queued for a worker that will take it: the worker leaves a suspended
owner's queued jobs where they are (`worker._claim_job`), as long as the suspension lasts."""
if job.status == JobStatus.RUNNING:
return True
if job.status != JobStatus.QUEUED:
return False
return not queries.owner_suspended(db, job)
def _ready_job(db: Session, job: "DownloadJob | None") -> tuple["DownloadJob", "MediaAsset"]:
asset = queries.asset_of(db, job)
if (
job is not None
and asset is not None
and asset.status in ("pending", "downloading")
and _being_made(db, job)
):
# Being (re)made — a re-download, or a conversion — so the link works again once it is ready:
# its own reason, so the page can say "try again shortly" rather than "this link is gone".
# Only while the job is still going: a cancelled or paused rebuild leaves the asset pending
# with nothing working on it, and "try again in a few minutes" would never come true.
raise refused(404, "video_preparing")
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
raise refused(404, "video_gone")
# 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.safe_abs_path(settings.files_root, asset.rel_path)
if path is None:
raise refused(404, "video_gone")
return job, asset
def _ready_target(db: Session, link: "DownloadLink") -> tuple["DownloadJob", "MediaAsset"]:
"""A file link's file. A folder link names no one file: its files are asked for by id."""
if link.job_id is None:
raise refused(404, "video_gone")
return _ready_job(db, queries.job(db, link.job_id))
def _link_folder(db: Session, link: "DownloadLink") -> "FileFolder":
folder = queries.link_folder(db, link)
if folder is None:
raise refused(404, "link_gone")
return folder
def _folder_job(
db: Session, link: "DownloadLink", job_id: int
) -> tuple["DownloadJob", "MediaAsset"]:
job = queries.folder_job(db, _link_folder(db, link), job_id)
if job is None:
raise refused(404, "video_gone")
return _ready_job(db, job)
def _base(token: str, job_id: int | None) -> str:
"""A file link's file lives under its token; a folder link's files, each under its id."""
return f"/api/public/watch/{token}" + (f"/files/{job_id}" if job_id is not None else "")
def _file_url(token: str, grant: str | None = None, job_id: int | None = None) -> str:
url = f"{_base(token, job_id)}/file"
return f"{url}?g={grant}" if grant else url
def _poster_url(
token: str, asset: "MediaAsset", grant: str | None = None, job_id: int | None = None
) -> str | None:
if not asset.poster_path:
return None
url = f"{_base(token, job_id)}/poster.jpg"
return f"{url}?g={grant}" if grant else url
def _folder_meta(db: Session, link: "DownloadLink", token: str, grant: str | None) -> dict:
"""A folder link's page: the folder's name and each file's own watch metadata
(`linksmod.folder_files`: ready, on disk, newest first)."""
folder = _link_folder(db, link)
files = [
{
"id": job.id,
**linksmod.public_meta(
link,
job,
asset,
_file_url(token, grant, job.id),
_poster_url(token, asset, grant, job.id),
),
}
for job, asset in linksmod.folder_files(db, folder)
]
return {
"needs_password": False,
"kind": "folder",
"name": folder.name,
"allow_download": link.allow_download,
"files": files,
}
def _file_meta(db: Session, link: "DownloadLink", token: str, grant: str | None) -> dict:
job, asset = _ready_target(db, link)
return {
"needs_password": False,
"kind": "file",
**linksmod.public_meta(
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
),
}
def _opened(db: Session, link: "DownloadLink", token: str, grant: str | None = None) -> dict:
"""The page an open link shows, counted as one view (the handler commits the count)."""
meta = (
_folder_meta(db, link, token, grant)
if link.folder_id is not None
else _file_meta(db, link, token, grant)
)
linksmod.count_view(db, link)
return meta
@router.get("/watch/{token}")
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
"""Public metadata for the watch page: one file's, or a folder's list. 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:
# Which kind it opens, for the password prompt's title — never what is in it.
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
meta = _opened(db, link, token)
db.commit()
return meta
class WatchUnlockIn(BaseModel):
password: str = ""
@router.post("/watch/{token}/unlock")
def watch_unlock(
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
) -> dict:
"""Exchange the link password for a signed grant carried by the media URLs."""
# The link first: a token naming none must leave no limiter entry behind, or posting made-up tokens
# would grow the limiter without bound.
link = _link_or_404(db, token)
viewer = f"{token}|{client_key(request)}"
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
raise refused(429, "too_many_attempts")
if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta.
meta = _opened(db, link, token)
db.commit()
return {"ok": True, **meta}
if not verify_password(body.password, link.password_hash):
_unlock_failures.allow(token)
raise refused(403, "wrong_share_password")
grant = linksmod.make_grant(token, link.password_version)
meta = _opened(db, link, token, grant)
db.commit()
return {"ok": True, **meta}
def _granted(link: "DownloadLink", token: str, g: str | None) -> None:
"""A password link's files want the grant its unlock handed out."""
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise refused(403, "share_password_required")
def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment."""
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
if path is None:
raise refused(404, "video_gone")
filename = storage.download_filename(asset.title or "video", asset.container, path)
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
# allow-list (storage.served_media_type) — NEVER guessed from the file's own extension. A generic
# 'file' (and any media whose container we don't positively recognise, e.g. a media/HTML polyglot
# named "evil.html") returns None → a neutral force-download attachment, so arbitrary uploaded
# HTML/SVG can't execute inline on this login-free public origin. A recognised media container is
# served with its real audio/video type, inline unless the link forces a download so it can play
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
media_type = storage.served_media_type(asset.media_kind, asset.container)
if media_type is None:
return FileResponse(
path,
filename=filename,
media_type="application/octet-stream",
content_disposition_type="attachment",
)
disposition = "attachment" if link.allow_download else "inline"
return FileResponse(
path, filename=filename, media_type=media_type, content_disposition_type=disposition
)
def _poster(asset: "MediaAsset") -> FileResponse:
if not asset.poster_path:
raise not_found("poster")
path = storage.safe_abs_path(settings.files_root, asset.poster_path)
if path is None:
raise not_found("poster")
return FileResponse(path, media_type="image/jpeg")
@router.get("/watch/{token}/file")
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""A file link's file. Password links require a valid grant."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _serve(link, _ready_target(db, link)[1])
@router.get("/watch/{token}/poster.jpg")
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _poster(_ready_target(db, link)[1])
@router.get("/watch/{token}/files/{job_id}/file")
def watch_folder_file(token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)):
"""One file of a folder link's folder, while it is in it. Password links require a grant."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _serve(link, _folder_job(db, link, job_id)[1])
@router.get("/watch/{token}/files/{job_id}/poster.jpg")
def watch_folder_poster(
token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)
):
"""The poster frame of one file of a folder link's folder, under the same terms as the file."""
link = _link_or_404(db, token)
_granted(link, token, g)
return _poster(_folder_job(db, link, job_id)[1])