mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-25 10:07:44 +00:00
fix(aria2): harden protocol and torrent transfers
This commit is contained in:
@@ -19,6 +19,7 @@ GEMINI.md
|
|||||||
implementation_plan.md
|
implementation_plan.md
|
||||||
CROSS_PLATFORM_CHECKLIST.md
|
CROSS_PLATFORM_CHECKLIST.md
|
||||||
Cross-platform-checklist-gemini.MD
|
Cross-platform-checklist-gemini.MD
|
||||||
|
ARIA2_AVERAGE_USER_AUDIT.md
|
||||||
YouTube_media_download_handoff.md
|
YouTube_media_download_handoff.md
|
||||||
Release_checklist.md
|
Release_checklist.md
|
||||||
Release Checklist/
|
Release Checklist/
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ pub struct DownloadItem {
|
|||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
pub sftp_host_key_md: Option<String>,
|
||||||
|
#[ts(optional)]
|
||||||
pub headers: Option<String>,
|
pub headers: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub checksum: Option<String>,
|
pub checksum: Option<String>,
|
||||||
@@ -207,6 +209,9 @@ pub struct DownloadItem {
|
|||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
pub credentials_required: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional)]
|
||||||
pub last_error_kind: Option<DownloadErrorKind>,
|
pub last_error_kind: Option<DownloadErrorKind>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
|||||||
+91
-7
@@ -1726,10 +1726,14 @@ async fn resolve_and_validate_url_host(
|
|||||||
let addrs: Vec<_> = if let Ok(ip) = lookup_host.parse::<std::net::IpAddr>() {
|
let addrs: Vec<_> = if let Ok(ip) = lookup_host.parse::<std::net::IpAddr>() {
|
||||||
vec![std::net::SocketAddr::new(ip, port)]
|
vec![std::net::SocketAddr::new(ip, port)]
|
||||||
} else {
|
} else {
|
||||||
tokio::net::lookup_host((lookup_host, port))
|
tokio::time::timeout(
|
||||||
.await
|
std::time::Duration::from_secs(5),
|
||||||
.map_err(|_| "SSRF blocked: DNS resolution failed")?
|
tokio::net::lookup_host((lookup_host, port)),
|
||||||
.collect()
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "SSRF blocked: DNS resolution timed out")?
|
||||||
|
.map_err(|_| "SSRF blocked: DNS resolution failed")?
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
let addr = addrs.first().copied().ok_or("SSRF blocked: No DNS records")?;
|
let addr = addrs.first().copied().ok_or("SSRF blocked: No DNS records")?;
|
||||||
if addrs.iter().any(|candidate| is_blocked_network_address(candidate.ip())) {
|
if addrs.iter().any(|candidate| is_blocked_network_address(candidate.ip())) {
|
||||||
@@ -6160,6 +6164,60 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn validate_torrent_web_seed_destinations(seeds: &[String]) -> Result<(), String> {
|
||||||
|
for uri in seeds {
|
||||||
|
let parsed = reqwest::Url::parse(uri)
|
||||||
|
.map_err(|_| "Torrent web-seed URI is invalid".to_string())?;
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https")
|
||||||
|
|| parsed.host_str().is_none_or(str::is_empty)
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err("Torrent web-seed URI must use HTTP or HTTPS without credentials or fragments".to_string());
|
||||||
|
}
|
||||||
|
resolve_and_validate_url_host(&parsed).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embedded web seeds are optional Torrent accelerators, not the transfer's
|
||||||
|
/// source of truth. Keep the peer-only path usable when a stale seed domain is
|
||||||
|
/// temporarily unavailable, while still rejecting destinations that resolve
|
||||||
|
/// to local or private addresses before they are offered to Aria2.
|
||||||
|
pub(crate) async fn filter_torrent_web_seed_destinations(
|
||||||
|
seeds: &[String],
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let mut allowed = Vec::with_capacity(seeds.len());
|
||||||
|
for seed in seeds {
|
||||||
|
let parsed = reqwest::Url::parse(seed)
|
||||||
|
.map_err(|_| "Torrent web-seed URI is invalid".to_string())?;
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https")
|
||||||
|
|| parsed.host_str().is_none_or(str::is_empty)
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err("Torrent web-seed URI must use HTTP or HTTPS without credentials or fragments".to_string());
|
||||||
|
}
|
||||||
|
match resolve_and_validate_url_host(&parsed).await {
|
||||||
|
Ok(_) => allowed.push(seed.clone()),
|
||||||
|
Err(error)
|
||||||
|
if error == "SSRF blocked: DNS resolution failed"
|
||||||
|
|| error == "SSRF blocked: DNS resolution timed out"
|
||||||
|
|| error == "SSRF blocked: No DNS records" =>
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Skipping unavailable embedded Torrent web seed {}",
|
||||||
|
parsed.host_str().unwrap_or("<unknown host>")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(allowed)
|
||||||
|
}
|
||||||
|
|
||||||
async fn validate_torrent_enqueue(
|
async fn validate_torrent_enqueue(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
item: &mut queue::EnqueueItem,
|
item: &mut queue::EnqueueItem,
|
||||||
@@ -6191,6 +6249,17 @@ async fn validate_torrent_enqueue(
|
|||||||
let bytes = std::fs::read(path)
|
let bytes = std::fs::read(path)
|
||||||
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
|
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
|
||||||
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||||
|
let normalized_user_web_seeds = queue::normalize_torrent_web_seeds(
|
||||||
|
item.torrent_web_seeds.as_deref(),
|
||||||
|
&metadata.files,
|
||||||
|
)?;
|
||||||
|
let user_web_seed_uris = normalized_user_web_seeds
|
||||||
|
.iter()
|
||||||
|
.map(|seed| seed.uri.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
validate_torrent_web_seed_destinations(&user_web_seed_uris).await?;
|
||||||
|
item.torrent_web_seeds = (!normalized_user_web_seeds.is_empty())
|
||||||
|
.then_some(normalized_user_web_seeds);
|
||||||
crate::torrent::validate_info_hash(
|
crate::torrent::validate_info_hash(
|
||||||
item.torrent_info_hash.as_deref(),
|
item.torrent_info_hash.as_deref(),
|
||||||
&metadata.info_hash,
|
&metadata.info_hash,
|
||||||
@@ -6545,9 +6614,10 @@ async fn resolve_magnet_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
|
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
|
||||||
|
let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?;
|
||||||
let metadata_result = crate::torrent_probe::run_metadata_probe(
|
let metadata_result = crate::torrent_probe::run_metadata_probe(
|
||||||
client,
|
client,
|
||||||
source,
|
&sanitized_source,
|
||||||
options,
|
options,
|
||||||
&metadata_path,
|
&metadata_path,
|
||||||
Duration::from_secs(60),
|
Duration::from_secs(60),
|
||||||
@@ -6737,6 +6807,8 @@ async fn enqueue_download_locked(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Internal)?;
|
.map_err(AppError::Internal)?;
|
||||||
} else {
|
} else {
|
||||||
|
item.sftp_host_key_md = queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref())
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
validate_enqueue_uris(&item.url, item.mirrors.as_deref())
|
validate_enqueue_uris(&item.url, item.mirrors.as_deref())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Internal)?;
|
.map_err(AppError::Internal)?;
|
||||||
@@ -6834,7 +6906,13 @@ async fn enqueue_many(
|
|||||||
let validation = if item.is_torrent.unwrap_or(false) {
|
let validation = if item.is_torrent.unwrap_or(false) {
|
||||||
validate_torrent_enqueue(&app_handle, &mut item).await
|
validate_torrent_enqueue(&app_handle, &mut item).await
|
||||||
} else {
|
} else {
|
||||||
validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await
|
match queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref()) {
|
||||||
|
Ok(normalized) => {
|
||||||
|
item.sftp_host_key_md = normalized;
|
||||||
|
validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = validation {
|
if let Err(error) = validation {
|
||||||
results.push(crate::ipc::EnqueueResult {
|
results.push(crate::ipc::EnqueueResult {
|
||||||
@@ -8677,6 +8755,7 @@ async fn verify_torrent_data(
|
|||||||
speed_limit: item.speed_limit.clone(),
|
speed_limit: item.speed_limit.clone(),
|
||||||
username: item.username.clone(),
|
username: item.username.clone(),
|
||||||
password: item.password.clone(),
|
password: item.password.clone(),
|
||||||
|
sftp_host_key_md: item.sftp_host_key_md.clone(),
|
||||||
headers: item.headers.clone(),
|
headers: item.headers.clone(),
|
||||||
checksum: item.checksum.clone(),
|
checksum: item.checksum.clone(),
|
||||||
cookies: item.cookies.clone(),
|
cookies: item.cookies.clone(),
|
||||||
@@ -8901,7 +8980,10 @@ async fn normalize_persisted_torrent_web_seeds(
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| format!("could not read cached Torrent metadata: {error}"))?;
|
.map_err(|error| format!("could not read cached Torrent metadata: {error}"))?;
|
||||||
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||||
crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files)
|
let normalized = crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files)?;
|
||||||
|
let web_seed_uris = normalized.iter().map(|seed| seed.uri.clone()).collect::<Vec<_>>();
|
||||||
|
validate_torrent_web_seed_destinations(&web_seed_uris).await?;
|
||||||
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -8979,6 +9061,8 @@ async fn set_torrent_web_seeds(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
|
let web_seed_uris = normalized.iter().map(|seed| seed.uri.clone()).collect::<Vec<_>>();
|
||||||
|
validate_torrent_web_seed_destinations(&web_seed_uris).await?;
|
||||||
let previous_seeds =
|
let previous_seeds =
|
||||||
replace_persisted_torrent_web_seeds(database.inner(), &id, &normalized)?;
|
replace_persisted_torrent_web_seeds(database.inner(), &id, &normalized)?;
|
||||||
// The download can cross the queued/active boundary while metadata is
|
// The download can cross the queued/active boundary while metadata is
|
||||||
|
|||||||
+352
-89
@@ -51,6 +51,28 @@ pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999";
|
|||||||
pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
|
pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
|
||||||
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
|
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
|
||||||
|
|
||||||
|
pub fn normalize_sftp_host_key_md(value: Option<&str>) -> Result<Option<String>, String> {
|
||||||
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let (kind, digest) = value
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| "SFTP host-key fingerprint must use TYPE=DIGEST form".to_string())?;
|
||||||
|
let kind = kind.trim().to_ascii_lowercase();
|
||||||
|
let digest = digest.trim().to_ascii_lowercase();
|
||||||
|
let expected_length = match kind.as_str() {
|
||||||
|
"md5" => 32,
|
||||||
|
"sha-1" => 40,
|
||||||
|
_ => return Err("SFTP host-key fingerprint type must be md5 or sha-1".to_string()),
|
||||||
|
};
|
||||||
|
if digest.len() != expected_length || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
return Err(format!(
|
||||||
|
"SFTP {kind} host-key fingerprint must contain exactly {expected_length} hexadecimal characters"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(format!("{kind}={digest}")))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result<Option<String>, String> {
|
pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result<Option<String>, String> {
|
||||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -732,6 +754,7 @@ pub struct SpawnPayload {
|
|||||||
pub speed_limit: Option<String>,
|
pub speed_limit: Option<String>,
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
|
pub sftp_host_key_md: Option<String>,
|
||||||
pub headers: Option<String>,
|
pub headers: Option<String>,
|
||||||
pub checksum: Option<String>,
|
pub checksum: Option<String>,
|
||||||
pub cookies: Option<String>,
|
pub cookies: Option<String>,
|
||||||
@@ -4980,14 +5003,14 @@ enum BoundedRangeSupport {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 {
|
async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> Result<i32, String> {
|
||||||
let requested = clamp_download_connections(
|
let requested = clamp_download_connections(
|
||||||
payload
|
payload
|
||||||
.connections
|
.connections
|
||||||
.unwrap_or(DOWNLOAD_CONNECTIONS_MIN),
|
.unwrap_or(DOWNLOAD_CONNECTIONS_MIN),
|
||||||
);
|
);
|
||||||
if requested <= 1 {
|
if requested <= 1 {
|
||||||
return requested;
|
return Ok(requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
for uri in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) {
|
for uri in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) {
|
||||||
@@ -5002,7 +5025,7 @@ async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 {
|
|||||||
id,
|
id,
|
||||||
uri_host_for_log(&uri)
|
uri_host_for_log(&uri)
|
||||||
);
|
);
|
||||||
return 1;
|
return Ok(1);
|
||||||
}
|
}
|
||||||
Ok(BoundedRangeSupport::Supported) => {}
|
Ok(BoundedRangeSupport::Supported) => {}
|
||||||
Ok(BoundedRangeSupport::Unknown) => {
|
Ok(BoundedRangeSupport::Unknown) => {
|
||||||
@@ -5013,6 +5036,7 @@ async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 {
|
|||||||
requested
|
requested
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Err(error) if error.starts_with("SSRF blocked:") => return Err(error),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"aria2 range probe [{}]: {} probe failed: {}; keeping {} connections",
|
"aria2 range probe [{}]: {} probe failed: {}; keeping {} connections",
|
||||||
@@ -5025,7 +5049,7 @@ async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requested
|
Ok(requested)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_http_uri(uri: &str) -> bool {
|
fn is_http_uri(uri: &str) -> bool {
|
||||||
@@ -5069,44 +5093,93 @@ async fn probe_bounded_range_support(
|
|||||||
) -> Result<BoundedRangeSupport, String> {
|
) -> Result<BoundedRangeSupport, String> {
|
||||||
crate::ensure_reqwest_crypto_provider();
|
crate::ensure_reqwest_crypto_provider();
|
||||||
|
|
||||||
let mut builder = reqwest::Client::builder()
|
let original = reqwest::Url::parse(uri).map_err(|error| error.to_string())?;
|
||||||
.redirect(reqwest::redirect::Policy::limited(5))
|
let mut current = original.clone();
|
||||||
.timeout(std::time::Duration::from_secs(10));
|
let mut credentials_allowed = true;
|
||||||
|
for redirect_count in 0..=5 {
|
||||||
|
let (host, address) = crate::resolve_and_validate_url_host(¤t).await?;
|
||||||
|
let mut builder = reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.resolve(&host, address);
|
||||||
|
|
||||||
if let Some(proxy) = payload
|
if let Some(proxy) = payload
|
||||||
.proxy
|
.proxy
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
if proxy.eq_ignore_ascii_case("none") {
|
if proxy.eq_ignore_ascii_case("none") {
|
||||||
builder = builder.no_proxy();
|
builder = builder.no_proxy();
|
||||||
} else {
|
} else {
|
||||||
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?);
|
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let client = builder.build().map_err(|error| error.to_string())?;
|
||||||
|
let request = client
|
||||||
|
.get(current.clone())
|
||||||
|
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||||
|
.header(reqwest::header::ACCEPT_ENCODING, "identity");
|
||||||
|
let include_credentials = credentials_allowed
|
||||||
|
&& can_forward_payload_credentials(&original, ¤t);
|
||||||
|
let response = apply_payload_headers(request, payload, include_credentials)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
|
if response.status().is_redirection() {
|
||||||
|
if redirect_count == 5 {
|
||||||
|
return Err("range probe redirect limit exceeded".to_string());
|
||||||
|
}
|
||||||
|
let location = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LOCATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.ok_or_else(|| "range probe redirect has no valid Location".to_string())?;
|
||||||
|
let next = current
|
||||||
|
.join(location)
|
||||||
|
.map_err(|error| format!("invalid range probe redirect: {error}"))?;
|
||||||
|
if !matches!(next.scheme(), "http" | "https") {
|
||||||
|
return Err("range probe redirect uses an unsupported scheme".to_string());
|
||||||
|
}
|
||||||
|
credentials_allowed = credentials_allowed
|
||||||
|
&& can_forward_payload_credentials(&original, &next);
|
||||||
|
current = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_range = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_RANGE)
|
||||||
|
.and_then(|value| value.to_str().ok());
|
||||||
|
return Ok(classify_bounded_range_response(
|
||||||
|
response.status(),
|
||||||
|
content_range,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = builder.build().map_err(|error| error.to_string())?;
|
Err("range probe redirect loop exhausted".to_string())
|
||||||
let request = client
|
}
|
||||||
.get(uri)
|
|
||||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
|
||||||
.header(reqwest::header::ACCEPT_ENCODING, "identity");
|
|
||||||
let request = apply_payload_headers(request, payload);
|
|
||||||
let response = request.send().await.map_err(|error| error.to_string())?;
|
|
||||||
let content_range = response
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::CONTENT_RANGE)
|
|
||||||
.and_then(|value| value.to_str().ok());
|
|
||||||
|
|
||||||
Ok(classify_bounded_range_response(
|
fn can_forward_payload_credentials(
|
||||||
response.status(),
|
original: &reqwest::Url,
|
||||||
content_range,
|
current: &reqwest::Url,
|
||||||
))
|
) -> bool {
|
||||||
|
original.host() == current.host()
|
||||||
|
&& (original.port_or_known_default() == current.port_or_known_default()
|
||||||
|
|| (original.scheme() == "http"
|
||||||
|
&& current.scheme() == "https"
|
||||||
|
&& original.port_or_known_default() == Some(80)
|
||||||
|
&& current.port_or_known_default() == Some(443)))
|
||||||
|
&& (original.scheme() == current.scheme()
|
||||||
|
|| (original.scheme() == "http" && current.scheme() == "https"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_payload_headers(
|
fn apply_payload_headers(
|
||||||
mut request: reqwest::RequestBuilder,
|
mut request: reqwest::RequestBuilder,
|
||||||
payload: &SpawnPayload,
|
payload: &SpawnPayload,
|
||||||
|
include_credentials: bool,
|
||||||
) -> reqwest::RequestBuilder {
|
) -> reqwest::RequestBuilder {
|
||||||
if let Some(user_agent) = payload
|
if let Some(user_agent) = payload
|
||||||
.user_agent
|
.user_agent
|
||||||
@@ -5115,40 +5188,111 @@ fn apply_payload_headers(
|
|||||||
{
|
{
|
||||||
request = request.header(reqwest::header::USER_AGENT, user_agent);
|
request = request.header(reqwest::header::USER_AGENT, user_agent);
|
||||||
}
|
}
|
||||||
if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) {
|
if include_credentials {
|
||||||
request = request.header(reqwest::header::COOKIE, cookies);
|
if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) {
|
||||||
}
|
request = request.header(reqwest::header::COOKIE, cookies);
|
||||||
if let Some(headers) = payload.headers.as_deref() {
|
}
|
||||||
for line in headers
|
if let Some(headers) = payload.headers.as_deref() {
|
||||||
.lines()
|
for line in headers
|
||||||
.map(str::trim)
|
.lines()
|
||||||
.filter(|line| !line.is_empty())
|
.map(str::trim)
|
||||||
{
|
.filter(|line| !line.is_empty())
|
||||||
let Some((name, value)) = line.split_once(':') else {
|
{
|
||||||
continue;
|
let Some((name, value)) = line.split_once(':') else {
|
||||||
};
|
continue;
|
||||||
if name.trim().eq_ignore_ascii_case("range") {
|
};
|
||||||
continue;
|
if name.trim().eq_ignore_ascii_case("range") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(value) = reqwest::header::HeaderValue::from_str(value.trim()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
request = request.header(name, value);
|
||||||
}
|
}
|
||||||
let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else {
|
}
|
||||||
continue;
|
if let Some(username) = payload
|
||||||
};
|
.username
|
||||||
let Ok(value) = reqwest::header::HeaderValue::from_str(value.trim()) else {
|
.as_deref()
|
||||||
continue;
|
.filter(|value| !value.is_empty())
|
||||||
};
|
{
|
||||||
request = request.header(name, value);
|
request = request.basic_auth(username, payload.password.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(username) = payload
|
|
||||||
.username
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
request = request.basic_auth(username, payload.password.as_deref());
|
|
||||||
}
|
|
||||||
request
|
request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_protocol_auth_options(
|
||||||
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
payload: &SpawnPayload,
|
||||||
|
uris: &[String],
|
||||||
|
) {
|
||||||
|
let has_http = uris.iter().any(|uri| is_http_uri(uri));
|
||||||
|
let has_ftp = uris.iter().any(|uri| {
|
||||||
|
url::Url::parse(uri)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|parsed| matches!(parsed.scheme(), "ftp" | "sftp"))
|
||||||
|
});
|
||||||
|
let has_sftp = uris.iter().any(|uri| {
|
||||||
|
url::Url::parse(uri)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|parsed| parsed.scheme() == "sftp")
|
||||||
|
});
|
||||||
|
if has_http {
|
||||||
|
if let Some(user) = &payload.username {
|
||||||
|
options.insert("http-user".to_string(), serde_json::json!(user));
|
||||||
|
}
|
||||||
|
if let Some(pass) = &payload.password {
|
||||||
|
options.insert("http-passwd".to_string(), serde_json::json!(pass));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_ftp {
|
||||||
|
if let Some(user) = &payload.username {
|
||||||
|
options.insert("ftp-user".to_string(), serde_json::json!(user));
|
||||||
|
}
|
||||||
|
if let Some(pass) = &payload.password {
|
||||||
|
options.insert("ftp-passwd".to_string(), serde_json::json!(pass));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_sftp {
|
||||||
|
if let Some(fingerprint) = &payload.sftp_host_key_md {
|
||||||
|
options.insert("ssh-host-key-md".to_string(), serde_json::json!(fingerprint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_checksum_options(
|
||||||
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
checksum: Option<&str>,
|
||||||
|
) {
|
||||||
|
if let Some(chk) = checksum {
|
||||||
|
let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') {
|
||||||
|
format!("{}={}", algo.to_ascii_lowercase(), digest)
|
||||||
|
} else {
|
||||||
|
chk.to_string()
|
||||||
|
};
|
||||||
|
options.insert("checksum".to_string(), serde_json::json!(formatted_chk));
|
||||||
|
options.insert("check-integrity".to_string(), serde_json::json!("true"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_aria2_transfer_network_policy(uris: &[String]) -> Result<(), String> {
|
||||||
|
for uri in uris {
|
||||||
|
let parsed = reqwest::Url::parse(uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https" | "ftp" | "sftp") {
|
||||||
|
return Err("Unsupported URL scheme".to_string());
|
||||||
|
}
|
||||||
|
// This is deliberately repeated immediately before addUri/addTorrent
|
||||||
|
// so admission-time DNS is not the only policy check. Aria2 still
|
||||||
|
// resolves independently later; Firelink therefore does not claim
|
||||||
|
// that this is an IP pinning boundary.
|
||||||
|
crate::resolve_and_validate_url_host(&parsed).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn classify_bounded_range_response(
|
fn classify_bounded_range_response(
|
||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
content_range: Option<&str>,
|
content_range: Option<&str>,
|
||||||
@@ -6234,8 +6378,16 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
if !payload.is_torrent {
|
if !payload.is_torrent {
|
||||||
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
||||||
}
|
}
|
||||||
|
let transfer_uris = if payload.is_torrent {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
crate::collect_download_uris(&payload.url, payload.mirrors.as_deref())
|
||||||
|
};
|
||||||
|
if !payload.is_torrent {
|
||||||
|
validate_aria2_transfer_network_policy(&transfer_uris).await?;
|
||||||
|
}
|
||||||
if should_apply_aria2_connection_options(payload) {
|
if should_apply_aria2_connection_options(payload) {
|
||||||
let conn = effective_aria2_connections(id, payload).await;
|
let conn = effective_aria2_connections(id, payload).await?;
|
||||||
apply_aria2_connection_options(&mut options, conn);
|
apply_aria2_connection_options(&mut options, conn);
|
||||||
}
|
}
|
||||||
apply_aria2_follow_options(&mut options, payload);
|
apply_aria2_follow_options(&mut options, payload);
|
||||||
@@ -6255,19 +6407,9 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
{
|
{
|
||||||
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
|
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
|
||||||
}
|
}
|
||||||
if let Some(user) = &payload.username {
|
if !payload.is_torrent {
|
||||||
options.insert("http-user".to_string(), serde_json::json!(user));
|
apply_protocol_auth_options(&mut options, payload, &transfer_uris);
|
||||||
}
|
apply_checksum_options(&mut options, payload.checksum.as_deref());
|
||||||
if let Some(pass) = &payload.password {
|
|
||||||
options.insert("http-passwd".to_string(), serde_json::json!(pass));
|
|
||||||
}
|
|
||||||
if let Some(chk) = &payload.checksum {
|
|
||||||
let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') {
|
|
||||||
format!("{}={}", algo.to_ascii_lowercase(), digest)
|
|
||||||
} else {
|
|
||||||
chk.clone()
|
|
||||||
};
|
|
||||||
options.insert("checksum".to_string(), serde_json::json!(formatted_chk));
|
|
||||||
}
|
}
|
||||||
if let Some(ua) = &payload.user_agent {
|
if let Some(ua) = &payload.user_agent {
|
||||||
options.insert("user-agent".to_string(), serde_json::json!(ua));
|
options.insert("user-agent".to_string(), serde_json::json!(ua));
|
||||||
@@ -6301,7 +6443,11 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
let bytes = tokio::fs::read(&path)
|
let bytes = tokio::fs::read(&path)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
|
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
|
||||||
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
let (sanitized_bytes, embedded_web_seeds) =
|
||||||
|
crate::torrent::sanitize_torrent_bytes_for_aria2(&bytes)?;
|
||||||
|
let embedded_web_seeds =
|
||||||
|
crate::filter_torrent_web_seed_destinations(&embedded_web_seeds).await?;
|
||||||
|
let metadata = crate::torrent::parse_torrent_bytes(&sanitized_bytes)?;
|
||||||
options.insert(
|
options.insert(
|
||||||
"index-out".to_string(),
|
"index-out".to_string(),
|
||||||
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)),
|
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)),
|
||||||
@@ -6316,19 +6462,19 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
serde_json::json!(indices.iter().map(u32::to_string).collect::<Vec<_>>().join(",")),
|
serde_json::json!(indices.iter().map(u32::to_string).collect::<Vec<_>>().join(",")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(sanitized_bytes);
|
||||||
let uris = if payload.torrent_web_seeds.is_some() {
|
let mut uris = embedded_web_seeds;
|
||||||
// Typed per-file web seeds are attached after the GID is
|
if payload.torrent_web_seeds.is_none() {
|
||||||
// known through changeUri. addTorrent accepts only one
|
uris.extend(
|
||||||
// unscoped URI list, which cannot represent fileIndex.
|
payload
|
||||||
Vec::new()
|
.mirrors
|
||||||
} else {
|
.as_deref()
|
||||||
payload
|
.map(|mirrors| crate::collect_download_uris("", Some(mirrors)))
|
||||||
.mirrors
|
.unwrap_or_default(),
|
||||||
.as_deref()
|
);
|
||||||
.map(|mirrors| crate::collect_download_uris("", Some(mirrors)))
|
uris.sort();
|
||||||
.unwrap_or_default()
|
uris.dedup();
|
||||||
};
|
}
|
||||||
("aria2.addTorrent", serde_json::json!([encoded, uris, options]))
|
("aria2.addTorrent", serde_json::json!([encoded, uris, options]))
|
||||||
} else {
|
} else {
|
||||||
let parsed = url::Url::parse(&payload.url)
|
let parsed = url::Url::parse(&payload.url)
|
||||||
@@ -6343,7 +6489,8 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
if selected.is_some() {
|
if selected.is_some() {
|
||||||
return Err("magnet file selection requires resolved torrent metadata".to_string());
|
return Err("magnet file selection requires resolved torrent metadata".to_string());
|
||||||
}
|
}
|
||||||
("aria2.addUri", serde_json::json!([[payload.url.clone()], options]))
|
let magnet = crate::torrent::sanitize_magnet_uri_for_aria2(&payload.url)?;
|
||||||
|
("aria2.addUri", serde_json::json!([[magnet], options]))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
||||||
@@ -6785,6 +6932,9 @@ pub struct EnqueueItem {
|
|||||||
pub speed_limit: Option<String>,
|
pub speed_limit: Option<String>,
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub sftp_host_key_md: Option<String>,
|
||||||
pub headers: Option<String>,
|
pub headers: Option<String>,
|
||||||
pub checksum: Option<String>,
|
pub checksum: Option<String>,
|
||||||
pub cookies: Option<String>,
|
pub cookies: Option<String>,
|
||||||
@@ -6898,6 +7048,7 @@ impl EnqueueItem {
|
|||||||
speed_limit: self.speed_limit,
|
speed_limit: self.speed_limit,
|
||||||
username: self.username,
|
username: self.username,
|
||||||
password: self.password,
|
password: self.password,
|
||||||
|
sftp_host_key_md: self.sftp_host_key_md,
|
||||||
headers: self.headers,
|
headers: self.headers,
|
||||||
checksum: self.checksum,
|
checksum: self.checksum,
|
||||||
cookies: self.cookies,
|
cookies: self.cookies,
|
||||||
@@ -8118,6 +8269,118 @@ mod tests {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_protocol_options_use_the_protocol_specific_aria2_keys() {
|
||||||
|
let payload = SpawnPayload {
|
||||||
|
username: Some("alice".to_string()),
|
||||||
|
password: Some("secret".to_string()),
|
||||||
|
sftp_host_key_md: Some(
|
||||||
|
"sha-1=0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||||
|
),
|
||||||
|
..SpawnPayload::default()
|
||||||
|
};
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
apply_protocol_auth_options(
|
||||||
|
&mut options,
|
||||||
|
&payload,
|
||||||
|
&["ftp://example.test/file.bin".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice")));
|
||||||
|
assert_eq!(options.get("ftp-passwd"), Some(&serde_json::json!("secret")));
|
||||||
|
assert!(!options.contains_key("http-user"));
|
||||||
|
assert!(!options.contains_key("ssh-host-key-md"));
|
||||||
|
|
||||||
|
options.clear();
|
||||||
|
apply_protocol_auth_options(
|
||||||
|
&mut options,
|
||||||
|
&payload,
|
||||||
|
&["sftp://example.test/file.bin".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice")));
|
||||||
|
assert_eq!(
|
||||||
|
options.get("ssh-host-key-md"),
|
||||||
|
Some(&serde_json::json!("sha-1=0123456789abcdef0123456789abcdef01234567"))
|
||||||
|
);
|
||||||
|
assert!(!options.contains_key("http-user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_checksum_options_enable_aria2_integrity_checks() {
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
apply_checksum_options(&mut options, Some("SHA-256=ABCDEF"));
|
||||||
|
assert_eq!(options.get("checksum"), Some(&serde_json::json!("sha-256=ABCDEF")));
|
||||||
|
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
|
||||||
|
|
||||||
|
options.clear();
|
||||||
|
apply_checksum_options(&mut options, None);
|
||||||
|
assert!(options.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sftp_host_key_fingerprints_are_normalized_and_bounded() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_sftp_host_key_md(Some(
|
||||||
|
" SHA-1=0123456789ABCDEF0123456789ABCDEF01234567 ",
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("sha-1=0123456789abcdef0123456789abcdef01234567")
|
||||||
|
);
|
||||||
|
assert!(normalize_sftp_host_key_md(Some("sha-256=abcd")).is_err());
|
||||||
|
assert!(normalize_sftp_host_key_md(Some("md5=abcd")).is_err());
|
||||||
|
assert!(normalize_sftp_host_key_md(Some("sha-1=xyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzx")).is_err());
|
||||||
|
assert_eq!(normalize_sftp_host_key_md(Some(" ")).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn range_probe_does_not_forward_credentials_across_origins() {
|
||||||
|
crate::ensure_reqwest_crypto_provider();
|
||||||
|
let payload = SpawnPayload {
|
||||||
|
username: Some("alice".to_string()),
|
||||||
|
password: Some("secret".to_string()),
|
||||||
|
cookies: Some("session=secret".to_string()),
|
||||||
|
headers: Some("Authorization: Bearer secret\nX-Test: yes".to_string()),
|
||||||
|
..SpawnPayload::default()
|
||||||
|
};
|
||||||
|
let client = reqwest::Client::builder().build().unwrap();
|
||||||
|
let same_origin = apply_payload_headers(
|
||||||
|
client.get("https://example.test/file"),
|
||||||
|
&payload,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(same_origin.headers().contains_key(reqwest::header::COOKIE));
|
||||||
|
assert!(same_origin.headers().contains_key(reqwest::header::AUTHORIZATION));
|
||||||
|
assert_eq!(same_origin.headers().get("x-test").unwrap(), "yes");
|
||||||
|
|
||||||
|
let cross_origin = apply_payload_headers(
|
||||||
|
client.get("https://cdn.example.test/file"),
|
||||||
|
&payload,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!cross_origin.headers().contains_key(reqwest::header::COOKIE));
|
||||||
|
assert!(!cross_origin.headers().contains_key(reqwest::header::AUTHORIZATION));
|
||||||
|
assert!(!cross_origin.headers().contains_key("x-test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn range_probe_allows_http_to_https_upgrade_but_not_cross_origin_return() {
|
||||||
|
let original = reqwest::Url::parse("http://example.test/file").unwrap();
|
||||||
|
let upgrade = reqwest::Url::parse("https://example.test/file").unwrap();
|
||||||
|
let unrelated = reqwest::Url::parse("https://cdn.example.test/file").unwrap();
|
||||||
|
|
||||||
|
assert!(can_forward_payload_credentials(&original, &original));
|
||||||
|
assert!(can_forward_payload_credentials(&original, &upgrade));
|
||||||
|
assert!(!can_forward_payload_credentials(&original, &unrelated));
|
||||||
|
assert!(!can_forward_payload_credentials(
|
||||||
|
&reqwest::Url::parse("https://example.test/file").unwrap(),
|
||||||
|
&reqwest::Url::parse("http://example.test/file").unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() {
|
fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() {
|
||||||
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
|
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
|
||||||
|
|||||||
+144
-4
@@ -15,6 +15,9 @@ pub struct ParsedTorrent {
|
|||||||
pub total_bytes: u64,
|
pub total_bytes: u64,
|
||||||
pub files: Vec<TorrentFile>,
|
pub files: Vec<TorrentFile>,
|
||||||
pub info_hash: String,
|
pub info_hash: String,
|
||||||
|
/// URL-list entries from the torrent metainfo. These are validated again
|
||||||
|
/// at enqueue time because Aria2 consumes the original torrent bytes.
|
||||||
|
pub web_seeds: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -290,7 +293,7 @@ fn parse_info(info: &BencodeValue) -> Result<ParsedTorrent, String> {
|
|||||||
let digest = Sha1::digest(encoded);
|
let digest = Sha1::digest(encoded);
|
||||||
let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect();
|
let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
|
||||||
Ok(ParsedTorrent { name, total_bytes, files, info_hash })
|
Ok(ParsedTorrent { name, total_bytes, files, info_hash, web_seeds: Vec::new() })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_btih(value: &str) -> Option<String> {
|
fn canonical_btih(value: &str) -> Option<String> {
|
||||||
@@ -358,7 +361,32 @@ pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, String> {
|
|||||||
let info = root
|
let info = root
|
||||||
.get(b"info".as_slice())
|
.get(b"info".as_slice())
|
||||||
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
|
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
|
||||||
parse_info(info)
|
let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?;
|
||||||
|
let mut parsed = parse_info(info)?;
|
||||||
|
parsed.web_seeds = web_seeds;
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the untrusted metainfo URL-list before handing a torrent to Aria2.
|
||||||
|
/// The info dictionary, and therefore the info hash, remains unchanged. The
|
||||||
|
/// caller can pass the parsed seeds back through the explicit web-seed policy
|
||||||
|
/// and add only the destinations that were accepted there.
|
||||||
|
pub fn sanitize_torrent_bytes_for_aria2(bytes: &[u8]) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||||
|
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let root = match Parser::new(bytes).parse()? {
|
||||||
|
BencodeValue::Dict(value) => value,
|
||||||
|
_ => return Err("torrent root is not a dictionary".to_string()),
|
||||||
|
};
|
||||||
|
let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?;
|
||||||
|
let mut sanitized = root;
|
||||||
|
sanitized.remove(b"url-list".as_slice());
|
||||||
|
let mut encoded = Vec::with_capacity(bytes.len());
|
||||||
|
encode(&BencodeValue::Dict(sanitized), &mut encoded);
|
||||||
|
Ok((encoded, web_seeds))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bounded_optional_text(value: Option<&BencodeValue>, limit: usize) -> Option<String> {
|
fn bounded_optional_text(value: Option<&BencodeValue>, limit: usize) -> Option<String> {
|
||||||
@@ -415,6 +443,35 @@ fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec<S
|
|||||||
values
|
values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>, String> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let entries = match value {
|
||||||
|
BencodeValue::Bytes(_) => std::slice::from_ref(value),
|
||||||
|
BencodeValue::List(entries) => entries.as_slice(),
|
||||||
|
_ => return Err("torrent url-list field is invalid".to_string()),
|
||||||
|
};
|
||||||
|
if entries.len() > 256 {
|
||||||
|
return Err("torrent url-list contains too many web seeds".to_string());
|
||||||
|
}
|
||||||
|
let mut normalized = Vec::with_capacity(entries.len());
|
||||||
|
for entry in entries {
|
||||||
|
let BencodeValue::Bytes(bytes) = entry else {
|
||||||
|
return Err("torrent url-list contains a non-text web seed".to_string());
|
||||||
|
};
|
||||||
|
let value = String::from_utf8(bytes.clone())
|
||||||
|
.map_err(|_| "torrent url-list contains invalid UTF-8".to_string())?;
|
||||||
|
let value = value.trim();
|
||||||
|
let uri = bounded_uri(value, &["http", "https"])
|
||||||
|
.ok_or_else(|| "torrent url-list contains an invalid HTTP(S) web seed".to_string())?;
|
||||||
|
if !normalized.contains(&uri) {
|
||||||
|
normalized.push(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDetails, String> {
|
pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDetails, String> {
|
||||||
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -429,6 +486,7 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDet
|
|||||||
.get(b"info".as_slice())
|
.get(b"info".as_slice())
|
||||||
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
|
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
|
||||||
let parsed = parse_info(info)?;
|
let parsed = parse_info(info)?;
|
||||||
|
let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?;
|
||||||
let info_dict = match info {
|
let info_dict = match info {
|
||||||
BencodeValue::Dict(value) => value,
|
BencodeValue::Dict(value) => value,
|
||||||
_ => return Err("torrent info dictionary is invalid".to_string()),
|
_ => return Err("torrent info dictionary is invalid".to_string()),
|
||||||
@@ -484,7 +542,7 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDet
|
|||||||
4_096,
|
4_096,
|
||||||
),
|
),
|
||||||
trackers,
|
trackers,
|
||||||
web_seeds: collect_torrent_uris(root.get(b"url-list".as_slice()), &["http", "https"]),
|
web_seeds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,7 +600,49 @@ fn magnet_metadata(source: &str) -> Result<ParsedTorrent, String> {
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.map(|value| crate::download_ownership::canonical_download_filename(&value))
|
.map(|value| crate::download_ownership::canonical_download_filename(&value))
|
||||||
.unwrap_or_else(|| format!("torrent-{info_hash}"));
|
.unwrap_or_else(|| format!("torrent-{info_hash}"));
|
||||||
Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash })
|
Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash, web_seeds: Vec::new() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the Magnet URI form that Firelink may hand to Aria2. Direct source
|
||||||
|
/// parameters can make Aria2 fetch arbitrary HTTP/FTP/SFTP resources during
|
||||||
|
/// metadata resolution, so keep the peer/tracker identity parameters but
|
||||||
|
/// remove `ws`, `as`, and `xs` sources. Users can add validated web seeds after
|
||||||
|
/// metadata is available through the transactional per-file path.
|
||||||
|
pub fn sanitize_magnet_uri_for_aria2(source: &str) -> Result<String, String> {
|
||||||
|
let mut parsed = url::Url::parse(source.trim()).map_err(|_| "invalid magnet URI".to_string())?;
|
||||||
|
if parsed.scheme() != "magnet"
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
|| parsed.host_str().is_some()
|
||||||
|
|| parsed.port().is_some()
|
||||||
|
{
|
||||||
|
return Err("magnet URI contains an invalid authority or fragment".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut has_info_hash = false;
|
||||||
|
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||||
|
for (key, value) in parsed.query_pairs() {
|
||||||
|
if matches!(key.as_ref(), "ws" | "as" | "xs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if key == "xt" {
|
||||||
|
let hash = value
|
||||||
|
.strip_prefix("urn:btih:")
|
||||||
|
.and_then(canonical_btih)
|
||||||
|
.ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?;
|
||||||
|
query.append_pair("xt", &format!("urn:btih:{hash}"));
|
||||||
|
has_info_hash = true;
|
||||||
|
} else {
|
||||||
|
query.append_pair(&key, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_info_hash {
|
||||||
|
return Err("magnet URI has no valid BitTorrent info hash".to_string());
|
||||||
|
}
|
||||||
|
let query = query.finish();
|
||||||
|
parsed.set_query(Some(&query));
|
||||||
|
Ok(parsed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn magnet_allows_cached_metadata(source: &str) -> bool {
|
pub fn magnet_allows_cached_metadata(source: &str) -> bool {
|
||||||
@@ -1107,6 +1207,46 @@ mod tests {
|
|||||||
assert_eq!(parsed.info_hash.len(), 40);
|
assert_eq!(parsed.info_hash.len(), 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retains_only_strict_http_web_seeds_for_enqueue_policy_validation() {
|
||||||
|
let parsed = parse_torrent_bytes(
|
||||||
|
b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae",
|
||||||
|
)
|
||||||
|
.expect("torrent with an HTTP web seed should parse");
|
||||||
|
assert_eq!(parsed.web_seeds, vec!["https://example.test/a"]);
|
||||||
|
|
||||||
|
let credentials = parse_torrent_bytes(
|
||||||
|
b"d4:infod6:lengthi5e4:name4:teste8:url-list32:https://user:pass@example.test/aee",
|
||||||
|
);
|
||||||
|
assert!(credentials.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitizing_torrent_web_seeds_preserves_the_info_hash() {
|
||||||
|
let bytes = b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae";
|
||||||
|
let original = parse_torrent_bytes(bytes).expect("torrent should parse");
|
||||||
|
let (sanitized, web_seeds) = sanitize_torrent_bytes_for_aria2(bytes)
|
||||||
|
.expect("torrent should be sanitized");
|
||||||
|
let parsed = parse_torrent_bytes(&sanitized).expect("sanitized torrent should parse");
|
||||||
|
|
||||||
|
assert_eq!(web_seeds, vec!["https://example.test/a"]);
|
||||||
|
assert!(parsed.web_seeds.is_empty());
|
||||||
|
assert_eq!(parsed.info_hash, original.info_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magnet_sanitization_removes_direct_sources_but_preserves_identity() {
|
||||||
|
let sanitized = sanitize_magnet_uri_for_aria2(
|
||||||
|
"magnet:?xt=urn:btih:0123456789012345678901234567890123456789&dn=demo&tr=udp%3A%2F%2Ftracker.example%3A80&ws=https%3A%2F%2Flocal.example%2Ffile&as=https%3A%2F%2Fother.example%2Ffile",
|
||||||
|
)
|
||||||
|
.expect("magnet should sanitize");
|
||||||
|
assert!(sanitized.contains("xt=urn%3Abtih%3A0123456789012345678901234567890123456789"));
|
||||||
|
assert!(sanitized.contains("dn=demo"));
|
||||||
|
assert!(sanitized.contains("tr=udp%3A%2F%2Ftracker.example%3A80"));
|
||||||
|
assert!(!sanitized.contains("ws="));
|
||||||
|
assert!(!sanitized.contains("as="));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_torrent_output_names_as_single_safe_components() {
|
fn validates_torrent_output_names_as_single_safe_components() {
|
||||||
for name in ["test", "My Torrent (1)", "archive.tar"] {
|
for name in ["test", "My Torrent (1)", "archive.tar"] {
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ import type { DownloadErrorKind } from "./DownloadErrorKind";
|
|||||||
import type { DownloadStatus } from "./DownloadStatus";
|
import type { DownloadStatus } from "./DownloadStatus";
|
||||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||||
|
|
||||||
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, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, };
|
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, sftpHostKeyMd?: 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, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, };
|
||||||
|
|||||||
@@ -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 { TorrentWebSeed } from "./TorrentWebSeed";
|
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||||
|
|
||||||
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_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, 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, sftp_host_key_md?: string, 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_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, };
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const [useAuth, setUseAuth] = useState(false);
|
const [useAuth, setUseAuth] = useState(false);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [sftpHostKeyMd, setSftpHostKeyMd] = useState('');
|
||||||
|
|
||||||
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
||||||
const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true);
|
const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true);
|
||||||
@@ -411,6 +412,7 @@ export const AddDownloadsModal = () => {
|
|||||||
setUseAuth(false);
|
setUseAuth(false);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
|
setSftpHostKeyMd('');
|
||||||
setAdvancedExpanded(false);
|
setAdvancedExpanded(false);
|
||||||
setChecksumEnabled(false);
|
setChecksumEnabled(false);
|
||||||
setChecksumAlgo('SHA-256');
|
setChecksumAlgo('SHA-256');
|
||||||
@@ -1515,6 +1517,9 @@ export const AddDownloadsModal = () => {
|
|||||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||||
username: useAuth ? username.trim() : undefined,
|
username: useAuth ? username.trim() : undefined,
|
||||||
password: useAuth ? password.trim() : undefined,
|
password: useAuth ? password.trim() : undefined,
|
||||||
|
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
|
||||||
|
? sftpHostKeyMd.trim() || undefined
|
||||||
|
: undefined,
|
||||||
headers: headersForRow(contextUrl) || undefined,
|
headers: headersForRow(contextUrl) || undefined,
|
||||||
checksum: checksumEnabled && checksumValue.trim()
|
checksum: checksumEnabled && checksumValue.trim()
|
||||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||||
@@ -1706,6 +1711,9 @@ export const AddDownloadsModal = () => {
|
|||||||
return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length);
|
return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length);
|
||||||
};
|
};
|
||||||
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||||
|
const hasSftpRows = parsedItems.some(item => item.selected !== false
|
||||||
|
&& !item.isTorrent
|
||||||
|
&& item.sourceUrl.trim().toLowerCase().startsWith('sftp:'));
|
||||||
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
||||||
const selectedPlaylistRows = selectedPlaylistSourceUrl
|
const selectedPlaylistRows = selectedPlaylistSourceUrl
|
||||||
? parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl && item.selected !== false)
|
? parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl && item.selected !== false)
|
||||||
@@ -2854,6 +2862,23 @@ export const AddDownloadsModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasSftpRows && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">
|
||||||
|
{t($ => $.addDownloads.sftpHostKeyMd)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sftpHostKeyMd}
|
||||||
|
onChange={event => setSftpHostKeyMd(event.target.value)}
|
||||||
|
placeholder={t($ => $.addDownloads.sftpHostKeyMdHint)}
|
||||||
|
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-text-muted">{t($ => $.addDownloads.sftpHostKeyMdDescription)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.headers)}</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.headers)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [connections, setConnections] = useState('');
|
const [connections, setConnections] = useState('');
|
||||||
|
const [sftpHostKeyMd, setSftpHostKeyMd] = useState('');
|
||||||
const [trackers, setTrackers] = useState('');
|
const [trackers, setTrackers] = useState('');
|
||||||
const [excludedTrackers, setExcludedTrackers] = useState('');
|
const [excludedTrackers, setExcludedTrackers] = useState('');
|
||||||
const [downloadLimit, setDownloadLimit] = useState('');
|
const [downloadLimit, setDownloadLimit] = useState('');
|
||||||
@@ -278,6 +279,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
detailsRef.current = details;
|
detailsRef.current = details;
|
||||||
|
|
||||||
const isTorrent = snapshot?.isTorrent === true;
|
const isTorrent = snapshot?.isTorrent === true;
|
||||||
|
const isSftp = Boolean(snapshot?.url.trim().toLowerCase().startsWith('sftp:'));
|
||||||
const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]);
|
const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]);
|
||||||
const peerDiagnosticState = getPropertiesPeerDiagnosticState(peers, diagnosticsLoading, peerDiagnosticPhase);
|
const peerDiagnosticState = getPropertiesPeerDiagnosticState(peers, diagnosticsLoading, peerDiagnosticPhase);
|
||||||
const availabilityDiagnosticState = getPropertiesAvailabilityDiagnosticState(availability, diagnosticsLoading, availabilityDiagnosticPhase);
|
const availabilityDiagnosticState = getPropertiesAvailabilityDiagnosticState(availability, diagnosticsLoading, availabilityDiagnosticPhase);
|
||||||
@@ -394,6 +396,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
setFileName(next.fileName);
|
setFileName(next.fileName);
|
||||||
setDestination(next.destination ?? '');
|
setDestination(next.destination ?? '');
|
||||||
setConnections(next.connections === undefined ? '' : String(next.connections));
|
setConnections(next.connections === undefined ? '' : String(next.connections));
|
||||||
|
setSftpHostKeyMd(next.sftpHostKeyMd ?? '');
|
||||||
setTrackers(next.torrentTrackers ?? '');
|
setTrackers(next.torrentTrackers ?? '');
|
||||||
setExcludedTrackers(next.torrentExcludeTrackers ?? '');
|
setExcludedTrackers(next.torrentExcludeTrackers ?? '');
|
||||||
setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null);
|
setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null);
|
||||||
@@ -973,6 +976,9 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation);
|
if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation);
|
||||||
}
|
}
|
||||||
} else if (activeTab === 'advanced') {
|
} else if (activeTab === 'advanced') {
|
||||||
|
if (isSftp && sftpHostKeyMd !== (snapshot.sftpHostKeyMd ?? '')) {
|
||||||
|
patch.sftpHostKeyMd = encodePropertiesPatchValue(sftpHostKeyMd.trim() || undefined);
|
||||||
|
}
|
||||||
for (const name of SECRET_NAMES) {
|
for (const name of SECRET_NAMES) {
|
||||||
const draft = secretDrafts[name];
|
const draft = secretDrafts[name];
|
||||||
if (!draft.touched) continue;
|
if (!draft.touched) continue;
|
||||||
@@ -980,7 +986,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await requestAction('apply-properties', patch);
|
await requestAction('apply-properties', patch);
|
||||||
}, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]);
|
}, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isSftp, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, sftpHostKeyMd, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]);
|
||||||
|
|
||||||
const chooseTab = (tab: PropertiesTab) => {
|
const chooseTab = (tab: PropertiesTab) => {
|
||||||
if (tab === activeTab) {
|
if (tab === activeTab) {
|
||||||
@@ -1538,6 +1544,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
|
|
||||||
{activeTab === 'advanced' && <div className="space-y-4">
|
{activeTab === 'advanced' && <div className="space-y-4">
|
||||||
<p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p>
|
<p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p>
|
||||||
|
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||||
|
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||||
<div><span className="text-text-muted">{t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}</p></div>
|
<div><span className="text-text-muted">{t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}</p></div>
|
||||||
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
|
|||||||
if (safePatch.destination !== undefined && typeof safePatch.destination !== 'string') {
|
if (safePatch.destination !== undefined && typeof safePatch.destination !== 'string') {
|
||||||
throw new Error('Invalid destination');
|
throw new Error('Invalid destination');
|
||||||
}
|
}
|
||||||
|
if (safePatch.sftpHostKeyMd !== undefined) {
|
||||||
|
if (typeof safePatch.sftpHostKeyMd !== 'string') throw new Error('Invalid SFTP host-key fingerprint');
|
||||||
|
const fingerprint = safePatch.sftpHostKeyMd.trim().toLowerCase();
|
||||||
|
const valid = /^(md5|sha-1)=[0-9a-f]+$/.test(fingerprint)
|
||||||
|
&& ((fingerprint.startsWith('md5=') && fingerprint.length === 36)
|
||||||
|
|| (fingerprint.startsWith('sha-1=') && fingerprint.length === 45));
|
||||||
|
if (!valid) throw new Error('Invalid SFTP host-key fingerprint');
|
||||||
|
safePatch.sftpHostKeyMd = fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
if (safePatch.connections !== undefined
|
if (safePatch.connections !== undefined
|
||||||
&& (!Number.isInteger(safePatch.connections) || safePatch.connections < 1 || safePatch.connections > 16)) {
|
&& (!Number.isInteger(safePatch.connections) || safePatch.connections < 1 || safePatch.connections > 16)) {
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ const common = {
|
|||||||
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||||
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
||||||
|
credentialsRequired: 'Credentials are required again after restart. Add them in Advanced and resume this download.',
|
||||||
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
||||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||||
@@ -459,6 +460,9 @@ const common = {
|
|||||||
algorithm: 'Algorithm',
|
algorithm: 'Algorithm',
|
||||||
digest: 'Digest',
|
digest: 'Digest',
|
||||||
expectedDigest: 'Expected digest',
|
expectedDigest: 'Expected digest',
|
||||||
|
sftpHostKeyMd: 'SFTP host-key fingerprint',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 hex characters or md5=32 hex characters',
|
||||||
|
sftpHostKeyMdDescription: 'Optional Aria2 host-key verification. Leave blank only if you accept Aria2’s unverified SFTP host key.',
|
||||||
cookies: 'Cookies',
|
cookies: 'Cookies',
|
||||||
headers: 'Headers',
|
headers: 'Headers',
|
||||||
mirrors: 'Mirrors',
|
mirrors: 'Mirrors',
|
||||||
@@ -763,6 +767,9 @@ const common = {
|
|||||||
verifyChecksum: 'Verify Checksum',
|
verifyChecksum: 'Verify Checksum',
|
||||||
checksumAlgorithm: 'Checksum algorithm',
|
checksumAlgorithm: 'Checksum algorithm',
|
||||||
expectedDigest: 'Expected digest',
|
expectedDigest: 'Expected digest',
|
||||||
|
sftpHostKeyMd: 'SFTP host-key fingerprint',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 hex characters or md5=32 hex characters',
|
||||||
|
sftpHostKeyMdDescription: 'Optional Aria2 host-key verification. Leave blank only if you accept Aria2’s unverified SFTP host key.',
|
||||||
headers: 'Headers',
|
headers: 'Headers',
|
||||||
requestHeaders: 'Request headers',
|
requestHeaders: 'Request headers',
|
||||||
cookies: 'Cookies',
|
cookies: 'Cookies',
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ const fa = {
|
|||||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||||
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
||||||
|
credentialsRequired: 'پس از راهاندازی مجدد، دوباره اطلاعات ورود لازم است. آنها را در بخش پیشرفته وارد و دانلود را ادامه دهید.',
|
||||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||||
@@ -459,6 +460,9 @@ const fa = {
|
|||||||
algorithm: 'الگوریتم',
|
algorithm: 'الگوریتم',
|
||||||
digest: 'هش',
|
digest: 'هش',
|
||||||
expectedDigest: 'هش مورد انتظار',
|
expectedDigest: 'هش مورد انتظار',
|
||||||
|
sftpHostKeyMd: 'اثر انگشت کلید میزبان SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=۴۰ نویسهٔ هگز یا md5=۳۲ نویسهٔ هگز',
|
||||||
|
sftpHostKeyMdDescription: 'اعتبارسنجی اختیاری کلید میزبان در Aria2. اگر خالی بگذارید، کلید SFTP بدون اعتبارسنجی پذیرفته میشود.',
|
||||||
cookies: 'کوکیها',
|
cookies: 'کوکیها',
|
||||||
headers: 'هدرها',
|
headers: 'هدرها',
|
||||||
mirrors: 'آینهها',
|
mirrors: 'آینهها',
|
||||||
@@ -763,6 +767,9 @@ const fa = {
|
|||||||
verifyChecksum: 'تأیید چکسام',
|
verifyChecksum: 'تأیید چکسام',
|
||||||
checksumAlgorithm: 'الگوریتم چکسام',
|
checksumAlgorithm: 'الگوریتم چکسام',
|
||||||
expectedDigest: 'هش مورد انتظار',
|
expectedDigest: 'هش مورد انتظار',
|
||||||
|
sftpHostKeyMd: 'اثر انگشت کلید میزبان SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=۴۰ نویسهٔ هگز یا md5=۳۲ نویسهٔ هگز',
|
||||||
|
sftpHostKeyMdDescription: 'اعتبارسنجی اختیاری کلید میزبان در Aria2. اگر خالی بگذارید، کلید SFTP بدون اعتبارسنجی پذیرفته میشود.',
|
||||||
headers: 'هدرها',
|
headers: 'هدرها',
|
||||||
requestHeaders: 'هدرهای درخواست',
|
requestHeaders: 'هدرهای درخواست',
|
||||||
cookies: 'کوکیها',
|
cookies: 'کوکیها',
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ const he = {
|
|||||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||||
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
||||||
|
credentialsRequired: 'לאחר הפעלה מחדש נדרשים שוב פרטי התחברות. הוסף אותם במתקדם והמשך את ההורדה.',
|
||||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||||
@@ -459,6 +460,9 @@ const he = {
|
|||||||
algorithm: 'אלגוריתם',
|
algorithm: 'אלגוריתם',
|
||||||
digest: 'ערך גיבוב',
|
digest: 'ערך גיבוב',
|
||||||
expectedDigest: 'ערך גיבוב צפוי',
|
expectedDigest: 'ערך גיבוב צפוי',
|
||||||
|
sftpHostKeyMd: 'טביעת אצבע של מפתח מארח SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 תווי hex או md5=32 תווי hex',
|
||||||
|
sftpHostKeyMdDescription: 'אימות אופציונלי של מפתח המארח ב-Aria2. השאר ריק רק אם מקובל עליך מפתח SFTP ללא אימות.',
|
||||||
cookies: 'עוגיות',
|
cookies: 'עוגיות',
|
||||||
headers: 'כותרות (Headers)',
|
headers: 'כותרות (Headers)',
|
||||||
mirrors: 'מראות',
|
mirrors: 'מראות',
|
||||||
@@ -763,6 +767,9 @@ const he = {
|
|||||||
verifyChecksum: 'אימות סכום ביקורת',
|
verifyChecksum: 'אימות סכום ביקורת',
|
||||||
checksumAlgorithm: 'אלגוריתם סכום ביקורת',
|
checksumAlgorithm: 'אלגוריתם סכום ביקורת',
|
||||||
expectedDigest: 'ערך גיבוב צפוי',
|
expectedDigest: 'ערך גיבוב צפוי',
|
||||||
|
sftpHostKeyMd: 'טביעת אצבע של מפתח מארח SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 תווי hex או md5=32 תווי hex',
|
||||||
|
sftpHostKeyMdDescription: 'אימות אופציונלי של מפתח המארח ב-Aria2. השאר ריק רק אם מקובל עליך מפתח SFTP ללא אימות.',
|
||||||
headers: 'כותרות (Headers)',
|
headers: 'כותרות (Headers)',
|
||||||
requestHeaders: 'כותרות בקשה',
|
requestHeaders: 'כותרות בקשה',
|
||||||
cookies: 'עוגיות',
|
cookies: 'עוגיות',
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ const ru = {
|
|||||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||||
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
||||||
|
credentialsRequired: 'После перезапуска снова нужны учётные данные. Добавьте их в разделе «Дополнительно» и возобновите загрузку.',
|
||||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||||
@@ -459,6 +460,9 @@ const ru = {
|
|||||||
algorithm: 'Алгоритм',
|
algorithm: 'Алгоритм',
|
||||||
digest: 'Хеш',
|
digest: 'Хеш',
|
||||||
expectedDigest: 'Ожидаемый хеш',
|
expectedDigest: 'Ожидаемый хеш',
|
||||||
|
sftpHostKeyMd: 'Отпечаток ключа хоста SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 шестнадцатеричных символов или md5=32',
|
||||||
|
sftpHostKeyMdDescription: 'Необязательная проверка ключа хоста Aria2. Оставляйте поле пустым только если принимаете непроверенный ключ SFTP.',
|
||||||
cookies: 'Файлы cookie',
|
cookies: 'Файлы cookie',
|
||||||
headers: 'Заголовки',
|
headers: 'Заголовки',
|
||||||
mirrors: 'Зеркала',
|
mirrors: 'Зеркала',
|
||||||
@@ -763,6 +767,9 @@ const ru = {
|
|||||||
verifyChecksum: 'Проверять контрольную сумму',
|
verifyChecksum: 'Проверять контрольную сумму',
|
||||||
checksumAlgorithm: 'Алгоритм контрольной суммы',
|
checksumAlgorithm: 'Алгоритм контрольной суммы',
|
||||||
expectedDigest: 'Ожидаемый хеш',
|
expectedDigest: 'Ожидаемый хеш',
|
||||||
|
sftpHostKeyMd: 'Отпечаток ключа хоста SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 шестнадцатеричных символов или md5=32',
|
||||||
|
sftpHostKeyMdDescription: 'Необязательная проверка ключа хоста Aria2. Оставляйте поле пустым только если принимаете непроверенный ключ SFTP.',
|
||||||
headers: 'Заголовки',
|
headers: 'Заголовки',
|
||||||
requestHeaders: 'Заголовки запроса',
|
requestHeaders: 'Заголовки запроса',
|
||||||
cookies: 'Файлы cookie',
|
cookies: 'Файлы cookie',
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ const uk = {
|
|||||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||||
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
||||||
|
credentialsRequired: 'Після перезапуску облікові дані потрібні знову. Додайте їх у розділі «Додатково» та відновіть завантаження.',
|
||||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||||
@@ -459,6 +460,9 @@ const uk = {
|
|||||||
algorithm: 'Алгоритм',
|
algorithm: 'Алгоритм',
|
||||||
digest: 'Хеш',
|
digest: 'Хеш',
|
||||||
expectedDigest: 'Очікуваний хеш',
|
expectedDigest: 'Очікуваний хеш',
|
||||||
|
sftpHostKeyMd: 'Відбиток ключа вузла SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 шістнадцяткових символів або md5=32',
|
||||||
|
sftpHostKeyMdDescription: 'Необов’язкова перевірка ключа вузла Aria2. Залишайте поле порожнім лише якщо приймаєте неперевірений ключ SFTP.',
|
||||||
cookies: 'Файли cookie',
|
cookies: 'Файли cookie',
|
||||||
headers: 'Заголовки',
|
headers: 'Заголовки',
|
||||||
mirrors: 'Дзеркала',
|
mirrors: 'Дзеркала',
|
||||||
@@ -763,6 +767,9 @@ const uk = {
|
|||||||
verifyChecksum: 'Перевірити контрольну суму',
|
verifyChecksum: 'Перевірити контрольну суму',
|
||||||
checksumAlgorithm: 'Алгоритм контрольної суми',
|
checksumAlgorithm: 'Алгоритм контрольної суми',
|
||||||
expectedDigest: 'Очікуваний хеш',
|
expectedDigest: 'Очікуваний хеш',
|
||||||
|
sftpHostKeyMd: 'Відбиток ключа вузла SFTP',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 шістнадцяткових символів або md5=32',
|
||||||
|
sftpHostKeyMdDescription: 'Необов’язкова перевірка ключа вузла Aria2. Залишайте поле порожнім лише якщо приймаєте неперевірений ключ SFTP.',
|
||||||
headers: 'Заголовки',
|
headers: 'Заголовки',
|
||||||
requestHeaders: 'Заголовки запиту',
|
requestHeaders: 'Заголовки запиту',
|
||||||
cookies: 'Файли cookie',
|
cookies: 'Файли cookie',
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ const zhCN = {
|
|||||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||||
editingUnavailable: '下载进行时无法编辑这些属性。',
|
editingUnavailable: '下载进行时无法编辑这些属性。',
|
||||||
|
credentialsRequired: '重启后需要再次提供凭据。请在“高级”中添加凭据,然后恢复下载。',
|
||||||
liveTorrentUploadLimit: '实时种子上传限速',
|
liveTorrentUploadLimit: '实时种子上传限速',
|
||||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||||
@@ -459,6 +460,9 @@ const zhCN = {
|
|||||||
algorithm: '算法',
|
algorithm: '算法',
|
||||||
digest: '哈希值',
|
digest: '哈希值',
|
||||||
expectedDigest: '预期哈希值',
|
expectedDigest: '预期哈希值',
|
||||||
|
sftpHostKeyMd: 'SFTP 主机密钥指纹',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 个十六进制字符或 md5=32 个',
|
||||||
|
sftpHostKeyMdDescription: '可选的 Aria2 主机密钥验证。仅在接受未验证的 SFTP 主机密钥时留空。',
|
||||||
cookies: 'Cookie',
|
cookies: 'Cookie',
|
||||||
headers: '请求头',
|
headers: '请求头',
|
||||||
mirrors: '镜像源',
|
mirrors: '镜像源',
|
||||||
@@ -763,6 +767,9 @@ const zhCN = {
|
|||||||
verifyChecksum: '验证校验和',
|
verifyChecksum: '验证校验和',
|
||||||
checksumAlgorithm: '校验和算法',
|
checksumAlgorithm: '校验和算法',
|
||||||
expectedDigest: '预期摘要',
|
expectedDigest: '预期摘要',
|
||||||
|
sftpHostKeyMd: 'SFTP 主机密钥指纹',
|
||||||
|
sftpHostKeyMdHint: 'sha-1=40 个十六进制字符或 md5=32 个',
|
||||||
|
sftpHostKeyMdDescription: '可选的 Aria2 主机密钥验证。仅在接受未验证的 SFTP 主机密钥时留空。',
|
||||||
headers: '请求头',
|
headers: '请求头',
|
||||||
requestHeaders: '请求头',
|
requestHeaders: '请求头',
|
||||||
cookies: 'Cookie',
|
cookies: 'Cookie',
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const PROPERTIES_SNAPSHOT_KEYS = [
|
|||||||
'resumable',
|
'resumable',
|
||||||
'connections',
|
'connections',
|
||||||
'speedLimit',
|
'speedLimit',
|
||||||
|
'sftpHostKeyMd',
|
||||||
'checksum',
|
'checksum',
|
||||||
'destination',
|
'destination',
|
||||||
'isMedia',
|
'isMedia',
|
||||||
@@ -73,6 +74,7 @@ const PROPERTIES_SNAPSHOT_KEYS = [
|
|||||||
'queuePosition',
|
'queuePosition',
|
||||||
'hasBeenDispatched',
|
'hasBeenDispatched',
|
||||||
'lastError',
|
'lastError',
|
||||||
|
'credentialsRequired',
|
||||||
'lastErrorKind',
|
'lastErrorKind',
|
||||||
'lastResolverFallback',
|
'lastResolverFallback',
|
||||||
'lastTry',
|
'lastTry',
|
||||||
@@ -195,6 +197,7 @@ export type SecretPatch =
|
|||||||
|
|
||||||
export const PROPERTIES_PATCH_CLEARABLE_KEYS = [
|
export const PROPERTIES_PATCH_CLEARABLE_KEYS = [
|
||||||
'destination',
|
'destination',
|
||||||
|
'sftpHostKeyMd',
|
||||||
'speedLimit',
|
'speedLimit',
|
||||||
'torrentTrackers',
|
'torrentTrackers',
|
||||||
'torrentExcludeTrackers',
|
'torrentExcludeTrackers',
|
||||||
|
|||||||
@@ -135,6 +135,30 @@ describe('useDownloadStore', () => {
|
|||||||
expect(fileName.endsWith('.mp4')).toBe(true);
|
expect(fileName.endsWith('.mp4')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the credential-required marker when the last secret is cleared', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'credential-marker',
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'failed',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
credentialsRequired: true
|
||||||
|
}] as any[]
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().applyProperties('credential-marker', {
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(true);
|
||||||
|
|
||||||
|
await useDownloadStore.getState().applyProperties('credential-marker', {
|
||||||
|
password: 'secret'
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears a persisted Torrent removal reservation when a paused item disables cleanup', async () => {
|
it('clears a persisted Torrent removal reservation when a paused item disables cleanup', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
@@ -1817,6 +1841,30 @@ describe('useDownloadStore', () => {
|
|||||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not resume a paused backend lifecycle without restored credentials', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'credential-resume-gated',
|
||||||
|
url: 'https://secure.example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'paused',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
credentialsRequired: true
|
||||||
|
}] as any[],
|
||||||
|
backendRegisteredIds: new Set(['credential-resume-gated'])
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().resumeDownload('credential-resume-gated'))
|
||||||
|
.resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||||
|
'resume_download',
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
if (cmd === 'db_get_all_queues') return [];
|
||||||
|
|||||||
@@ -114,6 +114,26 @@ const waitForPendingStartupResume = async (): Promise<void> => {
|
|||||||
if (pending) await pending.catch(() => undefined);
|
if (pending) await pending.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const credentialsRequiredMessage = (): string =>
|
||||||
|
i18n.t($ => $.properties.credentialsRequired);
|
||||||
|
|
||||||
|
const hasCredentialMaterial = (value: string | null | undefined): boolean =>
|
||||||
|
typeof value === 'string' && value.trim().length > 0;
|
||||||
|
|
||||||
|
const markCredentialsRequired = (id: string): void => {
|
||||||
|
useDownloadStore.getState().updateDownload(id, {
|
||||||
|
status: 'paused',
|
||||||
|
lastError: credentialsRequiredMessage(),
|
||||||
|
});
|
||||||
|
useDownloadStore.setState(state => ({
|
||||||
|
pendingOrder: state.pendingOrder.filter(value => value !== id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCredentialsRequired = (id: string): void => {
|
||||||
|
useDownloadStore.getState().updateDownload(id, { credentialsRequired: false });
|
||||||
|
};
|
||||||
|
|
||||||
const currentQueueControlGeneration = (queueId: string): number =>
|
const currentQueueControlGeneration = (queueId: string): number =>
|
||||||
queueControlGenerations.get(queueId) ?? 0;
|
queueControlGenerations.get(queueId) ?? 0;
|
||||||
|
|
||||||
@@ -316,6 +336,16 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
}
|
}
|
||||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||||
|
|
||||||
|
if (item.credentialsRequired === true
|
||||||
|
&& !hasCredentialMaterial(item.password)
|
||||||
|
&& !hasCredentialMaterial(item.cookies)
|
||||||
|
&& !hasCredentialMaterial(item.headers)
|
||||||
|
&& !hasCredentialMaterial(keychainPassword)) {
|
||||||
|
markCredentialsRequired(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.credentialsRequired === true) clearCredentialsRequired(id);
|
||||||
|
|
||||||
const proxy = proxyOverride === undefined
|
const proxy = proxyOverride === undefined
|
||||||
? await getProxyArgs(settings)
|
? await getProxyArgs(settings)
|
||||||
: proxyOverride;
|
: proxyOverride;
|
||||||
@@ -333,6 +363,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
|
sftp_host_key_md: item.sftpHostKeyMd || undefined,
|
||||||
headers: item.headers || null,
|
headers: item.headers || null,
|
||||||
checksum: item.checksum || null,
|
checksum: item.checksum || null,
|
||||||
cookies: item.cookies || null,
|
cookies: item.cookies || null,
|
||||||
@@ -1017,9 +1048,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
const state = get();
|
const state = get();
|
||||||
const item = state.downloads.find(d => d.id === id);
|
const item = state.downloads.find(d => d.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const normalizedUpdates = updates.fileName === undefined
|
const credentialsUpdated = (['password', 'cookies', 'headers'] as const)
|
||||||
? updates
|
.some(field => Object.prototype.hasOwnProperty.call(updates, field));
|
||||||
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
|
const nextCredentialMaterial = (['password', 'cookies', 'headers'] as const)
|
||||||
|
.some(field => hasCredentialMaterial(
|
||||||
|
Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field]
|
||||||
|
));
|
||||||
|
const normalizedUpdates = {
|
||||||
|
...(updates.fileName === undefined
|
||||||
|
? updates
|
||||||
|
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) }),
|
||||||
|
...(credentialsUpdated && nextCredentialMaterial
|
||||||
|
? { credentialsRequired: false }
|
||||||
|
: credentialsUpdated && item.credentialsRequired === true
|
||||||
|
? { credentialsRequired: true }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
const disablingTorrentRemoval = item.isTorrent === true
|
const disablingTorrentRemoval = item.isTorrent === true
|
||||||
&& normalizedUpdates.torrentRemoveUnselectedFile === false
|
&& normalizedUpdates.torrentRemoveUnselectedFile === false
|
||||||
&& item.torrentRemoveUnselectedFile !== false;
|
&& item.torrentRemoveUnselectedFile !== false;
|
||||||
@@ -1085,6 +1129,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
let targetItem = get().downloads.find(d => d.id === id);
|
let targetItem = get().downloads.find(d => d.id === id);
|
||||||
if (!targetItem) return false;
|
if (!targetItem) return false;
|
||||||
|
|
||||||
|
if (targetItem.credentialsRequired === true
|
||||||
|
&& !hasCredentialMaterial(targetItem.password)
|
||||||
|
&& !hasCredentialMaterial(targetItem.cookies)
|
||||||
|
&& !hasCredentialMaterial(targetItem.headers)) {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
const login = getSiteLogin(targetItem.url, settings);
|
||||||
|
let keychainPassword: string | null = null;
|
||||||
|
if (login && settings.keychainAccessReady) {
|
||||||
|
try {
|
||||||
|
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch keychain password for resume:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasCredentialMaterial(keychainPassword)) {
|
||||||
|
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
||||||
|
settings.setShowKeychainModal(true);
|
||||||
|
}
|
||||||
|
markCredentialsRequired(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearCredentialsRequired(id);
|
||||||
|
}
|
||||||
|
|
||||||
setDownloadControlIntent(id, 'resume');
|
setDownloadControlIntent(id, 'resume');
|
||||||
let previousStatus = targetItem.status;
|
let previousStatus = targetItem.status;
|
||||||
try {
|
try {
|
||||||
@@ -2343,6 +2411,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
console.warn("Could not fetch keychain password for login:", e);
|
console.warn("Could not fetch keychain password for login:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (item.credentialsRequired === true
|
||||||
|
&& !hasCredentialMaterial(item.password)
|
||||||
|
&& !hasCredentialMaterial(item.cookies)
|
||||||
|
&& !hasCredentialMaterial(item.headers)
|
||||||
|
&& !hasCredentialMaterial(keychainPassword)) {
|
||||||
|
markCredentialsRequired(item.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.credentialsRequired === true) clearCredentialsRequired(item.id);
|
||||||
const destPath = item.destination ||
|
const destPath = item.destination ||
|
||||||
await resolveCategoryDestination(settings, item.category);
|
await resolveCategoryDestination(settings, item.category);
|
||||||
itemsToEnqueue.push({
|
itemsToEnqueue.push({
|
||||||
@@ -2357,6 +2434,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
|
sftp_host_key_md: item.sftpHostKeyMd || undefined,
|
||||||
headers: item.headers || null,
|
headers: item.headers || null,
|
||||||
checksum: item.checksum || null,
|
checksum: item.checksum || null,
|
||||||
cookies: item.cookies || null,
|
cookies: item.cookies || null,
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ describe('download persistence progress snapshots', () => {
|
|||||||
expect(sanitized.lastResolverFallback).toBeUndefined();
|
expect(sanitized.lastResolverFallback).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks redacted downloads that need credentials after restart', () => {
|
||||||
|
const persisted = redactDownloadForPersistence({
|
||||||
|
...item('paused'),
|
||||||
|
username: 'alice',
|
||||||
|
password: 'secret',
|
||||||
|
cookies: 'session=redacted',
|
||||||
|
headers: 'Authorization: redacted',
|
||||||
|
});
|
||||||
|
expect(persisted.credentialsRequired).toBe(true);
|
||||||
|
expect(persisted.password).toBeUndefined();
|
||||||
|
expect(persisted.cookies).toBeUndefined();
|
||||||
|
expect(persisted.headers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
||||||
'keeps byte counters for %s snapshots',
|
'keeps byte counters for %s snapshots',
|
||||||
(status) => {
|
(status) => {
|
||||||
|
|||||||
@@ -566,6 +566,10 @@ const VOLATILE_PROGRESS_STATUSES = new Set([
|
|||||||
*/
|
*/
|
||||||
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
|
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
|
||||||
const copy: DownloadItem = { ...item };
|
const copy: DownloadItem = { ...item };
|
||||||
|
if (item.credentialsRequired === true
|
||||||
|
|| DOWNLOAD_SECRET_FIELDS.some(field => Boolean(item[field]))) {
|
||||||
|
copy.credentialsRequired = true;
|
||||||
|
}
|
||||||
delete copy.fraction;
|
delete copy.fraction;
|
||||||
delete copy.speed;
|
delete copy.speed;
|
||||||
delete copy.eta;
|
delete copy.eta;
|
||||||
|
|||||||
Reference in New Issue
Block a user