mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 09:30:29 +00:00
feat(torrents): add tracker controls
This commit is contained in:
+105
-1
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user