From 0f1f4e8003f46ff6f25d622ab8fcc25d300a67b6 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 1 Aug 2026 23:53:07 +0330 Subject: [PATCH] feat(torrents): add tracker controls --- scripts/smoke-torrent.js | 9 +- src-tauri/src/db.rs | 106 +++++++++++++++++++- src-tauri/src/ipc.rs | 3 + src-tauri/src/lib.rs | 7 +- src-tauri/src/queue.rs | 145 +++++++++++++++++++++++++++ src/bindings/DownloadItem.ts | 2 +- src/bindings/EnqueueItem.ts | 2 +- src/components/AddDownloadsModal.tsx | 26 ++++- src/components/PropertiesModal.tsx | 27 ++++- src/i18n/catalogs/en.ts | 6 ++ src/i18n/catalogs/fa.ts | 6 ++ src/i18n/catalogs/he.ts | 6 ++ src/i18n/catalogs/ru.ts | 6 ++ src/i18n/catalogs/uk.ts | 6 ++ src/i18n/catalogs/zh-CN.ts | 6 ++ src/store/useDownloadStore.test.ts | 10 +- src/store/useDownloadStore.ts | 12 ++- src/utils/addDownloadMetadata.ts | 1 + src/utils/downloads.test.ts | 17 ++++ src/utils/downloads.ts | 46 +++++++++ 20 files changed, 434 insertions(+), 15 deletions(-) diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index e85d0ca..1a67306 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -697,6 +697,10 @@ async function main() { assert(savedInfo instanceof Map, 'saved metadata has no info dictionary'); assert(sha1(bencode(savedInfo)).toString('hex') === torrent.infoHash, 'saved metadata hash differs from magnet'); console.log(`[OK] magnet metadata resolved and hash matched ${torrent.infoHash}`); + const trackerlessTorrent = new Map(savedTorrent); + trackerlessTorrent.delete('announce'); + trackerlessTorrent.delete('announce-list'); + const trackerlessTorrentBytes = bencode(trackerlessTorrent); const probeRemoved = await forceRemoveIfPresent(client, probeGid); if (probeRemoved) await waitForRemoved(client, probeGid); fs.rmSync(probeDir, { recursive: true, force: true }); @@ -704,10 +708,11 @@ async function main() { console.log('[OK] metadata probe was removed after resolution'); const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ - savedTorrentBytes.toString('base64'), + trackerlessTorrentBytes.toString('base64'), [], { dir: finalDir, + 'bt-tracker': `http://127.0.0.1:${trackerPort}/announce`, 'select-file': '1', 'index-out': indexOut, 'max-download-limit': '32K', @@ -729,7 +734,7 @@ async function main() { const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin')); assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`); assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output'); - console.log('[OK] selected addTorrent output, pause/resume, and Aria2 file ownership passed'); + console.log('[OK] additional tracker injection, selected output, pause/resume, and Aria2 file ownership passed'); const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin'); fs.mkdirSync(path.dirname(integrityPath), { recursive: true }); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 98dd6a3..97c0a38 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -951,6 +951,8 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { ); } + sanitize_portable_torrent_trackers(object); + if let Some(url) = object.get("url").and_then(Value::as_str) { if let Ok(mut parsed) = url::Url::parse(url) { let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some(); @@ -1008,6 +1010,61 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { } } +fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map) { + let Some(raw_value) = object.get("torrentTrackers").cloned() else { + return; + }; + let Some(raw) = raw_value.as_str().map(str::to_string) else { + object.remove("torrentTrackers"); + mark_portable_download_unresumable(object); + return; + }; + let raw = raw.trim(); + if raw.is_empty() { + object.remove("torrentTrackers"); + return; + } + let Some(normalized) = crate::queue::normalize_torrent_trackers(Some(raw)).ok().flatten() else { + object.remove("torrentTrackers"); + mark_portable_download_unresumable(object); + return; + }; + + let mut sanitized = Vec::new(); + let mut removed_context = false; + for token in normalized.split(',') { + let Ok(mut parsed) = url::Url::parse(token) else { + object.remove("torrentTrackers"); + mark_portable_download_unresumable(object); + return; + }; + let had_context = !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some(); + if had_context { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.set_query(None); + parsed.set_fragment(None); + removed_context = true; + } + sanitized.push(parsed.to_string()); + } + + if sanitized.is_empty() { + object.remove("torrentTrackers"); + } else { + object.insert( + "torrentTrackers".to_string(), + Value::String(sanitized.join(",")), + ); + } + if removed_context { + mark_portable_download_unresumable(object); + } +} + fn value_is_empty(value: &Value) -> bool { value.as_str().is_some_and(str::is_empty) || value.as_array().is_some_and(Vec::is_empty) @@ -1983,7 +2040,8 @@ mod tests { "cookies": "session=secret", "headers": "Authorization: Bearer secret", "mirrors": "https://user:secret@example.com/mirror", - "proxy": "http://user:secret@example.com:8080" + "proxy": "http://user:secret@example.com:8080", + "torrentTrackers": "https://tracker.example/announce?passkey=secret" }]) .to_string(); @@ -1997,6 +2055,52 @@ mod tests { for key in ["password", "cookies", "headers", "mirrors", "proxy"] { assert!(saved.get(key).is_none(), "portable data retained {key}"); } + assert_eq!(saved["torrentTrackers"], "https://tracker.example/announce"); + } + + #[test] + fn portable_download_persistence_drops_malformed_tracker_fields() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-malformed-trackers", + "status": "queued", + "queueId": "main", + "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + "torrentTrackers": { "token": "secret" } + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert!(saved.get("torrentTrackers").is_none()); + assert_eq!(saved["status"], "failed"); + assert_eq!(saved["resumable"], false); + assert!(!saved.to_string().contains("secret")); + } + + #[test] + fn portable_download_persistence_drops_invalid_tracker_urls() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-invalid-trackers", + "status": "queued", + "queueId": "main", + "url": "https://example.com/file.bin", + "torrentTrackers": "ftp://tracker.example/announce" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert!(saved.get("torrentTrackers").is_none()); + assert_eq!(saved["status"], "failed"); + assert_eq!(saved["resumable"], false); } #[test] diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index f7a37b8..fb32b11 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -194,6 +194,9 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_check_integrity: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_trackers: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d69d825..17621b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5674,11 +5674,12 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S async fn validate_torrent_enqueue( app_handle: &tauri::AppHandle, - item: &queue::EnqueueItem, + item: &mut queue::EnqueueItem, ) -> Result<(), String> { if item.is_media.unwrap_or(false) { return Err("torrent transfer cannot be a media download".to_string()); } + item.torrent_trackers = queue::normalize_torrent_trackers(item.torrent_trackers.as_deref())?; validate_enqueue_uris("", item.mirrors.as_deref()).await?; if let Some(path) = item.torrent_path.as_deref() { let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?; @@ -5941,7 +5942,7 @@ async fn enqueue_download( mut item: queue::EnqueueItem, ) -> Result { if item.is_torrent.unwrap_or(false) { - validate_torrent_enqueue(&app_handle, &item) + validate_torrent_enqueue(&app_handle, &mut item) .await .map_err(AppError::Internal)?; } else { @@ -6055,7 +6056,7 @@ async fn enqueue_many( for mut item in items { let id = item.id.clone(); let validation = if item.is_torrent.unwrap_or(false) { - validate_torrent_enqueue(&app_handle, &item).await + validate_torrent_enqueue(&app_handle, &mut item).await } else { validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await }; diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 966516b..1e3ab4d 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -215,6 +215,7 @@ pub struct SpawnPayload { pub torrent_max_peers: Option, pub torrent_peer_speed_limit: Option, pub torrent_check_integrity: bool, + pub torrent_trackers: Option, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -3047,6 +3048,8 @@ const ARIA2_STREAM_PIECE_SELECTOR: &str = "inorder"; const ARIA2_DEFAULT_TORRENT_MAX_PEERS: u32 = 55; const ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT: &str = "50K"; const MAX_TORRENT_MAX_PEERS: u32 = 1000; +const MAX_TORRENT_TRACKERS: usize = 64; +const MAX_TORRENT_TRACKER_BYTES: usize = 16 * 1024; fn apply_aria2_connection_options( options: &mut serde_json::Map, @@ -3106,6 +3109,74 @@ fn normalize_torrent_peer_speed_limit(value: Option<&str>) -> Result) -> Result, String> { + let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if raw.len() > MAX_TORRENT_TRACKER_BYTES { + return Err(format!( + "torrent tracker list must be at most {MAX_TORRENT_TRACKER_BYTES} bytes" + )); + } + + let mut trackers = Vec::new(); + let mut serialized_bytes = 0usize; + for line in raw.split(['\r', '\n']) { + let line = line.trim(); + if line.is_empty() { + continue; + } + for token in line.split(',') { + let token = token.trim(); + if token.is_empty() { + return Err("torrent tracker list contains an empty entry".to_string()); + } + if token.chars().any(char::is_control) { + return Err("torrent tracker URI contains a control character".to_string()); + } + let parsed = url::Url::parse(token) + .map_err(|_| "torrent tracker URI is invalid".to_string())?; + if !matches!(parsed.scheme(), "http" | "https" | "udp") { + return Err("torrent tracker URI must use http, https, or udp".to_string()); + } + if parsed.host_str().is_none_or(str::is_empty) { + return Err("torrent tracker URI must include a host".to_string()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("torrent tracker URI must not contain credentials".to_string()); + } + if parsed.fragment().is_some() { + return Err("torrent tracker URI must not contain a fragment".to_string()); + } + + let normalized = parsed.to_string(); + if trackers.iter().any(|tracker| tracker == &normalized) { + continue; + } + if trackers.len() >= MAX_TORRENT_TRACKERS { + return Err(format!( + "torrent tracker list must contain at most {MAX_TORRENT_TRACKERS} trackers" + )); + } + serialized_bytes = serialized_bytes + .checked_add(normalized.len()) + .and_then(|bytes| bytes.checked_add(if trackers.is_empty() { 0 } else { 1 })) + .ok_or_else(|| "torrent tracker list is too large".to_string())?; + if serialized_bytes > MAX_TORRENT_TRACKER_BYTES { + return Err(format!( + "torrent tracker list must be at most {MAX_TORRENT_TRACKER_BYTES} bytes" + )); + } + trackers.push(normalized); + } + } + + if trackers.is_empty() { + return Ok(None); + } + Ok(Some(trackers.join(","))) +} + fn apply_aria2_torrent_options( options: &mut serde_json::Map, payload: &SpawnPayload, @@ -3164,6 +3235,9 @@ fn apply_aria2_torrent_options( serde_json::json!(normalized), ); } + if let Some(trackers) = normalize_torrent_trackers(payload.torrent_trackers.as_deref())? { + options.insert("bt-tracker".to_string(), serde_json::json!(trackers)); + } if payload.torrent_check_integrity { options.insert( "check-integrity".to_string(), @@ -3772,6 +3846,9 @@ pub struct EnqueueItem { pub torrent_check_integrity: Option, #[serde(default)] #[ts(optional)] + pub torrent_trackers: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -3820,6 +3897,7 @@ impl EnqueueItem { torrent_max_peers: self.torrent_max_peers, torrent_peer_speed_limit: self.torrent_peer_speed_limit, torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false), + torrent_trackers: self.torrent_trackers, }, } } @@ -4013,6 +4091,73 @@ mod tests { assert!(item.into_task().payload.torrent_check_integrity); } + #[test] + fn torrent_trackers_are_normalized_and_deduplicated() { + assert_eq!( + normalize_torrent_trackers(Some( + " https://tracker.example/announce\nudp://tracker.example:6969/announce\nhttps://tracker.example/announce " + )) + .unwrap(), + Some("https://tracker.example/announce,udp://tracker.example:6969/announce".to_string()) + ); + assert_eq!(normalize_torrent_trackers(Some(" \n\n ")).unwrap(), None); + } + + #[test] + fn torrent_trackers_reject_unsafe_or_unbounded_values() { + for value in [ + "ftp://tracker.example/announce", + "https://user:pass@tracker.example/announce", + "https://tracker.example/announce#fragment", + "https://tracker.example/announce,", + "https://", + ] { + assert!(normalize_torrent_trackers(Some(value)).is_err(), "{value}"); + } + let too_many = (0..=MAX_TORRENT_TRACKERS) + .map(|index| format!("https://tracker{index}.example/announce")) + .collect::>() + .join("\n"); + assert!(normalize_torrent_trackers(Some(&too_many)).is_err()); + } + + #[test] + fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_trackers: Some("https://tracker.example/announce".to_string()), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!( + options.get("bt-tracker"), + Some(&serde_json::json!("https://tracker.example/announce")) + ); + } + + #[test] + fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() { + let item: EnqueueItem = serde_json::from_value(serde_json::json!({ + "id": "torrent-trackers", + "queue_id": "main", + "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", + "destination": "/tmp/downloads", + "filename": "payload", + "is_media": false, + "is_torrent": true, + "torrent_trackers": "https://tracker.example/announce" + })) + .expect("frontend enqueue payload should deserialize"); + + assert_eq!( + item.into_task().payload.torrent_trackers.as_deref(), + Some("https://tracker.example/announce") + ); + } + #[test] fn torrent_options_reject_invalid_seed_values() { let mut options = serde_json::Map::new(); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index f8addff..43aace2 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 1f82dd1..c39ee9b 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 2853330..060f126 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, normalizeSpeedLimitForBackend } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentTrackerList, normalizeSpeedLimitForBackend } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -233,6 +233,7 @@ export const AddDownloadsModal = () => { const [torrentMaxPeers, setTorrentMaxPeers] = useState(''); const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState(''); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); + const [torrentTrackers, setTorrentTrackers] = useState(''); const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); @@ -374,6 +375,7 @@ export const AddDownloadsModal = () => { setTorrentMaxPeers(''); setTorrentPeerSpeedLimit(''); setTorrentCheckIntegrity(false); + setTorrentTrackers(''); setUseAuth(false); setUsername(''); setPassword(''); @@ -972,6 +974,10 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true }); return; } + if (hasSelectedTorrent && !isValidTorrentTrackerList(torrentTrackers)) { + addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true }); + return; + } if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) { addToast({ message: t($ => $.addDownloads.dedicatedFolderNameRequired), @@ -1451,6 +1457,7 @@ export const AddDownloadsModal = () => { ? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined : undefined, torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined, + torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); @@ -2111,6 +2118,23 @@ export const AddDownloadsModal = () => { +
+ +