mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-03 14:37:56 +00:00
feat(torrent): add magnet and torrent handoff
This commit is contained in:
+1
-1
Submodule Extensions/Browser updated: 6f1b50bee4...64a8c3c5a2
@@ -36,7 +36,7 @@ const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
|
|||||||
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
||||||
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||||
const PROTOCOL_VERSION: &str = "4";
|
const PROTOCOL_VERSION: &str = "5";
|
||||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
@@ -75,6 +75,8 @@ struct ExtensionRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
media: bool,
|
media: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
torrent: bool,
|
||||||
|
#[serde(default)]
|
||||||
batch: bool,
|
batch: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
batch_name: Option<String>,
|
batch_name: Option<String>,
|
||||||
@@ -100,6 +102,7 @@ pub struct ExtensionDownload {
|
|||||||
cookies: Option<String>,
|
cookies: Option<String>,
|
||||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||||
media: bool,
|
media: bool,
|
||||||
|
torrent: bool,
|
||||||
batch: bool,
|
batch: bool,
|
||||||
batch_name: Option<String>,
|
batch_name: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -428,6 +431,20 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let torrent = !payload.media
|
||||||
|
&& urls.len() == 1
|
||||||
|
&& Url::parse(&urls[0]).ok().is_some_and(|url| {
|
||||||
|
if url.scheme() == "magnet" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
matches!(url.scheme(), "http" | "https")
|
||||||
|
&& (payload.torrent
|
||||||
|
|| filename_is_torrent(payload.filename.as_deref())
|
||||||
|
|| url.path().to_ascii_lowercase().ends_with(".torrent"))
|
||||||
|
});
|
||||||
|
if payload.torrent && !torrent {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let referer = payload.referer.and_then(|value| {
|
let referer = payload.referer.and_then(|value| {
|
||||||
let url = Url::parse(value.trim()).ok()?;
|
let url = Url::parse(value.trim()).ok()?;
|
||||||
@@ -482,6 +499,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
cookies,
|
cookies,
|
||||||
cookie_scopes,
|
cookie_scopes,
|
||||||
media: payload.media,
|
media: payload.media,
|
||||||
|
torrent,
|
||||||
batch,
|
batch,
|
||||||
batch_name,
|
batch_name,
|
||||||
})
|
})
|
||||||
@@ -568,7 +586,15 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
|||||||
|
|
||||||
fn normalize_url(raw_url: &str) -> Option<String> {
|
fn normalize_url(raw_url: &str) -> Option<String> {
|
||||||
let url = Url::parse(raw_url.trim()).ok()?;
|
let url = Url::parse(raw_url.trim()).ok()?;
|
||||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string())
|
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet")
|
||||||
|
.then(|| url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filename_is_torrent(filename: Option<&str>) -> bool {
|
||||||
|
filename
|
||||||
|
.and_then(|value| Path::new(value.trim()).file_name())
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.is_some_and(|value| value.to_ascii_lowercase().ends_with(".torrent"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_filename(filename: &str) -> Option<String> {
|
fn sanitize_filename(filename: &str) -> Option<String> {
|
||||||
@@ -752,7 +778,7 @@ mod tests {
|
|||||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||||
"4"
|
"5"
|
||||||
);
|
);
|
||||||
|
|
||||||
server.abort();
|
server.abort();
|
||||||
@@ -816,6 +842,7 @@ mod tests {
|
|||||||
cookies: None,
|
cookies: None,
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: true,
|
media: true,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
});
|
});
|
||||||
@@ -836,6 +863,7 @@ mod tests {
|
|||||||
cookies: None,
|
cookies: None,
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
});
|
});
|
||||||
@@ -897,6 +925,7 @@ mod tests {
|
|||||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: true,
|
media: true,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
})
|
})
|
||||||
@@ -918,6 +947,7 @@ mod tests {
|
|||||||
cookies: Some("session=browser-cookie-header".to_string()),
|
cookies: Some("session=browser-cookie-header".to_string()),
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
})
|
})
|
||||||
@@ -930,6 +960,63 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_handoff_accepts_magnets_and_preserves_the_intent() {
|
||||||
|
let download = normalize_download(ExtensionRequest {
|
||||||
|
urls: vec![
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||||
|
],
|
||||||
|
referer: None,
|
||||||
|
silent: false,
|
||||||
|
filename: None,
|
||||||
|
headers: None,
|
||||||
|
cookies: None,
|
||||||
|
cookie_scopes: None,
|
||||||
|
media: false,
|
||||||
|
torrent: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
|
})
|
||||||
|
.expect("valid magnet torrent handoff");
|
||||||
|
|
||||||
|
assert!(download.torrent);
|
||||||
|
assert_eq!(download.urls[0], "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567");
|
||||||
|
|
||||||
|
let opaque = normalize_download(ExtensionRequest {
|
||||||
|
urls: vec!["https://example.com/download?id=opaque".to_string()],
|
||||||
|
referer: None,
|
||||||
|
silent: true,
|
||||||
|
filename: None,
|
||||||
|
headers: None,
|
||||||
|
cookies: None,
|
||||||
|
cookie_scopes: None,
|
||||||
|
media: false,
|
||||||
|
torrent: true,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
|
})
|
||||||
|
.expect("explicit opaque torrent handoff");
|
||||||
|
assert!(opaque.torrent);
|
||||||
|
|
||||||
|
let legacy_magnet = normalize_download(ExtensionRequest {
|
||||||
|
urls: vec![
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||||
|
],
|
||||||
|
referer: None,
|
||||||
|
silent: false,
|
||||||
|
filename: None,
|
||||||
|
headers: None,
|
||||||
|
cookies: None,
|
||||||
|
cookie_scopes: None,
|
||||||
|
media: false,
|
||||||
|
torrent: false,
|
||||||
|
batch: false,
|
||||||
|
batch_name: None,
|
||||||
|
})
|
||||||
|
.expect("legacy magnet handoff");
|
||||||
|
assert!(legacy_magnet.torrent);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||||
let download = normalize_download(ExtensionRequest {
|
let download = normalize_download(ExtensionRequest {
|
||||||
@@ -954,6 +1041,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
})
|
})
|
||||||
@@ -984,6 +1072,7 @@ mod tests {
|
|||||||
cookies: Some("session=secret".to_string()),
|
cookies: Some("session=secret".to_string()),
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: None,
|
batch_name: None,
|
||||||
})
|
})
|
||||||
@@ -1007,6 +1096,7 @@ mod tests {
|
|||||||
cookies: None,
|
cookies: None,
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: true,
|
batch: true,
|
||||||
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
||||||
})
|
})
|
||||||
@@ -1030,6 +1120,7 @@ mod tests {
|
|||||||
cookies: None,
|
cookies: None,
|
||||||
cookie_scopes: None,
|
cookie_scopes: None,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: true,
|
batch: true,
|
||||||
batch_name: Some("Example Gallery".to_string()),
|
batch_name: Some("Example Gallery".to_string()),
|
||||||
})
|
})
|
||||||
|
|||||||
+253
-8
@@ -6,6 +6,7 @@ use regex::Regex;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::ffi::OsStr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -1747,12 +1748,27 @@ async fn validate_url_ssrf(url: &str) -> Result<Option<(String, std::net::Socket
|
|||||||
|
|
||||||
const MAX_REMOTE_TORRENT_REDIRECTS: usize = 5;
|
const MAX_REMOTE_TORRENT_REDIRECTS: usize = 5;
|
||||||
|
|
||||||
|
fn is_remote_torrent_source(source: &str, explicitly_torrent: bool) -> bool {
|
||||||
|
if crate::torrent::is_remote_torrent_url(source) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !explicitly_torrent {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
reqwest::Url::parse(source.trim())
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|url| matches!(url.scheme(), "http" | "https"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch remote `.torrent` metadata through the same SSRF, redirect, proxy,
|
/// Fetch remote `.torrent` metadata through the same SSRF, redirect, proxy,
|
||||||
/// timeout, and bounded-body rules used by Firelink's metadata path. The
|
/// timeout, and bounded-body rules used by Firelink's metadata path. The
|
||||||
/// bytes are parsed and cached before they can enter the Aria2 lifecycle.
|
/// bytes are parsed and cached before they can enter the Aria2 lifecycle.
|
||||||
async fn fetch_remote_torrent_bytes(
|
async fn fetch_remote_torrent_bytes(
|
||||||
source: &str,
|
source: &str,
|
||||||
proxy: Option<&str>,
|
proxy: Option<&str>,
|
||||||
|
headers: Option<&str>,
|
||||||
|
cookies: Option<&str>,
|
||||||
|
cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>,
|
||||||
) -> Result<Vec<u8>, String> {
|
) -> Result<Vec<u8>, String> {
|
||||||
ensure_reqwest_crypto_provider();
|
ensure_reqwest_crypto_provider();
|
||||||
let proxy = proxy
|
let proxy = proxy
|
||||||
@@ -1761,8 +1777,16 @@ async fn fetch_remote_torrent_bytes(
|
|||||||
.flatten();
|
.flatten();
|
||||||
let mut current = reqwest::Url::parse(source)
|
let mut current = reqwest::Url::parse(source)
|
||||||
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
||||||
|
let original_origin = Some(current.clone());
|
||||||
|
let cookies_available = metadata_cookie_header_present(headers, cookies, cookie_scopes);
|
||||||
|
let mut send_cookies = !cookies_available;
|
||||||
|
let mut cookie_retry_attempted = false;
|
||||||
|
|
||||||
for redirect_count in 0..=MAX_REMOTE_TORRENT_REDIRECTS {
|
let mut redirect_count = 0;
|
||||||
|
loop {
|
||||||
|
if redirect_count > MAX_REMOTE_TORRENT_REDIRECTS {
|
||||||
|
return Err("Too many redirects while fetching torrent metadata".to_string());
|
||||||
|
}
|
||||||
if !matches!(current.scheme(), "http" | "https") {
|
if !matches!(current.scheme(), "http" | "https") {
|
||||||
return Err("Remote torrent metadata must use HTTP or HTTPS".to_string());
|
return Err("Remote torrent metadata must use HTTP or HTTPS".to_string());
|
||||||
}
|
}
|
||||||
@@ -1785,6 +1809,27 @@ async fn fetch_remote_torrent_bytes(
|
|||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
builder = builder.resolve(&host, address);
|
builder = builder.resolve(&host, address);
|
||||||
|
let same_origin_credentials = should_send_metadata_credentials(
|
||||||
|
original_origin.as_ref(),
|
||||||
|
Some(¤t),
|
||||||
|
redirect_count,
|
||||||
|
);
|
||||||
|
let scoped_cookie = if send_cookies {
|
||||||
|
cookie_scope_for_url(current.as_str(), cookie_scopes)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let cookie_header = if same_origin_credentials {
|
||||||
|
scoped_cookie.or(cookies)
|
||||||
|
} else {
|
||||||
|
scoped_cookie
|
||||||
|
};
|
||||||
|
let header_map = if same_origin_credentials {
|
||||||
|
metadata_headers(headers, cookie_header, send_cookies)
|
||||||
|
} else {
|
||||||
|
metadata_headers(None, cookie_header, send_cookies)
|
||||||
|
};
|
||||||
|
builder = builder.default_headers(header_map);
|
||||||
let client = builder
|
let client = builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| format!("could not create torrent metadata client: {error}"))?;
|
.map_err(|error| format!("could not create torrent metadata client: {error}"))?;
|
||||||
@@ -1803,6 +1848,19 @@ async fn fetch_remote_torrent_bytes(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if should_retry_metadata_with_cookies(
|
||||||
|
response.status(),
|
||||||
|
cookies_available && !send_cookies,
|
||||||
|
cookie_retry_attempted,
|
||||||
|
) {
|
||||||
|
send_cookies = true;
|
||||||
|
cookie_retry_attempted = true;
|
||||||
|
current = reqwest::Url::parse(source)
|
||||||
|
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
||||||
|
redirect_count = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if response.status().is_redirection() {
|
if response.status().is_redirection() {
|
||||||
if redirect_count == MAX_REMOTE_TORRENT_REDIRECTS {
|
if redirect_count == MAX_REMOTE_TORRENT_REDIRECTS {
|
||||||
return Err("Too many redirects while fetching torrent metadata".to_string());
|
return Err("Too many redirects while fetching torrent metadata".to_string());
|
||||||
@@ -1815,6 +1873,7 @@ async fn fetch_remote_torrent_bytes(
|
|||||||
current = current
|
current = current
|
||||||
.join(location)
|
.join(location)
|
||||||
.map_err(|_| "Torrent metadata redirect URL is invalid".to_string())?;
|
.map_err(|_| "Torrent metadata redirect URL is invalid".to_string())?;
|
||||||
|
redirect_count += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1861,7 +1920,6 @@ async fn fetch_remote_torrent_bytes(
|
|||||||
return Ok(bytes);
|
return Ok(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("Too many redirects while fetching torrent metadata".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool {
|
fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool {
|
||||||
@@ -3263,6 +3321,20 @@ enum FirelinkDeepLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink {
|
fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink {
|
||||||
|
if deep_link.scheme() == "magnet" {
|
||||||
|
return if deep_link.query().is_some()
|
||||||
|
&& deep_link.fragment().is_none()
|
||||||
|
&& deep_link.username().is_empty()
|
||||||
|
&& deep_link.password().is_none()
|
||||||
|
&& deep_link.port().is_none()
|
||||||
|
&& deep_link.to_string().chars().count() < MAX_DEEP_LINK_PAYLOAD_LEN
|
||||||
|
{
|
||||||
|
FirelinkDeepLink::Add(vec![deep_link.to_string()])
|
||||||
|
} else {
|
||||||
|
FirelinkDeepLink::Invalid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if deep_link.scheme() != "firelink"
|
if deep_link.scheme() != "firelink"
|
||||||
|| !deep_link.username().is_empty()
|
|| !deep_link.username().is_empty()
|
||||||
|| deep_link.password().is_some()
|
|| deep_link.password().is_some()
|
||||||
@@ -3324,6 +3396,57 @@ fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_opened_torrent_argument(raw: &str) -> Option<String> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.is_empty()
|
||||||
|
|| raw.starts_with('-')
|
||||||
|
|| raw.contains('\r')
|
||||||
|
|| raw.contains('\n')
|
||||||
|
|| raw.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_windows_absolute = cfg!(target_os = "windows")
|
||||||
|
&& raw.len() >= 3
|
||||||
|
&& raw.as_bytes()[1] == b':'
|
||||||
|
&& matches!(raw.as_bytes()[2], b'/' | b'\\');
|
||||||
|
let path = if is_windows_absolute {
|
||||||
|
PathBuf::from(raw)
|
||||||
|
} else if let Ok(url) = url::Url::parse(raw) {
|
||||||
|
if url.scheme() != "file" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
url.to_file_path().ok()?
|
||||||
|
} else {
|
||||||
|
PathBuf::from(raw)
|
||||||
|
};
|
||||||
|
if !path.is_absolute() && !is_windows_absolute {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !path.extension().is_some_and(|extension| {
|
||||||
|
extension.to_string_lossy().eq_ignore_ascii_case("torrent")
|
||||||
|
}) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = path.to_string_lossy().into_owned();
|
||||||
|
(!path.contains('\r') && !path.contains('\n')).then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_opened_torrent_paths<I, S>(arguments: I) -> Vec<String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
arguments
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|argument| argument.as_ref().to_str().and_then(normalize_opened_torrent_argument))
|
||||||
|
.filter(|path| seen.insert(path.clone()))
|
||||||
|
.take(MAX_DEEP_LINK_URLS)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn restore_main_window(app_handle: &tauri::AppHandle) {
|
fn restore_main_window(app_handle: &tauri::AppHandle) {
|
||||||
let Some(window) = app_handle.get_webview_window("main") else {
|
let Some(window) = app_handle.get_webview_window("main") else {
|
||||||
if let Some(state) = app_handle.try_state::<MainWindowRestoreState>() {
|
if let Some(state) = app_handle.try_state::<MainWindowRestoreState>() {
|
||||||
@@ -3382,6 +3505,22 @@ fn dispatch_deep_links(app_handle: tauri::AppHandle, deep_links: Vec<url::Url>)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dispatch_opened_torrent_paths(app_handle: tauri::AppHandle, paths: Vec<String>) {
|
||||||
|
if paths.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restore_main_window(&app_handle);
|
||||||
|
let coordinator = app_handle.state::<AppState>().download_coordinator.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(error) = coordinator
|
||||||
|
.send(download::DownloadCmd::CaptureUrls(paths))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("Failed to dispatch opened torrent files: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub async fn rpc_call(
|
pub async fn rpc_call(
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -6465,6 +6604,10 @@ async fn inspect_torrent(
|
|||||||
id: String,
|
id: String,
|
||||||
cache: Option<bool>,
|
cache: Option<bool>,
|
||||||
proxy: Option<String>,
|
proxy: Option<String>,
|
||||||
|
headers: Option<String>,
|
||||||
|
cookies: Option<String>,
|
||||||
|
cookie_scopes: Option<Vec<extension_server::ExtensionCookieScope>>,
|
||||||
|
torrent: Option<bool>,
|
||||||
) -> Result<crate::ipc::TorrentMetadata, AppError> {
|
) -> Result<crate::ipc::TorrentMetadata, AppError> {
|
||||||
properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?;
|
properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?;
|
||||||
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
|
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
|
||||||
@@ -6479,9 +6622,15 @@ async fn inspect_torrent(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Internal);
|
.map_err(AppError::Internal);
|
||||||
}
|
}
|
||||||
if crate::torrent::is_remote_torrent_url(&source) {
|
if is_remote_torrent_source(&source, torrent == Some(true)) {
|
||||||
let remote_source = source.trim();
|
let remote_source = source.trim();
|
||||||
let bytes = fetch_remote_torrent_bytes(remote_source, proxy.as_deref())
|
let bytes = fetch_remote_torrent_bytes(
|
||||||
|
remote_source,
|
||||||
|
proxy.as_deref(),
|
||||||
|
headers.as_deref(),
|
||||||
|
cookies.as_deref(),
|
||||||
|
cookie_scopes.as_deref(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Internal)?;
|
.map_err(AppError::Internal)?;
|
||||||
let parsed = crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?;
|
let parsed = crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?;
|
||||||
@@ -10499,6 +10648,7 @@ mod tests {
|
|||||||
media_progress_speed,
|
media_progress_speed,
|
||||||
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
|
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
|
||||||
metadata_headers, metadata_response_error,
|
metadata_headers, metadata_response_error,
|
||||||
|
is_remote_torrent_source,
|
||||||
normalize_speed_limit_for_aria2,
|
normalize_speed_limit_for_aria2,
|
||||||
normalize_torrent_overall_upload_limit,
|
normalize_torrent_overall_upload_limit,
|
||||||
apply_aria2_torrent_global_options,
|
apply_aria2_torrent_global_options,
|
||||||
@@ -10509,6 +10659,8 @@ mod tests {
|
|||||||
apply_aria2_torrent_dht_options,
|
apply_aria2_torrent_dht_options,
|
||||||
aria2_rpc_port_is_occupied,
|
aria2_rpc_port_is_occupied,
|
||||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||||
|
collect_opened_torrent_paths,
|
||||||
|
normalize_opened_torrent_argument,
|
||||||
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||||
has_resumable_download_assets, is_media_artifact_name,
|
has_resumable_download_assets, is_media_artifact_name,
|
||||||
should_cleanup_media_artifacts_after_failure,
|
should_cleanup_media_artifacts_after_failure,
|
||||||
@@ -11029,17 +11181,33 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remote_torrent_fetch_rejects_non_http_and_embedded_credentials() {
|
async fn remote_torrent_fetch_rejects_non_http_and_embedded_credentials() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::fetch_remote_torrent_bytes("ftp://example.com/sample.torrent", None).await,
|
super::fetch_remote_torrent_bytes(
|
||||||
|
"ftp://example.com/sample.torrent",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
Err("Remote torrent metadata must use HTTP or HTTPS".to_string())
|
Err("Remote torrent metadata must use HTTP or HTTPS".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::fetch_remote_torrent_bytes("https://user:pass@example.com/sample.torrent", None)
|
super::fetch_remote_torrent_bytes(
|
||||||
.await,
|
"https://user:pass@example.com/sample.torrent",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
Err("Torrent metadata URLs must not contain credentials".to_string())
|
Err("Torrent metadata URLs must not contain credentials".to_string())
|
||||||
);
|
);
|
||||||
let proxy_error = super::fetch_remote_torrent_bytes(
|
let proxy_error = super::fetch_remote_torrent_bytes(
|
||||||
"https://example.com/sample.torrent",
|
"https://example.com/sample.torrent",
|
||||||
Some("socks5://127.0.0.1:1080"),
|
Some("socks5://127.0.0.1:1080"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("remote Torrent metadata must use the shared proxy policy");
|
.expect_err("remote Torrent metadata must use the shared proxy policy");
|
||||||
@@ -11995,6 +12163,26 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_torrent_intent_allows_opaque_http_sources() {
|
||||||
|
assert!(is_remote_torrent_source(
|
||||||
|
"https://example.com/download?id=opaque",
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!is_remote_torrent_source(
|
||||||
|
"https://example.com/download?id=opaque",
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(!is_remote_torrent_source(
|
||||||
|
"ftp://example.com/download?id=opaque",
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(is_remote_torrent_source(
|
||||||
|
"https://example.com/files/sample.torrent?download=1",
|
||||||
|
false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recognizes_resume_sidecars_without_treating_the_primary_file_as_partial() {
|
fn recognizes_resume_sidecars_without_treating_the_primary_file_as_partial() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
@@ -12372,6 +12560,48 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_direct_magnet_deep_links() {
|
||||||
|
let deep_link = url::Url::parse(
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parse_firelink_deep_link(&deep_link),
|
||||||
|
FirelinkDeepLink::Add(vec![deep_link.to_string()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_opened_torrent_arguments_to_absolute_files() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_opened_torrent_argument("file:///tmp/example%20one.torrent"),
|
||||||
|
Some("/tmp/example one.torrent".to_string())
|
||||||
|
);
|
||||||
|
assert!(normalize_opened_torrent_argument("relative/example.torrent").is_none());
|
||||||
|
assert!(normalize_opened_torrent_argument("--flag.torrent").is_none());
|
||||||
|
assert!(normalize_opened_torrent_argument("/tmp/line\nbreak.torrent").is_none());
|
||||||
|
assert!(normalize_opened_torrent_argument("file:///tmp/line%0Abreak.torrent").is_none());
|
||||||
|
assert!(normalize_opened_torrent_argument("/tmp/example.zip").is_none());
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
assert!(normalize_opened_torrent_argument("C:/example.torrent").is_some());
|
||||||
|
} else {
|
||||||
|
assert!(normalize_opened_torrent_argument("C:/example.torrent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
let paths = collect_opened_torrent_paths([
|
||||||
|
"--new-instance",
|
||||||
|
"/tmp/one.torrent",
|
||||||
|
"/tmp/one.torrent",
|
||||||
|
"/tmp/two.TORRENT",
|
||||||
|
"/tmp/readme.txt",
|
||||||
|
]);
|
||||||
|
assert_eq!(paths, vec![
|
||||||
|
"/tmp/one.torrent".to_string(),
|
||||||
|
"/tmp/two.TORRENT".to_string(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_launch_variants_and_nested_schemes() {
|
fn rejects_launch_variants_and_nested_schemes() {
|
||||||
let links = [
|
let links = [
|
||||||
@@ -13349,8 +13579,10 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(MainWindowRestoreState::default())
|
.manage(MainWindowRestoreState::default())
|
||||||
.manage(properties_window::PropertiesWindowRegistry::default())
|
.manage(properties_window::PropertiesWindowRegistry::default())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
restore_main_window(app);
|
restore_main_window(app);
|
||||||
|
let paths = collect_opened_torrent_paths(args);
|
||||||
|
dispatch_opened_torrent_paths(app.clone(), paths);
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
.manage(Aria2DaemonGuard::new())
|
.manage(Aria2DaemonGuard::new())
|
||||||
@@ -13603,6 +13835,12 @@ pub fn run() {
|
|||||||
.map_err(|error| format!("failed to create main window: {error}"))?;
|
.map_err(|error| format!("failed to create main window: {error}"))?;
|
||||||
restore_pending_main_window(app.handle());
|
restore_pending_main_window(app.handle());
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
|
dispatch_opened_torrent_paths(
|
||||||
|
app.handle().clone(),
|
||||||
|
collect_opened_torrent_paths(std::env::args_os().skip(1)),
|
||||||
|
);
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
window
|
window
|
||||||
@@ -14670,6 +14908,13 @@ pub fn run() {
|
|||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app_handle, event| match event {
|
.run(|app_handle, event| match event {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
tauri::RunEvent::Opened { urls } => {
|
||||||
|
let paths = collect_opened_torrent_paths(
|
||||||
|
urls.into_iter().map(|url| url.to_string()),
|
||||||
|
);
|
||||||
|
dispatch_opened_torrent_paths(app_handle.clone(), paths);
|
||||||
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
tauri::RunEvent::Reopen {
|
tauri::RunEvent::Reopen {
|
||||||
has_visible_windows,
|
has_visible_windows,
|
||||||
|
|||||||
@@ -38,12 +38,26 @@
|
|||||||
"resources": {
|
"resources": {
|
||||||
"engine-dist/": "engine-dist/",
|
"engine-dist/": "engine-dist/",
|
||||||
"../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
"../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
||||||
}
|
},
|
||||||
|
"fileAssociations": [
|
||||||
|
{
|
||||||
|
"ext": ["torrent"],
|
||||||
|
"mimeType": "application/x-bittorrent",
|
||||||
|
"name": "BitTorrent file",
|
||||||
|
"description": "BitTorrent metadata file",
|
||||||
|
"role": "Viewer",
|
||||||
|
"rank": "Alternate",
|
||||||
|
"exportedType": {
|
||||||
|
"identifier": "com.nimbold.firelink.torrent",
|
||||||
|
"conformsTo": ["public.data"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"deep-link": {
|
"deep-link": {
|
||||||
"desktop": {
|
"desktop": {
|
||||||
"schemes": ["firelink"]
|
"schemes": ["firelink", "magnet"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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";
|
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||||
|
|
||||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, batch: boolean, batch_name: string | null, };
|
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, };
|
||||||
|
|||||||
@@ -62,10 +62,12 @@ const formatBytes = (bytes: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeComparableUrl = (rawUrl: string) => {
|
const normalizeComparableUrl = (rawUrl: string) => {
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
if (trimmed.startsWith('/') || /^[a-z]:[\\/]/i.test(trimmed)) return trimmed;
|
||||||
try {
|
try {
|
||||||
return new URL(rawUrl).href;
|
return new URL(trimmed).href;
|
||||||
} catch {
|
} catch {
|
||||||
return rawUrl.trim();
|
return trimmed;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,6 +143,7 @@ export const AddDownloadsModal = () => {
|
|||||||
pendingAddHeaders,
|
pendingAddHeaders,
|
||||||
pendingAddCookies,
|
pendingAddCookies,
|
||||||
pendingAddMediaUrls,
|
pendingAddMediaUrls,
|
||||||
|
pendingAddTorrentUrls,
|
||||||
pendingAddBatchName,
|
pendingAddBatchName,
|
||||||
pendingAddRequestContexts,
|
pendingAddRequestContexts,
|
||||||
pendingAddRequestVersion,
|
pendingAddRequestVersion,
|
||||||
@@ -530,13 +533,8 @@ export const AddDownloadsModal = () => {
|
|||||||
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
|
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
|
||||||
});
|
});
|
||||||
|
|
||||||
const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => {
|
const forcedMediaUrls = new Set(pendingAddMediaUrls.map(normalizeComparableUrl));
|
||||||
try {
|
const forcedTorrentUrls = new Set(pendingAddTorrentUrls.map(normalizeComparableUrl));
|
||||||
return new URL(url).href;
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const requestFilenames = Object.fromEntries(
|
const requestFilenames = Object.fromEntries(
|
||||||
Object.entries(pendingAddRequestContexts)
|
Object.entries(pendingAddRequestContexts)
|
||||||
.filter(([, context]) => Boolean(context.filename))
|
.filter(([, context]) => Boolean(context.filename))
|
||||||
@@ -564,13 +562,15 @@ export const AddDownloadsModal = () => {
|
|||||||
requestFilenames,
|
requestFilenames,
|
||||||
requestContextVersions,
|
requestContextVersions,
|
||||||
playlistExpansions,
|
playlistExpansions,
|
||||||
selectedBySourceUrl
|
selectedBySourceUrl,
|
||||||
|
forcedTorrentUrls
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
urls,
|
urls,
|
||||||
pendingAddFilename,
|
pendingAddFilename,
|
||||||
pendingAddMediaUrls,
|
pendingAddMediaUrls,
|
||||||
|
pendingAddTorrentUrls,
|
||||||
pendingAddRequestContexts,
|
pendingAddRequestContexts,
|
||||||
hasExtensionRequestContext,
|
hasExtensionRequestContext,
|
||||||
playlistExpansions
|
playlistExpansions
|
||||||
@@ -608,7 +608,11 @@ export const AddDownloadsModal = () => {
|
|||||||
source: row.sourceUrl,
|
source: row.sourceUrl,
|
||||||
id: torrentCacheId,
|
id: torrentCacheId,
|
||||||
cache: true,
|
cache: true,
|
||||||
proxy: proxy ?? undefined
|
proxy: proxy ?? undefined,
|
||||||
|
headers: headersForRow(contextUrl) || undefined,
|
||||||
|
cookies: cookiesForRow(contextUrl, row.sourceUrl) || undefined,
|
||||||
|
cookieScopes: requestContext?.cookieScopes || undefined,
|
||||||
|
torrent: true
|
||||||
});
|
});
|
||||||
const isCurrentTorrentDraft = addModalOpenRef.current
|
const isCurrentTorrentDraft = addModalOpenRef.current
|
||||||
&& parsedItemsRef.current.some(currentRow =>
|
&& parsedItemsRef.current.some(currentRow =>
|
||||||
@@ -1465,6 +1469,7 @@ export const AddDownloadsModal = () => {
|
|||||||
try {
|
try {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
allocatedId = id;
|
allocatedId = id;
|
||||||
|
const contextUrl = requestContextUrlForRow(item);
|
||||||
let torrentPath = item.torrentPath;
|
let torrentPath = item.torrentPath;
|
||||||
if (item.isTorrent) {
|
if (item.isTorrent) {
|
||||||
if (item.torrentPath) {
|
if (item.torrentPath) {
|
||||||
@@ -1483,7 +1488,11 @@ export const AddDownloadsModal = () => {
|
|||||||
source: item.sourceUrl,
|
source: item.sourceUrl,
|
||||||
id,
|
id,
|
||||||
cache: true,
|
cache: true,
|
||||||
proxy: proxy ?? undefined
|
proxy: proxy ?? undefined,
|
||||||
|
headers: headersForRow(contextUrl) || undefined,
|
||||||
|
cookies: cookiesForRow(contextUrl, item.sourceUrl) || undefined,
|
||||||
|
cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined,
|
||||||
|
torrent: true
|
||||||
});
|
});
|
||||||
torrentPath = torrentData.torrentPath;
|
torrentPath = torrentData.torrentPath;
|
||||||
}
|
}
|
||||||
@@ -1492,8 +1501,6 @@ export const AddDownloadsModal = () => {
|
|||||||
? mediaFileNameForSelectedFormat(item.file, item)
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
: canonicalizeDownloadFileName(item.file);
|
: canonicalizeDownloadFileName(item.file);
|
||||||
let formatSelector = mediaFormatSelectorForRow(item);
|
let formatSelector = mediaFormatSelectorForRow(item);
|
||||||
const contextUrl = requestContextUrlForRow(item);
|
|
||||||
|
|
||||||
const category = categoryForFileName(finalFile);
|
const category = categoryForFileName(finalFile);
|
||||||
const added = await addDownload({
|
const added = await addDownload({
|
||||||
id,
|
id,
|
||||||
|
|||||||
+10
-1
@@ -41,7 +41,16 @@ type CommandMap = {
|
|||||||
result: MediaPlaylistMetadata;
|
result: MediaPlaylistMetadata;
|
||||||
};
|
};
|
||||||
inspect_torrent: {
|
inspect_torrent: {
|
||||||
args: { source: string; id: string; cache?: boolean; proxy?: string };
|
args: {
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
cache?: boolean;
|
||||||
|
proxy?: string;
|
||||||
|
headers?: string;
|
||||||
|
cookies?: string;
|
||||||
|
cookieScopes?: Array<ExtensionCookieScope>;
|
||||||
|
torrent?: boolean;
|
||||||
|
};
|
||||||
result: TorrentMetadata;
|
result: TorrentMetadata;
|
||||||
};
|
};
|
||||||
rekey_torrent_metadata: {
|
rekey_torrent_metadata: {
|
||||||
|
|||||||
@@ -2683,6 +2683,7 @@ describe('useDownloadStore', () => {
|
|||||||
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
||||||
],
|
],
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2721,6 +2722,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: null,
|
cookies: null,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2744,6 +2746,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2768,6 +2771,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: null,
|
cookies: null,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: true,
|
batch: true,
|
||||||
batch_name: 'Example Gallery'
|
batch_name: 'Example Gallery'
|
||||||
});
|
});
|
||||||
@@ -2792,6 +2796,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: 'first=session',
|
cookies: 'first=session',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2804,6 +2809,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: 'second=session',
|
cookies: 'second=session',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2843,6 +2849,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: true,
|
media: true,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2865,6 +2872,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2884,6 +2892,7 @@ describe('useDownloadStore', () => {
|
|||||||
{ url: 'https://media.example/', cookies: 'session=secret' }
|
{ url: 'https://media.example/', cookies: 'session=secret' }
|
||||||
],
|
],
|
||||||
media: true,
|
media: true,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2903,6 +2912,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2915,6 +2925,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: null,
|
cookies: null,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2945,6 +2956,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: 'session=secret',
|
cookies: 'session=secret',
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: true,
|
media: true,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
@@ -2967,6 +2979,7 @@ describe('useDownloadStore', () => {
|
|||||||
cookies: null,
|
cookies: null,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: false,
|
media: false,
|
||||||
|
torrent: false,
|
||||||
batch: false,
|
batch: false,
|
||||||
batch_name: null
|
batch_name: null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -924,6 +924,7 @@ export type PendingAddRequestContext = {
|
|||||||
cookies: string;
|
cookies: string;
|
||||||
cookieScopes?: ExtensionCookieScope[];
|
cookieScopes?: ExtensionCookieScope[];
|
||||||
media: boolean;
|
media: boolean;
|
||||||
|
torrent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteModalState = {
|
export type DeleteModalState = {
|
||||||
@@ -956,6 +957,7 @@ interface DownloadState {
|
|||||||
pendingAddHeaders: string;
|
pendingAddHeaders: string;
|
||||||
pendingAddCookies: string;
|
pendingAddCookies: string;
|
||||||
pendingAddMediaUrls: string[];
|
pendingAddMediaUrls: string[];
|
||||||
|
pendingAddTorrentUrls: string[];
|
||||||
pendingAddBatch: boolean;
|
pendingAddBatch: boolean;
|
||||||
pendingAddBatchName: string;
|
pendingAddBatchName: string;
|
||||||
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
|
||||||
@@ -971,7 +973,8 @@ interface DownloadState {
|
|||||||
media?: boolean,
|
media?: boolean,
|
||||||
cookieScopes?: ExtensionCookieScope[] | null,
|
cookieScopes?: ExtensionCookieScope[] | null,
|
||||||
batch?: boolean,
|
batch?: boolean,
|
||||||
batchName?: string | null
|
batchName?: string | null,
|
||||||
|
torrent?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
|
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
|
||||||
deleteModalState: DeleteModalState;
|
deleteModalState: DeleteModalState;
|
||||||
@@ -1399,6 +1402,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
pendingAddHeaders: '',
|
pendingAddHeaders: '',
|
||||||
pendingAddCookies: '',
|
pendingAddCookies: '',
|
||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
|
pendingAddTorrentUrls: [],
|
||||||
pendingAddBatch: false,
|
pendingAddBatch: false,
|
||||||
pendingAddBatchName: '',
|
pendingAddBatchName: '',
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
@@ -1420,6 +1424,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
pendingAddHeaders: '',
|
pendingAddHeaders: '',
|
||||||
pendingAddCookies: '',
|
pendingAddCookies: '',
|
||||||
pendingAddMediaUrls: [],
|
pendingAddMediaUrls: [],
|
||||||
|
pendingAddTorrentUrls: [],
|
||||||
pendingAddBatch: false,
|
pendingAddBatch: false,
|
||||||
pendingAddBatchName: '',
|
pendingAddBatchName: '',
|
||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
@@ -1436,7 +1441,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
media = false,
|
media = false,
|
||||||
cookieScopes,
|
cookieScopes,
|
||||||
batch = false,
|
batch = false,
|
||||||
batchName
|
batchName,
|
||||||
|
torrent = false
|
||||||
) => set((state) => {
|
) => set((state) => {
|
||||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||||
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
||||||
@@ -1482,12 +1488,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
headers: cleanHeaders,
|
headers: cleanHeaders,
|
||||||
cookies: cleanCookies,
|
cookies: cleanCookies,
|
||||||
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
|
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
|
||||||
media
|
media,
|
||||||
|
...(torrent ? { torrent: true } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts)
|
const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts)
|
||||||
.filter(([, context]) => context.media)
|
.filter(([, context]) => context.media)
|
||||||
.map(([url]) => url);
|
.map(([url]) => url);
|
||||||
|
const pendingAddTorrentUrls = Object.entries(pendingAddRequestContexts)
|
||||||
|
.filter(([, context]) => context.torrent)
|
||||||
|
.map(([url]) => url);
|
||||||
return {
|
return {
|
||||||
isAddModalOpen: true,
|
isAddModalOpen: true,
|
||||||
pendingAddUrls: mergedUrls,
|
pendingAddUrls: mergedUrls,
|
||||||
@@ -1496,6 +1506,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
pendingAddHeaders: cleanHeaders,
|
pendingAddHeaders: cleanHeaders,
|
||||||
pendingAddCookies: cleanCookies,
|
pendingAddCookies: cleanCookies,
|
||||||
pendingAddMediaUrls,
|
pendingAddMediaUrls,
|
||||||
|
pendingAddTorrentUrls,
|
||||||
pendingAddBatch: nextBatch,
|
pendingAddBatch: nextBatch,
|
||||||
pendingAddBatchName: nextBatchName,
|
pendingAddBatchName: nextBatchName,
|
||||||
pendingAddRequestContexts,
|
pendingAddRequestContexts,
|
||||||
@@ -1523,7 +1534,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
request.media === true,
|
request.media === true,
|
||||||
request.media === true ? undefined : request.cookie_scopes,
|
request.media === true ? undefined : request.cookie_scopes,
|
||||||
request.batch === true && urls.length >= 2,
|
request.batch === true && urls.length >= 2,
|
||||||
request.batch_name
|
request.batch_name,
|
||||||
|
request.torrent === true
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||||
|
|||||||
@@ -120,6 +120,29 @@ describe('add download metadata workflow', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves an explicit torrent handoff for an opaque remote URL', () => {
|
||||||
|
const sourceUrl = 'https://example.com/download?id=opaque';
|
||||||
|
const rows = reconcileDownloadRows(
|
||||||
|
sourceUrl,
|
||||||
|
[],
|
||||||
|
'example.torrent',
|
||||||
|
new Set(),
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
new Set([sourceUrl])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
sourceUrl,
|
||||||
|
isTorrent: true,
|
||||||
|
torrentCacheId: `${rows[0].id}-1`,
|
||||||
|
file: 'example.torrent'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('gives refreshed torrent metadata a new cache identity', () => {
|
it('gives refreshed torrent metadata a new cache identity', () => {
|
||||||
const existing = row({
|
const existing = row({
|
||||||
id: 'torrent-row',
|
id: 'torrent-row',
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ export const reconcileDownloadRows = (
|
|||||||
requestFilenames: Readonly<Record<string, string>> = {},
|
requestFilenames: Readonly<Record<string, string>> = {},
|
||||||
requestContextVersions: Readonly<Record<string, number>> = {},
|
requestContextVersions: Readonly<Record<string, number>> = {},
|
||||||
playlistExpansions: PlaylistExpansions = {},
|
playlistExpansions: PlaylistExpansions = {},
|
||||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
|
selectedBySourceUrl: Readonly<Record<string, boolean>> = {},
|
||||||
|
forceTorrentUrls: ReadonlySet<string> = new Set()
|
||||||
): AddDownloadDraftRow[] => {
|
): AddDownloadDraftRow[] => {
|
||||||
const inputs = parseInputLines(
|
const inputs = parseInputLines(
|
||||||
rawText,
|
rawText,
|
||||||
@@ -249,6 +250,7 @@ export const reconcileDownloadRows = (
|
|||||||
const preserved = existing.get(input.sourceUrl);
|
const preserved = existing.get(input.sourceUrl);
|
||||||
if (preserved) {
|
if (preserved) {
|
||||||
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
||||||
|
const forcedTorrent = input.valid && forceTorrentUrls.has(input.sourceUrl);
|
||||||
const requestContextVersion = input.requestContextVersion;
|
const requestContextVersion = input.requestContextVersion;
|
||||||
const contextChanged = requestContextVersion !== undefined
|
const contextChanged = requestContextVersion !== undefined
|
||||||
&& requestContextVersion !== preserved.requestContextVersion;
|
&& requestContextVersion !== preserved.requestContextVersion;
|
||||||
@@ -257,7 +259,10 @@ export const reconcileDownloadRows = (
|
|||||||
|| preserved.playlistIndex !== input.playlistIndex
|
|| preserved.playlistIndex !== input.playlistIndex
|
||||||
|| preserved.playlistCount !== input.playlistCount
|
|| preserved.playlistCount !== input.playlistCount
|
||||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
if ((forcedMedia && !preserved.isMedia)
|
||||||
|
|| (forcedTorrent && !preserved.isTorrent)
|
||||||
|
|| contextChanged
|
||||||
|
|| playlistContextChanged) {
|
||||||
const nextGeneration = preserved.generation + 1;
|
const nextGeneration = preserved.generation + 1;
|
||||||
const requestedFilename = input.playlistSourceUrl
|
const requestedFilename = input.playlistSourceUrl
|
||||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||||
@@ -271,7 +276,7 @@ export const reconcileDownloadRows = (
|
|||||||
generation: nextGeneration,
|
generation: nextGeneration,
|
||||||
requestContextVersion,
|
requestContextVersion,
|
||||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||||
isTorrent: input.isTorrent,
|
isTorrent: input.isTorrent || forcedTorrent,
|
||||||
size: undefined,
|
size: undefined,
|
||||||
sizeBytes: undefined,
|
sizeBytes: undefined,
|
||||||
resumable: undefined,
|
resumable: undefined,
|
||||||
@@ -290,7 +295,7 @@ export const reconcileDownloadRows = (
|
|||||||
playlistError: undefined,
|
playlistError: undefined,
|
||||||
metadataBlockedReason: undefined,
|
metadataBlockedReason: undefined,
|
||||||
torrentPath: undefined,
|
torrentPath: undefined,
|
||||||
torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
|
torrentCacheId: input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
|
||||||
torrentInfoHash: undefined,
|
torrentInfoHash: undefined,
|
||||||
torrentFiles: undefined,
|
torrentFiles: undefined,
|
||||||
selectedTorrentFileIndices: undefined
|
selectedTorrentFileIndices: undefined
|
||||||
@@ -323,7 +328,7 @@ export const reconcileDownloadRows = (
|
|||||||
|| forceMediaUrls.has(input.sourceUrl)
|
|| forceMediaUrls.has(input.sourceUrl)
|
||||||
|| isMediaUrl(input.sourceUrl)
|
|| isMediaUrl(input.sourceUrl)
|
||||||
),
|
),
|
||||||
isTorrent: input.valid && Boolean(input.isTorrent),
|
isTorrent: input.valid && (Boolean(input.isTorrent) || forceTorrentUrls.has(input.sourceUrl)),
|
||||||
isPlaylist: input.isPlaylist,
|
isPlaylist: input.isPlaylist,
|
||||||
playlistSourceUrl: input.playlistSourceUrl,
|
playlistSourceUrl: input.playlistSourceUrl,
|
||||||
playlistTitle: input.playlistTitle,
|
playlistTitle: input.playlistTitle,
|
||||||
@@ -331,7 +336,9 @@ export const reconcileDownloadRows = (
|
|||||||
playlistCount: input.playlistCount,
|
playlistCount: input.playlistCount,
|
||||||
playlistEntryTitle: input.playlistEntryTitle,
|
playlistEntryTitle: input.playlistEntryTitle,
|
||||||
metadataBlockedReason: undefined,
|
metadataBlockedReason: undefined,
|
||||||
torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined,
|
torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl))
|
||||||
|
? `${id}-${generation}`
|
||||||
|
: undefined,
|
||||||
selected: input.selected !== false
|
selected: input.selected !== false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user