- C3: `_reference_url` (downloads.py) and public.py's inline source-URL block were the same rule → extract `service.reference_url(job, asset)`; both surfaces now share it so the "downloaded from" link can't drift between them. - C4: Content-Disposition filename derivation (ext pick + doubled-ext strip + join) was duplicated in download_file and watch_file → extract `storage.download_filename(display_name, container, path)`. - C5: inline the one-line `_clean_basename` passthrough (folded into C4). - C6: the `db.get(MediaAsset, job.asset_id) if job.asset_id else None; _serialize(...)` resolve-then-serialize dance was repeated across 6 single-job handlers → fold into `_serialize_job(db, job)`. (File-serving handlers that use the asset for their own checks keep their explicit resolve.) Behavior-neutral; ruff/parse clean, localdev boots, downloads routes load.
91 lines
3.5 KiB
Python
91 lines
3.5 KiB
Python
"""Public "share by link" helpers for the download center.
|
|
|
|
A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one
|
|
download's file on the login-free `/watch/{token}` page. Password-protected links additionally
|
|
require a short-lived signed **grant** on the file request — because a `<video src>` can't send a
|
|
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
|
|
so the raw password never rides in the media URL (or server logs).
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
|
|
from app.config import settings
|
|
from app.models import DownloadLink
|
|
|
|
_GRANT_TTL = 6 * 3600 # a watch session's grant is good for 6h, then the viewer re-unlocks
|
|
|
|
|
|
def new_token() -> str:
|
|
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
|
|
|
|
|
|
def is_expired(link: DownloadLink) -> bool:
|
|
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
|
|
|
|
|
# --- signed grants (password-protected file access) ----------------------------------------
|
|
|
|
def _sign(msg: str) -> str:
|
|
return hmac.new(
|
|
settings.secret_key.encode(), msg.encode(), hashlib.sha256
|
|
).hexdigest()[:32]
|
|
|
|
|
|
def make_grant(token: str, now: float | None = None) -> str:
|
|
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`."""
|
|
exp = int((now if now is not None else datetime.now(timezone.utc).timestamp())) + _GRANT_TTL
|
|
return f"{exp}.{_sign(f'{token}.{exp}')}"
|
|
|
|
|
|
def check_grant(token: str, grant: str | None) -> bool:
|
|
if not grant or "." not in grant:
|
|
return False
|
|
exp_s, sig = grant.split(".", 1)
|
|
try:
|
|
exp = int(exp_s)
|
|
except ValueError:
|
|
return False
|
|
if exp < datetime.now(timezone.utc).timestamp():
|
|
return False
|
|
return hmac.compare_digest(sig, _sign(f"{token}.{exp}"))
|
|
|
|
|
|
# --- serialization -------------------------------------------------------------------------
|
|
|
|
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
|
"""What the file owner sees when managing their links."""
|
|
return {
|
|
"id": link.id,
|
|
"token": link.token,
|
|
"url": f"{base_url}/watch/{link.token}",
|
|
"allow_download": link.allow_download,
|
|
"has_password": link.password_hash is not None,
|
|
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
|
"view_count": link.view_count,
|
|
"created_at": link.created_at.isoformat() if link.created_at else None,
|
|
}
|
|
|
|
|
|
def public_meta(link: DownloadLink, job, asset, file_url: str, poster_url: str | None = None) -> dict:
|
|
"""Metadata for the public watch page (only ever returned once access is authorized).
|
|
|
|
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
|
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
|
from app.downloads import service
|
|
|
|
source_url = service.reference_url(job, asset)
|
|
return {
|
|
"title": (job and job.display_name) or asset.title,
|
|
"uploader": (job and job.display_uploader) or asset.uploader,
|
|
"uploader_url": (job and job.display_uploader_url) or asset.uploader_url,
|
|
"source_url": source_url,
|
|
"extra_links": (job and job.extra_links) or [],
|
|
"duration_s": asset.duration_s,
|
|
"width": asset.width,
|
|
"height": asset.height,
|
|
"allow_download": link.allow_download,
|
|
"file_url": file_url,
|
|
"poster_url": poster_url,
|
|
}
|