feat(torrents): add tracker controls

This commit is contained in:
NimBold
2026-08-01 23:53:07 +03:30
parent e31a3fcc90
commit 0f1f4e8003
20 changed files with 434 additions and 15 deletions
+7 -2
View File
@@ -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 });
+105 -1
View File
@@ -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<String, Value>) {
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]
+3
View File
@@ -194,6 +194,9 @@ pub struct DownloadItem {
#[serde(default)]
#[ts(optional)]
pub torrent_check_integrity: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_trackers: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+4 -3
View File
@@ -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<crate::ipc::EnqueueAccepted, AppError> {
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
};
+145
View File
@@ -215,6 +215,7 @@ pub struct SpawnPayload {
pub torrent_max_peers: Option<u32>,
pub torrent_peer_speed_limit: Option<String>,
pub torrent_check_integrity: bool,
pub torrent_trackers: Option<String>,
}
/// 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<String, serde_json::Value>,
@@ -3106,6 +3109,74 @@ fn normalize_torrent_peer_speed_limit(value: Option<&str>) -> Result<Option<Stri
.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())
}
pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<String>, 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<String, serde_json::Value>,
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<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -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::<Vec<_>>()
.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();
+1 -1
View File
@@ -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<number>, 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<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, };
+1 -1
View File
@@ -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<number>, 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<number>, 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, };
+25 -1
View File
@@ -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 = () => {
</span>
</span>
</label>
<div className="pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-trackers" className="block text-text-muted">
{t($ => $.addDownloads.torrentTrackers)}
</label>
<textarea
id="torrent-trackers"
rows={3}
value={torrentTrackers}
onChange={event => setTorrentTrackers(event.currentTarget.value)}
placeholder="https://tracker.example/announce"
aria-describedby="torrent-trackers-hint"
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
/>
<p id="torrent-trackers-hint" className="mt-1 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentTrackersHint)}
</p>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-max-peers" className="text-text-muted">
{t($ => $.addDownloads.torrentMaxPeers)}
+26 -1
View File
@@ -16,7 +16,7 @@ import {
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads';
import { isValidTorrentTrackerList, normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
@@ -78,6 +78,7 @@ export const PropertiesModal = () => {
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentTrackers, setTorrentTrackers] = useState('');
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
@@ -170,6 +171,7 @@ export const PropertiesModal = () => {
);
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setTorrentTrackers(activeItem.torrentTrackers || '');
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -265,6 +267,10 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentPeerSpeedLimitInvalid));
return;
}
if (item.isTorrent && !isValidTorrentTrackerList(torrentTrackers)) {
setErrorMessage(t($ => $.properties.torrentTrackersInvalid));
return;
}
const updates: Partial<DownloadItem> = {
url,
@@ -282,6 +288,7 @@ export const PropertiesModal = () => {
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
torrentCheckIntegrity,
torrentTrackers: torrentTrackers.trim() || undefined,
}
: {}),
...(connectionsDirty
@@ -704,6 +711,24 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-trackers-properties">
{t($ => $.properties.torrentTrackers)}
</label>
<div>
<textarea
id="torrent-trackers-properties"
rows={3}
value={torrentTrackers}
onChange={event => setTorrentTrackers(event.currentTarget.value)}
placeholder="https://tracker.example/announce"
disabled={transferLocked}
aria-describedby="torrent-trackers-properties-hint"
className="app-control min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<p id="torrent-trackers-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentTrackersHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
{t($ => $.properties.torrentVerifyIntegrity)}
</label>
+6
View File
@@ -236,6 +236,9 @@ const common = {
liveTorrentPeerOptionsApply: 'Apply peer controls',
liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.',
torrentPeerOptionsSavedHint: 'Saved per Torrent. 0 peers means unlimited; blank uses Aria2 defaults.',
torrentTrackers: 'Additional Torrent trackers',
torrentTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line. Optional comma-separated entries are also accepted; credentials are not allowed.',
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.',
torrentMaxPeers: 'Maximum Torrent peers',
@@ -484,6 +487,9 @@ const common = {
torrentSeedTimeInvalid: 'Torrent seed time must be greater than zero',
torrentSeedRatioInvalid: 'Torrent seed ratio must be zero or greater',
torrentUploadLimitInvalid: 'Torrent upload limit must be greater than zero',
torrentTrackers: 'Additional Torrent trackers',
torrentTrackersHint: 'Saved with this Torrent and applied on its next start or retry.',
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
torrentMaxPeers: 'Maximum Torrent peers',
+6
View File
@@ -236,6 +236,9 @@ const fa = {
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال می‌شود. برای استفاده از پیش‌فرض آریا۲ خالی بگذارید.',
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره می‌شود. صفر یعنی نامحدود؛ خالی یعنی پیش‌فرض آریا۲.',
torrentTrackers: 'Trackerهای اضافی تورنت',
torrentTrackersHint: 'هر Tracker را در یک خط بنویسید. HTTP، HTTPS یا UDP؛ اطلاعات ورود مجاز نیست.',
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
@@ -484,6 +487,9 @@ const fa = {
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد',
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
torrentTrackers: 'Trackerهای اضافی تورنت',
torrentTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود.',
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعه‌ها را بررسی می‌کند؛ قطعه‌های خراب ممکن است دوباره دانلود شوند.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
+6
View File
@@ -236,6 +236,9 @@ const he = {
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
torrentTrackers: 'עוקבי טורנט נוספים',
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
@@ -484,6 +487,9 @@ const he = {
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
torrentTrackers: 'עוקבי טורנט נוספים',
torrentTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא.',
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
+6
View File
@@ -236,6 +236,9 @@ const ru = {
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
torrentTrackers: 'Дополнительные трекеры торрента',
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
torrentMaxPeers: 'Максимум пиров торрента',
@@ -484,6 +487,9 @@ const ru = {
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
torrentTrackers: 'Дополнительные трекеры торрента',
torrentTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке.',
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
torrentMaxPeers: 'Максимум пиров торрента',
+6
View File
@@ -236,6 +236,9 @@ const uk = {
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
torrentTrackers: 'Додаткові трекери торрента',
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
torrentMaxPeers: 'Максимум пірів торрента',
@@ -484,6 +487,9 @@ const uk = {
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
torrentTrackers: 'Додаткові трекери торрента',
torrentTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби.',
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
torrentMaxPeers: 'Максимум пірів торрента',
+6
View File
@@ -236,6 +236,9 @@ const zhCN = {
liveTorrentPeerOptionsApply: '应用节点控制',
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
torrentTrackers: '其他 Torrent Tracker',
torrentTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker。不允许填写凭据。',
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
torrentMaxPeers: 'Torrent 最大对等节点数',
@@ -484,6 +487,9 @@ const zhCN = {
torrentSeedTimeInvalid: '做种时间必须大于零',
torrentSeedRatioInvalid: '做种比率不能小于零',
torrentUploadLimitInvalid: '种子上传限速必须大于零',
torrentTrackers: '其他 Torrent Tracker',
torrentTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。',
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
torrentMaxPeers: 'Torrent 最大对等节点数',
+7 -3
View File
@@ -853,12 +853,14 @@ describe('useDownloadStore', () => {
isTorrent: true,
torrentMaxPeers: 'not-a-number' as unknown as number,
torrentPeerSpeedLimit: 0 as unknown as string,
torrentCheckIntegrity: 'yes' as unknown as boolean
torrentCheckIntegrity: 'yes' as unknown as boolean,
torrentTrackers: 123 as unknown as string
});
expect(normalized.torrentMaxPeers).toBeUndefined();
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
expect(normalized.torrentCheckIntegrity).toBeUndefined();
expect(normalized.torrentTrackers).toBeUndefined();
});
it('normalizes proxy settings for download dispatch', async () => {
@@ -1509,7 +1511,8 @@ describe('useDownloadStore', () => {
category: 'Other',
dateAdded: '',
isTorrent: true,
torrentCheckIntegrity: true
torrentCheckIntegrity: true,
torrentTrackers: 'https://tracker.example/announce'
}, { type: 'start-now' });
const item = useDownloadStore.getState().downloads[0];
@@ -1520,7 +1523,8 @@ describe('useDownloadStore', () => {
expect.objectContaining({
item: expect.objectContaining({
id: 'start-1',
torrent_check_integrity: true
torrent_check_integrity: true,
torrent_trackers: 'https://tracker.example/announce'
})
})
);
+10 -2
View File
@@ -351,6 +351,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_trackers: item.torrentTrackers || undefined,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -631,14 +632,20 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
const normalizedCheckIntegrity = typeof rawCheckIntegrity === 'boolean'
? rawCheckIntegrity
: undefined;
const rawTrackers = download.torrentTrackers as unknown;
const normalizedTrackers = typeof rawTrackers === 'string' && rawTrackers.trim()
? rawTrackers.trim()
: undefined;
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity
rawCheckIntegrity !== normalizedCheckIntegrity ||
rawTrackers !== normalizedTrackers
? {
...download,
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity
torrentCheckIntegrity: normalizedCheckIntegrity,
torrentTrackers: normalizedTrackers
}
: download;
@@ -2170,6 +2177,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_trackers: item.torrentTrackers || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
+1
View File
@@ -65,6 +65,7 @@ export interface AddDownloadDraftRow {
torrentMaxPeers?: number;
torrentPeerSpeedLimit?: string;
torrentCheckIntegrity?: boolean;
torrentTrackers?: string;
}
/**
+17
View File
@@ -6,6 +6,7 @@ import {
downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
isValidTorrentTrackerList,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
@@ -51,6 +52,22 @@ describe('download persistence progress snapshots', () => {
);
});
describe('Torrent tracker input validation', () => {
it('accepts supported trackers separated by lines or commas', () => {
expect(isValidTorrentTrackerList(
' https://tracker.example/announce\nudp://tracker.example:6969/announce '
)).toBe(true);
expect(isValidTorrentTrackerList('https://tracker.example/announce,https://tracker.example/announce')).toBe(true);
});
it('rejects unsupported, credential-bearing, empty, and oversized entries', () => {
expect(isValidTorrentTrackerList('ftp://tracker.example/announce')).toBe(false);
expect(isValidTorrentTrackerList('https://user:pass@tracker.example/announce')).toBe(false);
expect(isValidTorrentTrackerList('https://tracker.example/announce,')).toBe(false);
expect(isValidTorrentTrackerList(Array.from({ length: 65 }, (_, index) => `https://tracker${index}.example/announce`).join('\n'))).toBe(false);
});
});
describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
+46
View File
@@ -118,6 +118,52 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n
return unit ? `${amount}${unit}` : `${amount}K`;
};
const MAX_TORRENT_TRACKERS = 64;
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
/**
* Performs the same user-facing safety checks as the native tracker boundary.
* The Rust validator remains authoritative because persisted data can bypass
* this helper and the browser URL parser is not the native URL parser.
*/
export const isValidTorrentTrackerList = (value: string): boolean => {
const raw = value.trim();
if (!raw) return true;
if (utf8ByteLength(raw) > MAX_TORRENT_TRACKER_BYTES) return false;
const normalized = new Set<string>();
let serializedBytes = 0;
for (const line of raw.split(/[\r\n]/)) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
for (const part of trimmedLine.split(',')) {
const token = part.trim();
if (!token || [...token].some(character => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) {
return false;
}
let parsed: URL;
try {
parsed = new URL(token);
} catch {
return false;
}
if (!['http:', 'https:', 'udp:'].includes(parsed.protocol) || !parsed.hostname) {
return false;
}
if (parsed.username || parsed.password || parsed.hash) {
return false;
}
const canonical = parsed.toString();
if (normalized.has(canonical)) continue;
normalized.add(canonical);
if (normalized.size > MAX_TORRENT_TRACKERS) return false;
serializedBytes += utf8ByteLength(canonical) + (normalized.size > 1 ? 1 : 0);
if (serializedBytes > MAX_TORRENT_TRACKER_BYTES) return false;
}
}
return normalized.size > 0;
};
export const initMediaDomains = async () => {
try {
const domains = await invoke('get_supported_media_domains');