feat(downloads): per-user, per-host yt-dlp cookie/auth support (S6)
Each user stores their own login cookies (Netscape cookies.txt) for an auth-walled download host (Facebook, Instagram, X, TikTok, Reddit) in Settings; a download of an auth-walled link on that host is retried with the acting user's cookie. Two load-bearing security controls, both in downloads.credentials as the single source of truth so enqueue and the worker can't drift: - cache privacy: a cookie-authed download salts its MediaAsset format_sig with the user id, so the authed asset is private and never cache-served to another user. The worker attaches a cookie ONLY when the asset it is downloading into IS that private, salted identity. - host scope (SEC-12): a cookie is resolved only for a URL whose netloc the credential owns (netloc-scoped _host_is); a look-alike/unrelated host gets none. cookies_enc is Fernet-encrypted at rest, only decrypted in-worker into a 0600 file under staging/.auth/ (survives the retry wipe + _produced_file scan) and unlinked in finally. The blob is never returned by a route, logged, audited, or written to a job's error/snapshot. cookiefile is set after the adapter's extra merge so an adapter can't clobber it. Settings UI is write-only (host + set/updated only, never echoed). New download_credentials table + migration 0074; per-user routes; HU+EN i18n. Tests: host-scope, cache-privacy (no cross-user serve), encrypt-at-rest, write-only API, worker attach-gate; FE dom test for the write-only field.
This commit is contained in:
@@ -43,6 +43,8 @@ function localizeDetail(d: unknown): string {
|
||||
}
|
||||
if (o?.code === "edit")
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,31 @@ export interface AdminDownloadQuota {
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
// Per-user download authentication (cookies). Write-only: the API never returns the stored blob,
|
||||
// only which supported hosts are configured + when. See backend downloads.credentials.
|
||||
export interface DownloadCredentialHost {
|
||||
host_key: string;
|
||||
label: string;
|
||||
configured: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export const downloadsApi = {
|
||||
downloadCredentials: (): Promise<{ hosts: DownloadCredentialHost[] }> =>
|
||||
req("/api/downloads/credentials"),
|
||||
setDownloadCredential: (
|
||||
hostKey: string,
|
||||
cookies: string,
|
||||
): Promise<{ ok: true; hosts: DownloadCredentialHost[] }> =>
|
||||
req(`/api/downloads/credentials/${encodeURIComponent(hostKey)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ cookies }),
|
||||
}),
|
||||
deleteDownloadCredential: (
|
||||
hostKey: string,
|
||||
): Promise<{ ok: true; hosts: DownloadCredentialHost[] }> =>
|
||||
req(`/api/downloads/credentials/${encodeURIComponent(hostKey)}`, { method: "DELETE" }),
|
||||
|
||||
downloadProfiles: (): Promise<DownloadProfile[]> => req("/api/downloads/profiles"),
|
||||
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
|
||||
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
||||
|
||||
@@ -78,6 +78,7 @@ export const qk = {
|
||||
downloadIndex: () => ["download-index"] as const,
|
||||
downloadUsage: () => ["download-usage"] as const,
|
||||
downloadProfiles: () => ["download-profiles"] as const,
|
||||
downloadCredentials: () => ["download-credentials"] as const,
|
||||
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
||||
dlQuota: (userId: number) => ["dl-quota", userId] as const,
|
||||
storyboard: (jobId: number) => ["storyboard", jobId] as const,
|
||||
|
||||
Reference in New Issue
Block a user