The nine Capture tables (files, jobs, folders, shares, links, quota, cookies, profiles, library resume points) leave app/models.py for the unit's own module; app.tables registers every unit's tables for the migrations, the test database and the ownership check. No schema change.
298 lines
12 KiB
Python
298 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 fastapi import APIRouter, Depends, Request
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, update
|
|
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.downloads.status import JobStatus
|
|
from app.models import User
|
|
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
|
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
|
|
|
|
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
|
|
owner = db.get(User, job.user_id)
|
|
return owner is not None and not owner.is_suspended
|
|
|
|
|
|
def _ready_job(db: Session, job: DownloadJob | None) -> tuple[DownloadJob, MediaAsset]:
|
|
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
|
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, db.get(DownloadJob, link.job_id))
|
|
|
|
|
|
def _link_folder(db: Session, link: DownloadLink) -> FileFolder:
|
|
folder = db.get(FileFolder, link.folder_id) if link.folder_id is not None else None
|
|
if folder is None:
|
|
raise refused(404, "link_gone")
|
|
return folder
|
|
|
|
|
|
def _folder_job(db: Session, link: DownloadLink, job_id: int) -> tuple[DownloadJob, MediaAsset]:
|
|
folder = _link_folder(db, link)
|
|
job = db.execute(
|
|
select(DownloadJob).where(DownloadJob.id == job_id, *linksmod.in_folder(folder))
|
|
).scalar_one_or_none()
|
|
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)
|
|
)
|
|
# In SQL, not `+= 1` on the loaded row: two opens at once would both write the same count.
|
|
db.execute(
|
|
update(DownloadLink)
|
|
.where(DownloadLink.id == link.id)
|
|
.values(view_count=DownloadLink.view_count + 1)
|
|
.execution_options(synchronize_session=False)
|
|
)
|
|
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])
|