Deep-review remediation for the E7 upload path, all findings adversarially verified: - Serve every library file with a Content-Type chosen from the ffprobe-verified media_kind + a strict container allow-list (storage.served_media_type), never guessed from the user-controlled file extension. Closes a stored-XSS vector where a media/HTML polyglot uploaded as "evil.html" was served inline as text/html on the login-free /watch surface. Applied to both the authenticated /file and the public watch_file endpoints. - Classify uploaded still images (jpg/png/gif/webp/…) as media_kind='file' so they are force-downloaded and don't get a broken in-app video player. - Roll back the on-disk side of an upload when the ingest transaction fails after place_file (unlink the placed file + its deterministic poster path), so a failed commit can't orphan bytes in the GC-skipped .uploads tree. - Floor download_upload_max_bytes at min=1 so a stored 0 can't silently disable the per-file cap. - Dedupe the ffprobe-JSON invocation into edit.run_ffprobe_json (shared by probe_codecs and upload._probe) and the upload extension into storage._clean_upload_ext; drop the now-unused parse_probe_streams. Tests: public + authenticated force-download and the polyglot-not-served-as-HTML guard, the real _probe classifier (image/cover-art/dims/duration), the ingest rollback/cleanup path, the max_jobs branch, the cap-floor invariant, and run_ffprobe_json error handling.
160 lines
7.2 KiB
Python
160 lines
7.2 KiB
Python
"""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 fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
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_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
|
job = db.get(DownloadJob, link.job_id)
|
|
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
|
if job is None or 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.safe_abs_path(settings.download_root, asset.rel_path)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
|
return job, asset
|
|
|
|
|
|
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
|
|
return _ready_target(db, link)[1]
|
|
|
|
|
|
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
|
|
|
|
|
|
def _poster_url(token: str, asset, grant: str | None = None) -> str | None:
|
|
if not asset.poster_path:
|
|
return None
|
|
base = f"/api/public/watch/{token}/poster.jpg"
|
|
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}
|
|
job, asset = _ready_target(db, link)
|
|
link.view_count += 1
|
|
db.commit()
|
|
return {
|
|
"needs_password": False,
|
|
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
|
}
|
|
|
|
|
|
class WatchUnlockIn(BaseModel):
|
|
password: str = ""
|
|
|
|
|
|
@router.post("/watch/{token}/unlock")
|
|
def watch_unlock(token: str, body: WatchUnlockIn, 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.
|
|
job, asset = _ready_target(db, link)
|
|
return {
|
|
"needs_password": False,
|
|
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
|
}
|
|
if not verify_password(body.password, link.password_hash):
|
|
raise HTTPException(status_code=403, detail="Wrong password.")
|
|
job, asset = _ready_target(db, link)
|
|
link.view_count += 1
|
|
db.commit()
|
|
grant = linksmod.make_grant(token, link.password_version)
|
|
return {
|
|
"needs_password": False,
|
|
**linksmod.public_meta(
|
|
link, job, asset, _file_url(token, grant), _poster_url(token, asset, 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, link.password_version):
|
|
raise HTTPException(status_code=403, detail="This link needs a password.")
|
|
asset = _ready_asset(db, link)
|
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
|
|
|
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
|
|
)
|
|
|
|
|
|
@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)
|
|
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
|
raise HTTPException(status_code=403, detail="This link needs a password.")
|
|
job, asset = _ready_target(db, link)
|
|
if not asset.poster_path:
|
|
raise HTTPException(status_code=404, detail="No poster.")
|
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="No poster.")
|
|
return FileResponse(path, media_type="image/jpeg")
|