From 8fd73a145e52d9f2554318187e4946a490cfe5fa Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 8 Sep 2026 21:15:31 +0330 Subject: [PATCH] fix(extension): support browser-local torrent attachments - Cache signed browser-local torrent bytes in the desktop handoff server.\n- Preserve managed torrent identity through Add-window inspection and rekey.\n- Advance the tested browser companion submodule to the published capture fix. --- Extensions/Browser | 2 +- src-tauri/src/extension_server.rs | 167 ++++++++++++++++++++++++-- src/bindings/ExtensionDownload.ts | 2 +- src/components/AddDownloadsModal.tsx | 23 +++- src/store/useDownloadStore.test.ts | 55 +++++++++ src/store/useDownloadStore.ts | 29 +++-- src/utils/addDownloadMetadata.test.ts | 26 ++++ src/utils/addDownloadMetadata.ts | 17 ++- 8 files changed, 294 insertions(+), 27 deletions(-) diff --git a/Extensions/Browser b/Extensions/Browser index fbede1c..84672ab 160000 --- a/Extensions/Browser +++ b/Extensions/Browser @@ -1 +1 @@ -Subproject commit fbede1cdc81610d8fcc4775ee7db858fbb1c3a51 +Subproject commit 84672ab6bceb669859382eea2c182b0656edd76c diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index dcb270a..26f8a73 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -7,6 +7,7 @@ use axum::{ routing::{get, post}, Router, }; +use base64::Engine as _; use hmac::{Hmac, KeyInit, Mac}; use reqwest::Url; use serde::{Deserialize, Serialize}; @@ -27,7 +28,11 @@ use ts_rs::TS; pub const EXTENSION_SERVER_PORT: u16 = 6412; pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive = EXTENSION_SERVER_PORT..=6422; const MAX_URL_COUNT: usize = 200; -const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; +const MAX_NON_TORRENT_REQUEST_BODY_BYTES: usize = 256 * 1024; +const MAX_ENCODED_TORRENT_BYTES: usize = + ((crate::torrent::MAX_TORRENT_BYTES + 2) / 3) * 4; +const MAX_REQUEST_BODY_BYTES: usize = + MAX_ENCODED_TORRENT_BYTES + MAX_NON_TORRENT_REQUEST_BODY_BYTES; const SIGNATURE_MAX_AGE_MS: u64 = 60_000; const SERVER_HEADER: &str = "x-firelink-server"; const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version"; @@ -36,7 +41,7 @@ const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof"; const SERVER_PORT_HEADER: &str = "x-firelink-server-port"; const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id"; const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n"; -const PROTOCOL_VERSION: &str = "5"; +const PROTOCOL_VERSION: &str = "6"; const MAX_PENDING_EXTENSION_ACKS: usize = 64; const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); @@ -80,6 +85,8 @@ struct ExtensionRequest { batch: bool, #[serde(default)] batch_name: Option, + #[serde(default)] + torrent_bytes_base64: Option, } #[derive(Clone, Deserialize, Serialize, TS)] @@ -105,6 +112,11 @@ pub struct ExtensionDownload { torrent: bool, batch: bool, batch_name: Option, + #[ts(optional)] + torrent_path: Option, + #[serde(skip)] + #[ts(skip)] + torrent_bytes: Option>, } pub async fn start_server( @@ -303,11 +315,25 @@ async fn download_handler( Err(_) => return Err(StatusCode::BAD_REQUEST), }; - let download = match normalize_download(payload) { + let mut download = match normalize_download(payload) { Some(v) => v, None => return Err(StatusCode::BAD_REQUEST), }; + let request_id = uuid::Uuid::new_v4().simple().to_string(); + if let Some(torrent_bytes) = download.torrent_bytes.take() { + let torrent_path = crate::torrent::cache_torrent_bytes( + &state.app_handle, + &request_id, + &torrent_bytes, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + download.urls = vec![torrent_path.clone()]; + download.torrent_path = Some(torrent_path); + } + let cached_torrent = download.torrent_path.is_some(); + let is_hidden = state .app_handle .get_webview_window("main") @@ -321,13 +347,19 @@ async fn download_handler( } if !wait_for_frontend(&state.frontend_ready).await { + if cached_torrent { + crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await; + } return Err(StatusCode::SERVICE_UNAVAILABLE); } - let request_id = uuid::Uuid::new_v4().simple().to_string(); - let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone()) - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - let mut download = download; + let Some(ack_receiver) = register_extension_ack(&state.extension_acks, request_id.clone()) + else { + if cached_torrent { + crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await; + } + return Err(StatusCode::SERVICE_UNAVAILABLE); + }; download.request_id = Some(request_id.clone()); if state @@ -336,6 +368,9 @@ async fn download_handler( .is_err() { remove_extension_ack(&state.extension_acks, &request_id); + if cached_torrent { + crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await; + } return Err(StatusCode::INTERNAL_SERVER_ERROR); } @@ -405,11 +440,33 @@ fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) { } } +fn decode_torrent_bytes(encoded: &str) -> Option> { + if encoded.is_empty() + || encoded.len() > MAX_ENCODED_TORRENT_BYTES + || encoded.len() % 4 != 0 + { + return None; + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + if bytes.is_empty() || bytes.len() > crate::torrent::MAX_TORRENT_BYTES { + return None; + } + crate::torrent::parse_torrent_bytes(&bytes).ok()?; + Some(bytes) +} + fn normalize_download(mut payload: ExtensionRequest) -> Option { if payload.urls.len() > MAX_URL_COUNT { return None; } + let torrent_bytes = match payload.torrent_bytes_base64.as_deref() { + Some(encoded) => Some(decode_torrent_bytes(encoded)?), + None => None, + }; + let mut seen = HashSet::new(); let urls = payload .urls @@ -420,6 +477,16 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option Option Option bool { mod tests { use super::{ acknowledge_extension_download, add_server_identity, claim_request_at, - has_allowed_request_origin, is_valid_client_nonce, normalize_download, + decode_torrent_bytes, has_allowed_request_origin, is_valid_client_nonce, normalize_download, normalize_url, required_client_nonce, same_origin_url, sanitize_filename, sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER, @@ -741,6 +811,7 @@ mod tests { routing::get, Router, }; + use base64::Engine as _; use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; use std::collections::HashMap; @@ -767,7 +838,7 @@ mod tests { assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); assert_eq!( response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(), - "5" + "6" ); server.abort(); @@ -834,6 +905,7 @@ mod tests { torrent: false, batch: false, batch_name: None, + torrent_bytes_base64: None, }); assert!(download.is_none()); @@ -855,6 +927,7 @@ mod tests { torrent: false, batch: false, batch_name: None, + torrent_bytes_base64: None, }); assert!(download.is_none()); @@ -917,6 +990,7 @@ mod tests { torrent: false, batch: false, batch_name: None, + torrent_bytes_base64: None, }) .expect("valid media handoff"); @@ -942,6 +1016,7 @@ mod tests { torrent: false, batch: false, batch_name: None, + torrent_bytes_base64: None, }) .expect("valid download handoff"); @@ -972,6 +1047,7 @@ mod tests { torrent: false, batch: true, batch_name: Some("batch".to_string()), + torrent_bytes_base64: None, }) .expect("valid multi-url handoff"); @@ -999,6 +1075,7 @@ mod tests { torrent: true, batch: false, batch_name: None, + torrent_bytes_base64: None, }) .expect("valid magnet torrent handoff"); @@ -1017,6 +1094,7 @@ mod tests { torrent: true, batch: false, batch_name: None, + torrent_bytes_base64: None, }) .expect("explicit opaque torrent handoff"); assert!(opaque.torrent); @@ -1035,11 +1113,78 @@ mod tests { torrent: false, batch: false, batch_name: None, + torrent_bytes_base64: None, }) .expect("legacy magnet handoff"); assert!(legacy_magnet.torrent); } + #[test] + fn browser_local_torrent_bytes_are_normalized_as_a_single_http_sourced_torrent() { + let bytes = b"d4:infod6:lengthi5e4:name4:testee".to_vec(); + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let download = normalize_download(ExtensionRequest { + urls: vec!["https://privatebin.example/paste".to_string()], + referer: Some("https://privatebin.example/paste".to_string()), + silent: true, + filename: Some("TerraScape.TORRENT".to_string()), + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + torrent: true, + batch: false, + batch_name: None, + torrent_bytes_base64: Some(encoded), + }) + .expect("browser-local torrent bytes should be accepted"); + + assert!(download.torrent); + assert_eq!(download.urls, vec!["https://privatebin.example/paste"]); + assert_eq!(download.filename.as_deref(), Some("TerraScape.TORRENT")); + assert_eq!(download.torrent_bytes.as_deref(), Some(bytes.as_slice())); + assert!(download.torrent_path.is_none()); + } + + #[test] + fn browser_local_torrent_bytes_require_valid_bencoded_metadata_and_http_source() { + let valid = base64::engine::general_purpose::STANDARD + .encode(b"d4:infod6:lengthi5e4:name4:testee"); + let invalid = base64::engine::general_purpose::STANDARD.encode(b"not a torrent"); + + assert!(decode_torrent_bytes(&invalid).is_none()); + assert!(normalize_download(ExtensionRequest { + urls: vec!["blob:https://privatebin.example/attachment".to_string()], + referer: None, + silent: true, + filename: Some("download.torrent".to_string()), + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + torrent: true, + batch: false, + batch_name: None, + torrent_bytes_base64: Some(valid.clone()), + }) + .is_none()); + assert!(normalize_download(ExtensionRequest { + urls: vec!["https://privatebin.example/paste".to_string()], + referer: None, + silent: true, + filename: Some("download.torrent".to_string()), + headers: None, + cookies: None, + cookie_scopes: None, + media: true, + torrent: true, + batch: false, + batch_name: None, + torrent_bytes_base64: Some(valid), + }) + .is_none()); + } + #[test] fn regular_capture_normalizes_host_scoped_cookie_headers() { let download = normalize_download(ExtensionRequest { @@ -1067,6 +1212,7 @@ mod tests { torrent: false, batch: false, batch_name: None, + torrent_bytes_base64: None, }) .expect("valid download handoff"); @@ -1098,6 +1244,7 @@ mod tests { torrent: false, batch: false, batch_name: None, + torrent_bytes_base64: None, }) .expect("valid multi-url handoff"); @@ -1122,6 +1269,7 @@ mod tests { torrent: false, batch: true, batch_name: Some("Example Gallery / Chapter: 1".to_string()), + torrent_bytes_base64: None, }) .expect("valid selected-link batch"); @@ -1146,6 +1294,7 @@ mod tests { torrent: false, batch: true, batch_name: Some("Example Gallery".to_string()), + torrent_bytes_base64: None, }) .expect("valid single-link handoff"); diff --git a/src/bindings/ExtensionDownload.ts b/src/bindings/ExtensionDownload.ts index 91e0b5f..6c7531c 100644 --- a/src/bindings/ExtensionDownload.ts +++ b/src/bindings/ExtensionDownload.ts @@ -1,4 +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 = { request_id?: string, urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, }; +export type ExtensionDownload = { request_id?: string, urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, torrent_path?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index aaf9b54..d84ecea 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -215,11 +215,18 @@ export const AddDownloadsModal = () => { if (!row.isTorrent) continue; activeDraftIds.add(row.torrentCacheId || row.id); activeDraftIds.add(`${row.id}-${row.generation}`); + const requestContext = pendingAddRequestContexts[normalizeComparableUrl(row.sourceUrl)]; + if (requestContext?.torrentPath + && requestContext.torrentCacheId + && requestContext.torrentPath === row.torrentPath + && requestContext?.torrentCacheId === row.torrentCacheId) { + cachedTorrentDraftIdsRef.current.add(requestContext.torrentCacheId); + } } const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current) .filter(id => !activeDraftIds.has(id)); if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds); - }, [cleanupDraftTorrentCache, parsedItems]); + }, [cleanupDraftTorrentCache, parsedItems, pendingAddRequestContexts]); useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]); @@ -579,6 +586,16 @@ export const AddDownloadsModal = () => { Object.entries(pendingAddRequestContexts) .map(([url, context]) => [url, context.version]) ); + const requestTorrentPaths = Object.fromEntries( + Object.entries(pendingAddRequestContexts) + .filter(([, context]) => Boolean(context.torrentPath)) + .map(([url, context]) => [url, context.torrentPath as string]) + ); + const requestTorrentCacheIds = Object.fromEntries( + Object.entries(pendingAddRequestContexts) + .filter(([, context]) => Boolean(context.torrentCacheId)) + .map(([url, context]) => [url, context.torrentCacheId as string]) + ); setParsedItems(current => { const selectedBySourceUrl = Object.fromEntries( current.map(row => [row.sourceUrl, row.selected !== false]) @@ -598,7 +615,9 @@ export const AddDownloadsModal = () => { requestContextVersions, playlistExpansions, selectedBySourceUrl, - forcedTorrentUrls + forcedTorrentUrls, + requestTorrentPaths, + requestTorrentCacheIds ); }); }, [ diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 95a9554..4e0842c 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -4591,6 +4591,61 @@ describe('useDownloadStore', () => { expect(state.pendingAddMediaUrls).toEqual([]); }); + it('routes a browser-local torrent handoff with its managed cache identity', async () => { + const torrentPath = '/Users/test/Library/Application Support/Firelink/torrents/request-id.torrent'; + await useDownloadStore.getState().handleExtensionDownload({ + request_id: 'request-id', + urls: [torrentPath], + torrent_path: torrentPath, + referer: 'https://example.com/page', + silent: true, + filename: 'sample.torrent', + headers: null, + cookies: null, + cookie_scopes: null, + media: false, + torrent: true, + batch: false, + batch_name: null + }); + + const state = useDownloadStore.getState(); + expect(state.pendingAddUrls).toBe(torrentPath); + expect(state.pendingAddTorrentUrls).toEqual([torrentPath]); + expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({ + media: false, + torrent: true, + torrentPath, + torrentCacheId: 'request-id' + }); + }); + + it('retains a Windows managed torrent path as the request context key', async () => { + const torrentPath = 'C:\\Users\\test\\AppData\\Roaming\\Firelink\\torrents\\request-id.torrent'; + await useDownloadStore.getState().handleExtensionDownload({ + request_id: 'request-id', + urls: [torrentPath], + torrent_path: torrentPath, + referer: 'https://example.com/page', + silent: true, + filename: 'sample.torrent', + headers: null, + cookies: null, + cookie_scopes: null, + media: false, + torrent: true, + batch: false, + batch_name: null + }); + + const state = useDownloadStore.getState(); + expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({ + torrentPath, + torrentCacheId: 'request-id' + }); + expect(state.pendingAddRequestContexts).not.toHaveProperty(`c:${torrentPath.slice(1)}`); + }); + it('does not reuse stale extension metadata for a later single-link handoff', async () => { useDownloadStore.setState({ isAddModalOpen: true, diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 05b23b1..93800f3 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -1122,6 +1122,8 @@ export type PendingAddRequestContext = { cookieScopes?: ExtensionCookieScope[]; media: boolean; torrent?: boolean; + torrentPath?: string; + torrentCacheId?: string; }; export type DeleteModalState = { @@ -1178,7 +1180,9 @@ interface DownloadState { cookieScopes?: ExtensionCookieScope[] | null, batch?: boolean, batchName?: string | null, - torrent?: boolean + torrent?: boolean, + torrentPath?: string, + torrentCacheId?: string ) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise; deleteModalState: DeleteModalState; @@ -1928,7 +1932,9 @@ export const useDownloadStore = create((set, get) => { cookieScopes, batch = false, batchName, - torrent = false + torrent = false, + torrentPath, + torrentCacheId ) => set((state) => { const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls); const existingUrls = isAppending ? state.pendingAddUrls : ''; @@ -1967,10 +1973,13 @@ export const useDownloadStore = create((set, get) => { const trimmedUrl = rawUrl.trim(); if (!trimmedUrl) continue; let key = trimmedUrl; - try { - key = new URL(trimmedUrl).href; - } catch { - // The Add modal will mark malformed input invalid; retain its original key here. + const isLocalPath = trimmedUrl.startsWith('/') || /^[a-z]:[\\/]/i.test(trimmedUrl); + if (!isLocalPath) { + try { + key = new URL(trimmedUrl).href; + } catch { + // The Add modal will mark malformed input invalid; retain its original key here. + } } const isItemMedia = isExplicitMedia || isMediaUrl(trimmedUrl); pendingAddRequestContexts[key] = { @@ -1981,7 +1990,9 @@ export const useDownloadStore = create((set, get) => { cookies: isItemMedia ? '' : cleanCookies, ...(cleanCookieScopes?.length && !isItemMedia ? { cookieScopes: cleanCookieScopes } : {}), media: isItemMedia, - ...(torrent ? { torrent: true } : {}) + ...(torrent ? { torrent: true } : {}), + ...(torrentPath ? { torrentPath } : {}), + ...(torrentCacheId ? { torrentCacheId } : {}) }; } const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts) @@ -2027,7 +2038,9 @@ export const useDownloadStore = create((set, get) => { request.media === true ? undefined : request.cookie_scopes, request.batch === true && urls.length >= 2, request.batch_name, - request.torrent === true + request.torrent === true, + request.torrent_path || undefined, + request.request_id || undefined ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index aaaf1ea..1df354a 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -152,6 +152,32 @@ describe('add download metadata workflow', () => { }); }); + it('retains the native-managed path and cache identity for a browser-local torrent', () => { + const source = '/Users/test/Library/Application Support/Firelink/torrents/request-id.torrent'; + const rows = reconcileDownloadRows( + source, + [], + undefined, + new Set(), + undefined, + {}, + { [source]: 1 }, + {}, + {}, + new Set(), + { [source]: source }, + { [source]: 'request-id' } + ); + + expect(rows[0]).toMatchObject({ + sourceUrl: source, + isTorrent: true, + status: 'loading', + torrentPath: source, + torrentCacheId: 'request-id' + }); + }); + it('gives refreshed torrent metadata a new cache identity', () => { const existing = row({ id: 'torrent-row', diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 6f87bac..4a81d4b 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -272,7 +272,9 @@ export const reconcileDownloadRows = ( requestContextVersions: Readonly> = {}, playlistExpansions: PlaylistExpansions = {}, selectedBySourceUrl: Readonly> = {}, - forceTorrentUrls: ReadonlySet = new Set() + forceTorrentUrls: ReadonlySet = new Set(), + requestTorrentPaths: Readonly> = {}, + requestTorrentCacheIds: Readonly> = {} ): AddDownloadDraftRow[] => { const inputs = parseInputLines( rawText, @@ -331,8 +333,9 @@ export const reconcileDownloadRows = ( playlistEntryTitle: input.playlistEntryTitle, playlistError: undefined, metadataBlockedReason: undefined, - torrentPath: undefined, - torrentCacheId: input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined, + torrentPath: requestTorrentPaths[input.sourceUrl], + torrentCacheId: requestTorrentCacheIds[input.sourceUrl] + || (input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined), torrentInfoHash: undefined, torrentFiles: undefined, selectedTorrentFileIndices: undefined @@ -400,9 +403,11 @@ export const reconcileDownloadRows = ( playlistCount: input.playlistCount, playlistEntryTitle: input.playlistEntryTitle, metadataBlockedReason: undefined, - torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl)) - ? `${id}-${generation}` - : undefined, + torrentPath: requestTorrentPaths[input.sourceUrl], + torrentCacheId: requestTorrentCacheIds[input.sourceUrl] + || (input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl)) + ? `${id}-${generation}` + : undefined), torrentMetadataStatus: input.valid && input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'loading' : undefined,