From a56b8591517920d536d245357ccaed4ca14c34e9 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 16 Jul 2026 22:58:12 +0330 Subject: [PATCH] fix(downloads): preserve authenticated capture metadata Keep Gmail attachment filenames and origin-scoped browser cookies intact through metadata redirects, while rejecting Google sign-in responses.\n\nFixes #21 --- Extensions/Browser | 2 +- src-tauri/src/extension_server.rs | 132 ++++++++++++++++++- src-tauri/src/lib.rs | 187 +++++++++++++++++++++++++-- src/bindings/ExtensionCookieScope.ts | 3 + src/bindings/ExtensionDownload.ts | 3 +- src/components/AddDownloadsModal.tsx | 55 +++++++- src/ipc.ts | 3 +- src/store/useDownloadStore.test.ts | 36 ++++++ src/store/useDownloadStore.ts | 17 ++- 9 files changed, 406 insertions(+), 32 deletions(-) create mode 100644 src/bindings/ExtensionCookieScope.ts diff --git a/Extensions/Browser b/Extensions/Browser index 7e09b3f..637f97a 160000 --- a/Extensions/Browser +++ b/Extensions/Browser @@ -1 +1 @@ -Subproject commit 7e09b3f3a77a5bd995ddc8731f09fbb7b1612212 +Subproject commit 637f97a0dace1afc0390e43451be468bf2416468 diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index a48bbab..f2f304f 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -63,9 +63,18 @@ struct ExtensionRequest { #[serde(default)] cookies: Option, #[serde(default)] + cookie_scopes: Option>, + #[serde(default)] media: bool, } +#[derive(Clone, Deserialize, Serialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct ExtensionCookieScope { + pub url: String, + pub cookies: String, +} + #[derive(Clone, Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct ExtensionDownload { @@ -75,6 +84,7 @@ pub struct ExtensionDownload { filename: Option, headers: Option, cookies: Option, + cookie_scopes: Option>, media: bool, } @@ -314,7 +324,7 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool { false } -fn normalize_download(payload: ExtensionRequest) -> Option { +fn normalize_download(mut payload: ExtensionRequest) -> Option { let mut seen = HashSet::new(); let urls = payload .urls @@ -345,13 +355,26 @@ fn normalize_download(payload: ExtensionRequest) -> Option { // request headers, but drop Cookie headers and the dedicated cookie field // so a legacy or untrusted caller cannot reuse one session across hosts. let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1); - let cookies = if !payload.media && urls.len() == 1 { - payload - .cookies - .filter(|value| !value.trim().is_empty()) + let cookie_scopes = if !payload.media && urls.len() == 1 { + let mut scopes = payload.cookie_scopes.take().unwrap_or_default(); + if let Some(cookies) = payload.cookies.take() { + if !cookies.trim().is_empty() { + scopes.push(ExtensionCookieScope { + url: urls[0].clone(), + cookies, + }); + } + } + normalize_cookie_scopes(scopes) } else { None }; + let cookies = cookie_scopes.as_ref().and_then(|scopes| { + scopes + .iter() + .find(|scope| same_origin_url(&scope.url, &urls[0])) + .map(|scope| scope.cookies.clone()) + }); Some(ExtensionDownload { urls, @@ -365,10 +388,62 @@ fn normalize_download(payload: ExtensionRequest) -> Option { // builds pay for a doomed metadata request before retrying. Ordinary // captured downloads still need their exact request cookies. cookies, + cookie_scopes, media: payload.media, }) } +fn normalize_cookie_scopes(scopes: Vec) -> Option> { + let mut normalized = Vec::new(); + let mut seen_origins = HashSet::new(); + + for scope in scopes { + let Ok(url) = Url::parse(scope.url.trim()) else { + continue; + }; + if !matches!(url.scheme(), "http" | "https") { + continue; + } + let cookies = scope.cookies.trim(); + if cookies.is_empty() { + continue; + } + let Some(host) = url.host_str() else { + continue; + }; + let origin = format!( + "{}://{}:{}", + url.scheme(), + host, + url.port_or_known_default().unwrap_or(443) + ); + if !seen_origins.insert(origin) { + continue; + } + normalized.push(ExtensionCookieScope { + url: url.to_string(), + cookies: cookies.to_string(), + }); + if normalized.len() >= 16 { + break; + } + } + + (!normalized.is_empty()).then_some(normalized) +} + +fn same_origin_url(left: &str, right: &str) -> bool { + let Some(left) = Url::parse(left).ok() else { + return false; + }; + let Some(right) = Url::parse(right).ok() else { + return false; + }; + left.scheme() == right.scheme() + && left.host() == right.host() + && left.port_or_known_default() == right.port_or_known_default() +} + fn normalize_headers(headers: Option, media: bool) -> Option { let headers = headers?; if !media { @@ -532,7 +607,7 @@ mod tests { use super::{ add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce, is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest, - PROTOCOL_VERSION_HEADER, SERVER_HEADER, + ExtensionCookieScope, PROTOCOL_VERSION_HEADER, SERVER_HEADER, }; use axum::{ http::{HeaderMap, HeaderValue, StatusCode}, @@ -613,6 +688,7 @@ mod tests { filename: None, headers: None, cookies: None, + cookie_scopes: None, media: true, }); @@ -640,6 +716,7 @@ mod tests { "x".repeat(64 * 1024) )), cookies: Some(format!("large={}", "x".repeat(64 * 1024))), + cookie_scopes: None, media: true, }) .expect("valid media handoff"); @@ -658,6 +735,7 @@ mod tests { filename: None, headers: None, cookies: Some("session=browser-cookie-header".to_string()), + cookie_scopes: None, media: false, }) .expect("valid download handoff"); @@ -669,6 +747,47 @@ mod tests { ); } + #[test] + fn regular_capture_normalizes_host_scoped_cookie_headers() { + let download = normalize_download(ExtensionRequest { + urls: vec!["https://mail.google.com/mail/u/0/?view=att".to_string()], + referer: Some("https://mail.google.com/mail/u/0/".to_string()), + silent: true, + filename: Some("report.zip".to_string()), + headers: None, + cookies: Some("SID=mail-session".to_string()), + cookie_scopes: Some(vec![ + ExtensionCookieScope { + url: "https://mail.google.com/".to_string(), + cookies: "SID=mail-session".to_string(), + }, + ExtensionCookieScope { + url: "https://accounts.google.com/".to_string(), + cookies: "SID=account-session".to_string(), + }, + ExtensionCookieScope { + url: "https://mail.google.com/another-path".to_string(), + cookies: "duplicate=ignored".to_string(), + }, + ]), + media: false, + }) + .expect("valid download handoff"); + + assert_eq!(download.cookies.as_deref(), Some("SID=mail-session")); + assert_eq!( + download + .cookie_scopes + .as_ref() + .map(|scopes| scopes.len()), + Some(2) + ); + assert_eq!( + download.cookie_scopes.as_ref().unwrap()[1].cookies, + "SID=account-session" + ); + } + #[test] fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() { let download = normalize_download(ExtensionRequest { @@ -681,6 +800,7 @@ mod tests { filename: None, headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()), cookies: Some("session=secret".to_string()), + cookie_scopes: None, media: false, }) .expect("valid multi-url handoff"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3bd091c..e3796ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,7 +116,20 @@ fn filename_from_url_path(raw_url: &str) -> Option { // If the escape sequence cannot form valid UTF-8, retain the raw segment // so a malformed URL cannot erase an otherwise usable filename. let decoded = percent_decode_metadata_value(last).unwrap_or_else(|| last.to_string()); - sanitize_metadata_filename(&decoded) + let filename = sanitize_metadata_filename(&decoded)?; + let is_gmail_profile_path = parsed.host_str() == Some("mail.google.com") + && parsed.path().contains("/mail/u/"); + let is_gmail_profile_number = is_gmail_profile_path + && filename.chars().all(|character| character.is_ascii_digit()); + (!is_weak_url_filename(&filename) && !is_gmail_profile_number).then_some(filename) +} + +fn is_weak_url_filename(filename: &str) -> bool { + let normalized = filename.trim().to_ascii_lowercase(); + matches!( + normalized.as_str(), + "identifier" | "download" | "view" | "uc" + ) } fn metadata_filename_from_response( @@ -129,11 +142,13 @@ fn metadata_filename_from_response( .get(reqwest::header::CONTENT_DISPOSITION) .and_then(|value| value.to_str().ok()) .and_then(filename_from_content_disposition) + .filter(|filename| !is_weak_url_filename(filename)) { return filename; } filename_from_url_disposition_query(current_url) + .filter(|filename| !is_weak_url_filename(filename)) .or_else(|| filename_from_url_path(original_url)) .or_else(|| filename_from_url_path(current_url)) .unwrap_or_else(|| "download".to_string()) @@ -149,7 +164,11 @@ fn metadata_response_error(status: reqwest::StatusCode) -> Option { }) } -fn metadata_cookie_header_present(headers: Option<&str>, cookies: Option<&str>) -> bool { +fn metadata_cookie_header_present( + headers: Option<&str>, + cookies: Option<&str>, + cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>, +) -> bool { cookies.is_some_and(|value| !value.trim().is_empty()) || headers.is_some_and(|value| { value.lines().any(|line| { @@ -159,11 +178,14 @@ fn metadata_cookie_header_present(headers: Option<&str>, cookies: Option<&str>) name.trim().eq_ignore_ascii_case("cookie") && !value.trim().is_empty() }) }) + || cookie_scopes.is_some_and(|scopes| { + scopes.iter().any(|scope| !scope.cookies.trim().is_empty()) + }) } fn metadata_headers( headers: Option<&str>, - cookies: Option<&str>, + cookie_header: Option<&str>, include_cookies: bool, ) -> reqwest::header::HeaderMap { let mut header_map = reqwest::header::HeaderMap::new(); @@ -191,8 +213,11 @@ fn metadata_headers( } if include_cookies { - if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) { - if let Ok(value) = reqwest::header::HeaderValue::from_str(cookies) { + if let Some(cookie_header) = cookie_header + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Ok(value) = reqwest::header::HeaderValue::from_str(cookie_header) { header_map.insert(reqwest::header::COOKIE, value); } } @@ -1592,6 +1617,41 @@ fn should_send_metadata_credentials( .is_some_and(|(original, current)| same_origin(original, current)) } +fn cookie_scope_for_url<'a>( + current_url: &str, + cookie_scopes: Option<&'a [extension_server::ExtensionCookieScope]>, +) -> Option<&'a str> { + let current = reqwest::Url::parse(current_url).ok()?; + cookie_scopes?.iter().find_map(|scope| { + let scope_url = reqwest::Url::parse(&scope.url).ok()?; + let same_googleusercontent_site = scope_url.scheme() == current.scheme() + && scope_url.port_or_known_default() == current.port_or_known_default() + && scope_url.host_str() == Some("googleusercontent.com") + && current + .host_str() + .is_some_and(|host| host.ends_with(".googleusercontent.com")); + (same_origin(&scope_url, ¤t) || same_googleusercontent_site) + .then_some(scope.cookies.as_str()) + }) +} + +fn metadata_authentication_error(current_url: &str, content_type: Option<&str>) -> Option { + let url = reqwest::Url::parse(current_url).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + let path = url.path().to_ascii_lowercase(); + let content_type = content_type.unwrap_or_default().to_ascii_lowercase(); + let is_html = content_type.starts_with("text/html") + || content_type.starts_with("application/xhtml+xml"); + let is_google_auth_host = host == "accounts.google.com" + && (path.contains("signin") + || path.contains("servicelogin") + || url.query().is_some_and(|query| query.contains("continue="))); + + (is_google_auth_host && (is_html || path.contains("signin"))).then(|| { + "The server returned a Google sign-in page instead of the requested attachment. Re-authenticate in the browser and try again.".to_string() + }) +} + #[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed. #[tauri::command] async fn fetch_metadata( @@ -1601,6 +1661,7 @@ async fn fetch_metadata( password: Option, headers: Option, cookies: Option, + cookie_scopes: Option>, proxy: Option, defer_cookies: Option, ) -> Result { @@ -1609,7 +1670,11 @@ async fn fetch_metadata( let mut current_url = url.clone(); let original_origin = reqwest::Url::parse(&url).ok(); let mut redirects = 0; - let cookies_available = metadata_cookie_header_present(headers.as_deref(), cookies.as_deref()); + let cookies_available = metadata_cookie_header_present( + headers.as_deref(), + cookies.as_deref(), + cookie_scopes.as_deref(), + ); let defer_cookies = defer_cookies.unwrap_or(false); let mut send_cookies = !defer_cookies || !cookies_available; let mut cookie_retry_attempted = false; @@ -1657,16 +1722,29 @@ async fn fetch_metadata( } let current_origin = reqwest::Url::parse(¤t_url).ok(); - let should_send_auth = should_send_metadata_credentials( + let same_origin_credentials = should_send_metadata_credentials( original_origin.as_ref(), current_origin.as_ref(), redirects, ); - let header_map = if should_send_auth { - metadata_headers(headers.as_deref(), cookies.as_deref(), send_cookies) + let scoped_cookie = if send_cookies { + cookie_scope_for_url(¤t_url, cookie_scopes.as_deref()) } else { - reqwest::header::HeaderMap::new() + None + }; + let cookie_header = if same_origin_credentials { + scoped_cookie.or(cookies.as_deref()) + } else { + scoped_cookie + }; + let header_map = if same_origin_credentials { + metadata_headers(headers.as_deref(), cookie_header, send_cookies) + } else { + // A cross-origin redirect may receive only the cookie scope that + // explicitly matches its host. Never forward captured headers or + // a source-host Cookie header to an unrelated redirect target. + metadata_headers(None, cookie_header, send_cookies) }; builder = builder.default_headers(header_map); @@ -1677,7 +1755,7 @@ async fn fetch_metadata( let mut get_req = client .get(&request_url) .header(reqwest::header::RANGE, "bytes=0-0"); - if should_send_auth { + if same_origin_credentials { if let Some(ref user) = username { if !user.is_empty() { get_req = get_req.basic_auth(user, password.as_deref()); @@ -1688,7 +1766,7 @@ async fn fetch_metadata( }; let mut head_req = client.head(&request_url); - if should_send_auth { + if same_origin_credentials { if let Some(ref user) = username { if !user.is_empty() { head_req = head_req.basic_auth(user, password.as_deref()); @@ -1760,6 +1838,16 @@ async fn fetch_metadata( } } + if let Some(error) = metadata_authentication_error( + ¤t_url, + current_res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + ) { + return Err(error); + } + if let Some(error) = metadata_response_error(current_res.status()) { return Err(error); } @@ -6090,7 +6178,8 @@ mod tests { filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format, is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key, media_output_template, media_progress_args, media_progress_speed, - metadata_cookie_header_present, metadata_headers, metadata_response_error, + cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, + metadata_headers, metadata_response_error, normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, @@ -6600,7 +6689,8 @@ mod tests { let headers = "Cookie: oversized=secret\nReferer: https://example.com/page"; assert!(metadata_cookie_header_present( Some(headers), - Some("session=secret") + Some("session=secret"), + None )); let initial_headers = metadata_headers(Some(headers), Some("session=secret"), false); @@ -6621,6 +6711,43 @@ mod tests { ); } + #[test] + fn metadata_cookie_scopes_select_only_the_matching_origin() { + let scopes = vec![ + crate::extension_server::ExtensionCookieScope { + url: "https://mail.google.com/".to_string(), + cookies: "mail=session".to_string(), + }, + crate::extension_server::ExtensionCookieScope { + url: "https://accounts.google.com/".to_string(), + cookies: "account=session".to_string(), + }, + crate::extension_server::ExtensionCookieScope { + url: "https://googleusercontent.com/".to_string(), + cookies: "googleusercontent=session".to_string(), + }, + ]; + + assert_eq!( + cookie_scope_for_url("https://mail.google.com/mail/u/0/", Some(&scopes)), + Some("mail=session") + ); + assert_eq!( + cookie_scope_for_url("https://accounts.google.com/v3/signin/identifier", Some(&scopes)), + Some("account=session") + ); + assert_eq!( + cookie_scope_for_url("https://mail-attachment.googleusercontent.com/file", Some(&scopes)), + Some("googleusercontent=session") + ); + assert_eq!( + cookie_scope_for_url("https://attacker.example/redirect", Some(&scopes)), + None + ); + assert!(!metadata_cookie_header_present(None, None, Some(&scopes[..0]))); + assert!(metadata_cookie_header_present(None, None, Some(&scopes))); + } + #[test] fn metadata_cookie_retry_is_limited_to_one_auth_challenge() { let mut send_cookies = false; @@ -6865,6 +6992,38 @@ mod tests { filename_from_url_path("https://example.com/files/a%2Fb.mkv"), Some("b.mkv".to_string()) ); + assert_eq!( + filename_from_url_path("https://accounts.google.com/v3/signin/identifier"), + None + ); + assert_eq!( + filename_from_url_path("https://mail.google.com/mail/u/0/"), + None + ); + assert_eq!( + filename_from_url_path("https://example.com/files/123"), + Some("123".to_string()) + ); + } + + #[test] + fn metadata_rejects_google_sign_in_interstitials() { + let message = metadata_authentication_error( + "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fmail.google.com%2F", + Some("text/html; charset=UTF-8"), + ) + .expect("Google sign-in HTML must not become a download"); + assert!(message.contains("Google sign-in page")); + assert!(metadata_authentication_error( + "https://accounts.google.com/drive/download/report.zip", + Some("application/zip"), + ) + .is_none()); + assert!(metadata_authentication_error( + "https://downloads.example.com/signin/identifier", + Some("text/html"), + ) + .is_none()); } #[test] diff --git a/src/bindings/ExtensionCookieScope.ts b/src/bindings/ExtensionCookieScope.ts new file mode 100644 index 0000000..9efabc5 --- /dev/null +++ b/src/bindings/ExtensionCookieScope.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExtensionCookieScope = { url: string, cookies: string, }; diff --git a/src/bindings/ExtensionDownload.ts b/src/bindings/ExtensionDownload.ts index c2af9a2..0f0e4b4 100644 --- a/src/bindings/ExtensionDownload.ts +++ b/src/bindings/ExtensionDownload.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExtensionCookieScope } from "./ExtensionCookieScope"; -export type ExtensionDownload = { urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, }; +export type ExtensionDownload = { urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index a46a63b..fb76e4e 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -51,9 +51,48 @@ const normalizeComparableUrl = (rawUrl: string) => { } }; -const urlsHaveDifferentHosts = (sourceUrl: string, targetUrl: string) => { +const urlsHaveDifferentOrigins = (sourceUrl: string, targetUrl: string) => { try { - return new URL(sourceUrl).hostname.toLowerCase() !== new URL(targetUrl).hostname.toLowerCase(); + const source = new URL(sourceUrl); + const target = new URL(targetUrl); + return source.protocol !== target.protocol + || source.hostname.toLowerCase() !== target.hostname.toLowerCase() + || source.port !== target.port; + } catch { + return false; + } +}; + +const cookieScopeForUrl = (context: PendingAddRequestContext | undefined, targetUrl: string) => { + if (!context?.cookieScopes?.length) return ''; + try { + const target = new URL(targetUrl); + return context.cookieScopes.find(scope => { + try { + const scopeUrl = new URL(scope.url); + const sameGoogleusercontentSite = scopeUrl.protocol === target.protocol + && scopeUrl.port === target.port + && scopeUrl.hostname.toLowerCase() === 'googleusercontent.com' + && target.hostname.toLowerCase().endsWith('.googleusercontent.com'); + return sameGoogleusercontentSite || (scopeUrl.protocol === target.protocol + && scopeUrl.hostname.toLowerCase() === target.hostname.toLowerCase() + && scopeUrl.port === target.port); + } catch { + return false; + } + })?.cookies.trim() || ''; + } catch { + return ''; + } +}; + +const isGoogleAuthenticatedCaptureUrl = (rawUrl: string) => { + try { + const hostname = new URL(rawUrl).hostname.toLowerCase(); + return hostname === 'mail.google.com' + || hostname === 'accounts.google.com' + || hostname === 'googleusercontent.com' + || hostname.endsWith('.googleusercontent.com'); } catch { return false; } @@ -152,14 +191,16 @@ export const AddDownloadsModal = () => { const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => { if (cookiesManuallyEditedRef.current) return cookies.trim(); const context = requestContextForUrl(sourceUrl); - if (context && context.cookies && urlsHaveDifferentHosts(sourceUrl, targetUrl)) { - return ''; - } + const scopedCookies = cookieScopeForUrl(context, targetUrl); + if (scopedCookies) return scopedCookies; + if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return ''; if (context) return context.cookies.trim(); return hasExtensionRequestContext ? '' : cookies.trim(); }; const shouldDeferCookiesForRow = (sourceUrl: string) => - !cookiesManuallyEditedRef.current && Boolean(requestContextForUrl(sourceUrl)); + !cookiesManuallyEditedRef.current + && Boolean(requestContextForUrl(sourceUrl)) + && !isGoogleAuthenticatedCaptureUrl(sourceUrl); const suggestedFilenameForRow = (sourceUrl: string) => { const context = requestContextForUrl(sourceUrl); if (context?.filename) return context.filename; @@ -389,6 +430,7 @@ export const AddDownloadsModal = () => { const proxy = await getProxyArgs(settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore); const contextUrl = requestContextUrlForRow(row); + const requestContext = requestContextForUrl(contextUrl); if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) { settingsStore.setShowKeychainModal(true); return; @@ -493,6 +535,7 @@ export const AddDownloadsModal = () => { password: useAuth ? password || null : keychainPassword, headers: headersForRow(contextUrl) || null, cookies: cookiesForRow(contextUrl, row.sourceUrl) || null, + cookieScopes: requestContext?.cookieScopes || null, proxy, deferCookies: shouldDeferCookiesForRow(row.sourceUrl) }); diff --git a/src/ipc.ts b/src/ipc.ts index 024a13b..ba73b72 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -5,6 +5,7 @@ import type { DownloadCategory } from './bindings/DownloadCategory'; import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; import type { DownloadStateEvent } from './bindings/DownloadStateEvent'; import type { ExtensionDownload } from './bindings/ExtensionDownload'; +import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope'; import type { MediaMetadata } from './bindings/MediaMetadata'; import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata'; import type { MetadataResponse } from './bindings/MetadataResponse'; @@ -18,7 +19,7 @@ import type { PlatformInfo } from './bindings/PlatformInfo'; type CommandMap = { fetch_metadata: { - args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null; deferCookies?: boolean }; + args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; cookieScopes: Array | null; proxy: string | null; deferCookies?: boolean }; result: MetadataResponse; }; fetch_media_metadata: { diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 928f55c..ad8bb73 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1318,6 +1318,10 @@ describe('useDownloadStore', () => { filename: 'file.bin', headers: 'X-Test: value', cookies: 'session=secret', + cookie_scopes: [ + { url: 'https://mail.google.com/', cookies: 'SID=mail-session' }, + { url: 'https://accounts.google.com/', cookies: 'SID=account-session' } + ], media: false }); @@ -1328,6 +1332,10 @@ describe('useDownloadStore', () => { expect(state.pendingAddFilename).toBe('file.bin'); expect(state.pendingAddHeaders).toBe('X-Test: value'); expect(state.pendingAddCookies).toBe('session=secret'); + expect(state.pendingAddRequestContexts['https://example.com/file.bin'].cookieScopes).toEqual([ + { url: 'https://mail.google.com/', cookies: 'SID=mail-session' }, + { url: 'https://accounts.google.com/', cookies: 'SID=account-session' } + ]); expect(state.pendingAddMediaUrls).toEqual([]); }); @@ -1349,6 +1357,7 @@ describe('useDownloadStore', () => { filename: null, headers: 'User-Agent: Firefox Test', cookies: null, + cookie_scopes: null, media: false }); @@ -1369,6 +1378,7 @@ describe('useDownloadStore', () => { filename: 'report.pdf', headers: 'User-Agent: Test', cookies: 'session=secret', + cookie_scopes: null, media: false }); @@ -1390,6 +1400,7 @@ describe('useDownloadStore', () => { filename: 'first.zip', headers: 'User-Agent: First Browser', cookies: 'first=session', + cookie_scopes: null, media: false }); await useDownloadStore.getState().handleExtensionDownload({ @@ -1399,6 +1410,7 @@ describe('useDownloadStore', () => { filename: 'second.zip', headers: 'User-Agent: Second Browser', cookies: 'second=session', + cookie_scopes: null, media: false }); @@ -1435,6 +1447,7 @@ describe('useDownloadStore', () => { filename: null, headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`, cookies: `oversized=${'x'.repeat(64 * 1024)}`, + cookie_scopes: null, media: true }); @@ -1454,12 +1467,31 @@ describe('useDownloadStore', () => { filename: 'private.zip', headers: null, cookies: 'session=secret', + cookie_scopes: null, media: false }); expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret'); }); + it('drops extension cookie scopes for explicit media captures', async () => { + await useDownloadStore.getState().handleExtensionDownload({ + urls: ['https://media.example/watch/123'], + referer: 'https://media.example/watch/123', + silent: true, + filename: null, + headers: null, + cookies: null, + cookie_scopes: [ + { url: 'https://media.example/', cookies: 'session=secret' } + ], + media: true + }); + + expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes) + .toBeUndefined(); + }); + it('clears stale request context when the same URL is captured without it later', async () => { const url = 'https://example.com/file.zip'; await useDownloadStore.getState().handleExtensionDownload({ @@ -1469,6 +1501,7 @@ describe('useDownloadStore', () => { filename: 'private.zip', headers: 'Authorization: secret', cookies: 'session=secret', + cookie_scopes: null, media: false }); await useDownloadStore.getState().handleExtensionDownload({ @@ -1478,6 +1511,7 @@ describe('useDownloadStore', () => { filename: null, headers: null, cookies: null, + cookie_scopes: null, media: false }); @@ -1505,6 +1539,7 @@ describe('useDownloadStore', () => { filename: null, headers: 'User-Agent: Firefox Test', cookies: 'session=secret', + cookie_scopes: null, media: true }); @@ -1524,6 +1559,7 @@ describe('useDownloadStore', () => { filename: 'file.bin', headers: 'User-Agent: Firefox Test', cookies: null, + cookie_scopes: null, media: false }); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index bd19794..6313c7d 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -5,6 +5,7 @@ import { invokeCommand as invoke } from '../ipc'; import type { DownloadItem } from '../bindings/DownloadItem'; import type { DownloadStatus } from '../bindings/DownloadStatus'; import type { ExtensionDownload } from '../bindings/ExtensionDownload'; +import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; @@ -451,6 +452,7 @@ export type PendingAddRequestContext = { filename: string; headers: string; cookies: string; + cookieScopes?: ExtensionCookieScope[]; media: boolean; }; @@ -487,7 +489,8 @@ interface DownloadState { filename?: string | null, headers?: string | null, cookies?: string | null, - media?: boolean + media?: boolean, + cookieScopes?: ExtensionCookieScope[] | null ) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise; deleteModalState: DeleteModalState; @@ -652,7 +655,7 @@ export const useDownloadStore = create((set, get) => ({ // opened or closed without URLs. pendingAddRequestVersion: state.pendingAddRequestVersion + 1 })), - openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => { + openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false, cookieScopes) => set((state) => { const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls); const existingUrls = isAppending ? state.pendingAddUrls : ''; const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls; @@ -660,6 +663,12 @@ export const useDownloadStore = create((set, get) => ({ const cleanFilename = filename?.trim() || ''; const cleanHeaders = headers?.trim() || ''; const cleanCookies = cookies?.trim() || ''; + const cleanCookieScopes = cookieScopes + ?.map(scope => ({ + url: scope.url.trim(), + cookies: scope.cookies.trim() + })) + .filter(scope => scope.url && scope.cookies); const requestVersion = state.pendingAddRequestVersion + 1; const pendingAddRequestContexts = isAppending ? { ...state.pendingAddRequestContexts } @@ -683,6 +692,7 @@ export const useDownloadStore = create((set, get) => ({ filename: cleanFilename, headers: cleanHeaders, cookies: cleanCookies, + ...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}), media }; } @@ -719,7 +729,8 @@ export const useDownloadStore = create((set, get) => ({ urls.length === 1 ? request.filename : null, headers, cookies, - request.media === true + request.media === true, + request.media === true ? undefined : request.cookie_scopes ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),