fix(downloads): Retry and Re-download take a cookie added after the failure

The login-wall copy tells the user to add their cookies and press Retry, but
Retry kept the job on the shared asset the worker never attaches a cookie to,
so it failed on the same wall. Resume now moves a pending/errored job onto the
asset enqueue would pick with the cookie (never off a file or a download in
flight).

Re-download salted its fresh asset with the job's previous asset id, which the
cookie gate cannot recompute, so even a cookie download rebuilt without the
cookie. The rebuild salt is now the new asset's own id (plus the user's cookie
salt when one applies), and is_authed_asset recognises it.
This commit is contained in:
2026-09-13 22:31:49 +02:00
parent 18c11ca437
commit 98ec1ffcab
6 changed files with 229 additions and 28 deletions
+26 -9
View File
@@ -1,10 +1,12 @@
"""Per-user, per-host download cookies — the single source of truth for "does the acting user's
cookie apply to this download, and how does that keep the result private".
Two call sites depend on ONE consistent answer (house rule: an invariant lives in shared code, not
Two sides depend on ONE consistent answer (house rule: an invariant lives in shared code, not
in prose):
* `enqueue` salts the MediaAsset cache signature with the user id WHEN a cookie applies, so the
authed download becomes a PRIVATE asset that is never cache-served to another user.
authed download becomes a PRIVATE asset that is never cache-served to another user. Retry
(`service.resume`) and Re-download (`service.redownload`) ask the same question again, so a
cookie the user added after a failure goes with the next attempt.
* the worker attaches the cookie file to yt-dlp ONLY when a cookie still applies AND the asset it
is downloading into is that private, salted identity — so a cookie can never poison a shared
asset (the load-bearing cross-user privacy fix).
@@ -30,7 +32,7 @@ from sqlalchemy.orm import Session
from app.downloads import formats
from app.downloads.adapters import _host_is
from app.models import DownloadCredential
from app.models import DownloadCredential, MediaAsset
from app.security import decrypt_optional, encrypt
log = logging.getLogger("siftlode.downloads.credentials")
@@ -104,12 +106,27 @@ def authed_format_sig(spec: dict, user_id: int, host_key: str) -> str:
return formats.format_sig(spec, salt=cookie_salt(user_id, host_key))
def is_authed_asset(asset_format_sig: str, spec: dict, user_id: int, url: str | None) -> bool:
def rebuild_salt(asset_id: int, cookie: str | None) -> str:
"""The salt of a Re-download's fresh asset: its OWN row id (so every rebuild is a new identity no
enqueue can ever compute), prefixed with the user's `cookie_salt` when a cookie applies. The id is
what makes it recognisable — the worker has the asset row, so `is_authed_asset` can recompute this
one, where a salt built from the job's previous asset id could not be."""
return f"{cookie}:rebuild:{asset_id}" if cookie else f"rebuild:{asset_id}"
def is_authed_asset(asset: MediaAsset, spec: dict, user_id: int, url: str | None) -> bool:
"""Whether an asset is `user_id`'s private, cookie-salted identity for `url`'s host — the one
kind of asset a cookie is ever attached to. The worker's cookie gate asks it before a download,
kind of asset a cookie is ever attached to: the one enqueue creates (`authed_format_sig`), or a
Re-download's rebuild of it (`rebuild_salt`). The worker's cookie gate asks it before a download,
and the failure diagnosis asks it after one (a failure on this asset happened WITH the cookie)."""
host_key = host_key_for_url(url)
return host_key is not None and asset_format_sig == authed_format_sig(spec, user_id, host_key)
if host_key is None:
return False
if asset.format_sig == authed_format_sig(spec, user_id, host_key):
return True
return asset.id is not None and asset.format_sig == formats.format_sig(
spec, salt=rebuild_salt(asset.id, cookie_salt(user_id, host_key))
)
def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
@@ -211,7 +228,7 @@ def resolve_cookiefile(
db: Session,
user_id: int,
url: str | None,
asset_format_sig: str,
asset: MediaAsset,
spec: dict,
staging: Path,
) -> Path | None:
@@ -220,7 +237,7 @@ def resolve_cookiefile(
The double gate that prevents cross-user leakage:
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
2. the asset being downloaded into must BE this user's private, salted identity
(`asset_format_sig == authed_format_sig(...)`).
(`is_authed_asset`).
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
@@ -232,7 +249,7 @@ def resolve_cookiefile(
cred = get_credential(db, user_id, host_key)
if cred is None:
return None
if not is_authed_asset(asset_format_sig, spec, user_id, url):
if not is_authed_asset(asset, spec, user_id, url):
return None # shared asset — never attach a cookie to a non-private identity
raw = decrypt_optional(cred.cookies_enc)
if not raw: