chore(downloads): C1+C2 — drop dead target_ext; centralize path-traversal guard

- C1: remove downloads/formats.py target_ext() — defined but never called (the
  worker derives the real extension from the produced file's suffix).
- C2: the download-root containment+existence guard was copy-pasted 6× across the
  file-serving endpoints (routes/downloads.py ×3, routes/public.py ×3). Extract
  storage.safe_abs_path(root, rel) -> Path|None so this security-sensitive check
  lives in one place; behavior identical (same containment test + messages). The
  extraction also made `pathlib.Path` unused in both route modules (removed).
This commit is contained in:
npeter83 2026-07-11 05:41:43 +02:00
parent c2a2c98f16
commit 2a44db04d8
4 changed files with 23 additions and 28 deletions

View file

@ -5,7 +5,6 @@ credential (a capability URL). They only ever expose one file's stream + minimal
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 pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
@ -42,9 +41,8 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
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.abs_path(settings.download_root, asset.rel_path).resolve()
root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists():
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
@ -116,9 +114,8 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
if link.password_hash and not linksmod.check_grant(token, g):
raise HTTPException(status_code=403, detail="This link needs a password.")
asset = _ready_asset(db, link)
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists():
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.")
ext = asset.container or path.suffix.lstrip(".")
@ -141,8 +138,7 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
job, asset = _ready_target(db, link)
if not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.")
path = storage.abs_path(settings.download_root, asset.poster_path).resolve()
root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists():
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")