mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-28 11:37:17 +00:00
fix: harden audited download and release paths
This commit is contained in:
@@ -123,7 +123,8 @@ jobs:
|
|||||||
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
|
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
|
||||||
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
|
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
|
||||||
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
|
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
|
||||||
7z x $installer.FullName "-o$extractRoot" -y
|
& 7z x $installer.FullName "-o$extractRoot" -y
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." }
|
||||||
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||||
|
|
||||||
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
||||||
@@ -249,11 +250,12 @@ jobs:
|
|||||||
local pattern="$1"
|
local pattern="$1"
|
||||||
local destination="$2"
|
local destination="$2"
|
||||||
local source
|
local source
|
||||||
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
|
mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0)
|
||||||
if [[ -z "$source" ]]; then
|
if (( ${#matches[@]} != 1 )); then
|
||||||
echo "::error::Missing release asset matching $pattern"
|
echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
source="${matches[0]}"
|
||||||
mv "$source" "release-assets/$destination"
|
mv "$source" "release-assets/$destination"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
(!value.is_empty() && value.chars().count() <= 512).then_some(value)
|
(!value.is_empty() && value.chars().count() <= 512).then_some(value)
|
||||||
});
|
});
|
||||||
// A multi-URL handoff has no per-URL cookie scope. Keep ordinary
|
// A multi-URL handoff has no per-URL cookie scope. Keep ordinary
|
||||||
// request headers, but drop Cookie headers and the dedicated cookie field
|
// request headers, but drop credential-bearing headers and the dedicated cookie field
|
||||||
// so a legacy or untrusted caller cannot reuse one session across hosts.
|
// so a legacy or untrusted caller cannot reuse one session across hosts.
|
||||||
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
|
||||||
let cookie_scopes = if !payload.media && urls.len() == 1 {
|
let cookie_scopes = if !payload.media && urls.len() == 1 {
|
||||||
@@ -548,7 +548,17 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
|||||||
.lines()
|
.lines()
|
||||||
.filter(|line| {
|
.filter(|line| {
|
||||||
line.split_once(':')
|
line.split_once(':')
|
||||||
.map(|(name, _)| !name.trim().eq_ignore_ascii_case("cookie"))
|
.map(|(name, _)| {
|
||||||
|
!matches!(
|
||||||
|
name.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"authorization"
|
||||||
|
| "cookie"
|
||||||
|
| "cookie2"
|
||||||
|
| "proxy-authorization"
|
||||||
|
| "set-cookie"
|
||||||
|
| "set-cookie2"
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -881,7 +891,7 @@ mod tests {
|
|||||||
silent: false,
|
silent: false,
|
||||||
filename: None,
|
filename: None,
|
||||||
headers: Some(format!(
|
headers: Some(format!(
|
||||||
"Cookie: stale={};\nUser-Agent: Firefox",
|
"Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox",
|
||||||
"x".repeat(64 * 1024)
|
"x".repeat(64 * 1024)
|
||||||
)),
|
)),
|
||||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||||
|
|||||||
@@ -1912,6 +1912,8 @@ async fn fetch_metadata(
|
|||||||
let mut current_res = match head_req.send().await {
|
let mut current_res = match head_req.send().await {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(head_error) => build_get_range().send().await.map_err(|get_error| {
|
Err(head_error) => build_get_range().send().await.map_err(|get_error| {
|
||||||
|
let head_error = crate::redact_sensitive_text(&head_error.to_string());
|
||||||
|
let get_error = crate::redact_sensitive_text(&get_error.to_string());
|
||||||
format!(
|
format!(
|
||||||
"HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})"
|
"HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})"
|
||||||
)
|
)
|
||||||
@@ -2815,6 +2817,9 @@ fn approve_download_root(app_handle: tauri::AppHandle, path: String) -> Result<S
|
|||||||
if !resolved.is_absolute() {
|
if !resolved.is_absolute() {
|
||||||
return Err("Download root must be an absolute path".to_string());
|
return Err("Download root must be an absolute path".to_string());
|
||||||
}
|
}
|
||||||
|
if crate::path_has_symlink_component(&resolved) {
|
||||||
|
return Err("Download root may not contain symlink components".to_string());
|
||||||
|
}
|
||||||
let canonical = std::fs::canonicalize(&resolved)
|
let canonical = std::fs::canonicalize(&resolved)
|
||||||
.map_err(|error| format!("Failed to resolve download root: {error}"))?;
|
.map_err(|error| format!("Failed to resolve download root: {error}"))?;
|
||||||
if !canonical.is_dir() {
|
if !canonical.is_dir() {
|
||||||
@@ -3892,7 +3897,9 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
// user-installed tools, or platform-specific executable aliases.
|
// user-installed tools, or platform-specific executable aliases.
|
||||||
let trusted_path = crate::platform::trusted_system_path()?;
|
let trusted_path = crate::platform::trusted_system_path()?;
|
||||||
|
|
||||||
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
let max_retries = max_tries
|
||||||
|
.unwrap_or(crate::retry::MAX_RETRIES as i32)
|
||||||
|
.max(0) as usize;
|
||||||
let mut strike = 0_usize;
|
let mut strike = 0_usize;
|
||||||
let mut effective_cookie_source = cookie_source.clone();
|
let mut effective_cookie_source = cookie_source.clone();
|
||||||
let mut browser_cookie_fallback_used = false;
|
let mut browser_cookie_fallback_used = false;
|
||||||
|
|||||||
+12
-4
@@ -1,5 +1,5 @@
|
|||||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||||
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome};
|
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
|
||||||
use log;
|
use log;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
@@ -1600,7 +1600,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
||||||
max_tries.unwrap_or(0).max(0) as usize
|
max_tries.unwrap_or(MAX_RETRIES as i32).max(0) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aria2_attempt_limit(max_tries: Option<i32>) -> u32 {
|
fn aria2_attempt_limit(max_tries: Option<i32>) -> u32 {
|
||||||
@@ -1977,8 +1977,9 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("aria2 addUri [{}] failed: {}", id, e);
|
let safe_error = crate::redact_sensitive_text(&e);
|
||||||
Err(format!("aria2 addUri failed: {e}"))
|
log::error!("aria2 addUri [{}] failed: {}", id, safe_error);
|
||||||
|
Err(format!("aria2 addUri failed: {safe_error}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2288,4 +2289,11 @@ mod tests {
|
|||||||
assert_eq!(aria2_attempt_limit(Some(0)), 1);
|
assert_eq!(aria2_attempt_limit(Some(0)), 1);
|
||||||
assert_eq!(aria2_attempt_limit(Some(10)), 1);
|
assert_eq!(aria2_attempt_limit(Some(10)), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn omitted_retry_limit_uses_the_shared_default_but_zero_stays_explicit() {
|
||||||
|
assert_eq!(automatic_retry_limit(None), MAX_RETRIES);
|
||||||
|
assert_eq!(automatic_retry_limit(Some(0)), 0);
|
||||||
|
assert_eq!(automatic_retry_limit(Some(2)), 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-12
@@ -26,6 +26,26 @@ fn stop_is_due(
|
|||||||
&& last_stop_key != stop_key
|
&& last_stop_key != stop_key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn overnight_stop_is_due(
|
||||||
|
stop_time_enabled: bool,
|
||||||
|
start_minute: Option<u32>,
|
||||||
|
stop_minute: Option<u32>,
|
||||||
|
current_minute: u32,
|
||||||
|
previous_day_allowed: bool,
|
||||||
|
last_start_key: &str,
|
||||||
|
previous_start_key: &str,
|
||||||
|
last_stop_key: &str,
|
||||||
|
stop_key: &str,
|
||||||
|
) -> bool {
|
||||||
|
stop_time_enabled
|
||||||
|
&& previous_day_allowed
|
||||||
|
&& start_minute.zip(stop_minute).is_some_and(|(start, stop)| {
|
||||||
|
stop < start && current_minute >= stop && current_minute < start
|
||||||
|
})
|
||||||
|
&& last_start_key == previous_start_key
|
||||||
|
&& last_stop_key != stop_key
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_scheduler(
|
pub fn spawn_scheduler(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||||
@@ -56,20 +76,20 @@ pub fn spawn_scheduler(
|
|||||||
|
|
||||||
let allowed_today =
|
let allowed_today =
|
||||||
scheduler.everyday || scheduler.selected_days.contains(¤t_day);
|
scheduler.everyday || scheduler.selected_days.contains(¤t_day);
|
||||||
if !allowed_today {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let date_key = now.format("%Y-%m-%d").to_string();
|
let date_key = now.format("%Y-%m-%d").to_string();
|
||||||
let start_key = format!("{date_key}-start");
|
let start_key = format!("{date_key}-start");
|
||||||
let stop_key = format!("{date_key}-stop");
|
let stop_key = format!("{date_key}-stop");
|
||||||
let start_minute = minute_of_day(&scheduler.start_time);
|
let start_minute = minute_of_day(&scheduler.start_time);
|
||||||
let stop_minute = minute_of_day(&scheduler.stop_time);
|
let stop_minute = minute_of_day(&scheduler.stop_time);
|
||||||
|
let overnight = start_minute
|
||||||
|
.zip(stop_minute)
|
||||||
|
.is_some_and(|(start, stop)| stop < start);
|
||||||
let before_stop = !scheduler.stop_time_enabled
|
let before_stop = !scheduler.stop_time_enabled
|
||||||
|| stop_minute.is_some_and(|stop| current_minute < stop);
|
|| stop_minute.is_some_and(|stop| current_minute < stop);
|
||||||
|
|
||||||
if start_minute.is_some_and(|start| current_minute >= start)
|
if allowed_today
|
||||||
&& before_stop
|
&& start_minute.is_some_and(|start| current_minute >= start)
|
||||||
|
&& (overnight || before_stop)
|
||||||
&& scheduler_last_start_key != start_key
|
&& scheduler_last_start_key != start_key
|
||||||
&& last_emit
|
&& last_emit
|
||||||
.get("start")
|
.get("start")
|
||||||
@@ -85,17 +105,43 @@ pub fn spawn_scheduler(
|
|||||||
last_emit.insert("start", std::time::Instant::now());
|
last_emit.insert("start", std::time::Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
if stop_is_due(
|
let same_day_stop_due = allowed_today
|
||||||
|
&& !overnight
|
||||||
|
&& stop_is_due(
|
||||||
|
scheduler.stop_time_enabled,
|
||||||
|
stop_minute,
|
||||||
|
current_minute,
|
||||||
|
&scheduler_last_start_key,
|
||||||
|
&start_key,
|
||||||
|
&scheduler_last_stop_key,
|
||||||
|
&stop_key,
|
||||||
|
);
|
||||||
|
let previous_day = now.date_naive().pred_opt();
|
||||||
|
let previous_day_allowed = previous_day.is_some_and(|day| {
|
||||||
|
scheduler.everyday
|
||||||
|
|| scheduler
|
||||||
|
.selected_days
|
||||||
|
.contains(&day.weekday().num_days_from_sunday())
|
||||||
|
});
|
||||||
|
let previous_start_key = previous_day
|
||||||
|
.map(|day| format!("{}-start", day.format("%Y-%m-%d")))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let overnight_stop_due = overnight_stop_is_due(
|
||||||
scheduler.stop_time_enabled,
|
scheduler.stop_time_enabled,
|
||||||
|
start_minute,
|
||||||
stop_minute,
|
stop_minute,
|
||||||
current_minute,
|
current_minute,
|
||||||
|
previous_day_allowed,
|
||||||
&scheduler_last_start_key,
|
&scheduler_last_start_key,
|
||||||
&start_key,
|
&previous_start_key,
|
||||||
&scheduler_last_stop_key,
|
&scheduler_last_stop_key,
|
||||||
&stop_key,
|
&stop_key,
|
||||||
) && last_emit
|
);
|
||||||
.get("stop")
|
|
||||||
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
|
if (same_day_stop_due || overnight_stop_due)
|
||||||
|
&& last_emit
|
||||||
|
.get("stop")
|
||||||
|
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
|
||||||
{
|
{
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"schedule-trigger",
|
"schedule-trigger",
|
||||||
@@ -113,7 +159,7 @@ pub fn spawn_scheduler(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{minute_of_day, stop_is_due};
|
use super::{minute_of_day, overnight_stop_is_due, stop_is_due};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_valid_scheduler_times() {
|
fn parses_valid_scheduler_times() {
|
||||||
@@ -150,4 +196,41 @@ mod tests {
|
|||||||
"2026-06-22-stop",
|
"2026-06-22-stop",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overnight_stop_uses_the_previous_day_start() {
|
||||||
|
assert!(overnight_stop_is_due(
|
||||||
|
true,
|
||||||
|
Some(1320),
|
||||||
|
Some(360),
|
||||||
|
420,
|
||||||
|
true,
|
||||||
|
"2026-06-22-start",
|
||||||
|
"2026-06-22-start",
|
||||||
|
"",
|
||||||
|
"2026-06-23-stop",
|
||||||
|
));
|
||||||
|
assert!(!overnight_stop_is_due(
|
||||||
|
true,
|
||||||
|
Some(1320),
|
||||||
|
Some(360),
|
||||||
|
1380,
|
||||||
|
true,
|
||||||
|
"2026-06-22-start",
|
||||||
|
"2026-06-22-start",
|
||||||
|
"",
|
||||||
|
"2026-06-22-stop",
|
||||||
|
));
|
||||||
|
assert!(!overnight_stop_is_due(
|
||||||
|
true,
|
||||||
|
Some(1320),
|
||||||
|
Some(360),
|
||||||
|
420,
|
||||||
|
false,
|
||||||
|
"2026-06-22-start",
|
||||||
|
"2026-06-22-start",
|
||||||
|
"",
|
||||||
|
"2026-06-23-stop",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,11 +230,11 @@ export const AddDownloadsModal = () => {
|
|||||||
const closeModalFromDismissAction = useCallback(() => {
|
const closeModalFromDismissAction = useCallback(() => {
|
||||||
if (isSubmitting || isSubmittingRef.current || showKeychainModal) return;
|
if (isSubmitting || isSubmittingRef.current || showKeychainModal) return;
|
||||||
const hasPendingInput = Boolean(
|
const hasPendingInput = Boolean(
|
||||||
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
|
urls.trim() || pendingAddUrls.trim() || parsedItems.some(item => item.selected !== false) || headers.trim() || cookies.trim()
|
||||||
);
|
);
|
||||||
if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return;
|
if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return;
|
||||||
toggleAddModal(false);
|
toggleAddModal(false);
|
||||||
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
|
}, [cookies, headers, isSubmitting, parsedItems, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAddModalOpen) {
|
if (!isAddModalOpen) {
|
||||||
@@ -1802,13 +1802,14 @@ export const AddDownloadsModal = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.cookies)}</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.cookies)}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="password"
|
||||||
value={cookies}
|
value={cookies}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
cookiesManuallyEditedRef.current = true;
|
cookiesManuallyEditedRef.current = true;
|
||||||
setCookies(e.target.value);
|
setCookies(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder={t($ => $.addDownloads.cookiePlaceholder)}
|
placeholder={t($ => $.addDownloads.cookiePlaceholder)}
|
||||||
|
autoComplete="off"
|
||||||
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
||||||
aria-label={t($ => $.addDownloads.cookies)}
|
aria-label={t($ => $.addDownloads.cookies)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface DownloadItemProps {
|
|||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
queueLength: number;
|
queueLength: number;
|
||||||
tableGridTemplate: string;
|
tableGridTemplate: string;
|
||||||
|
tableMinWidth: number;
|
||||||
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
||||||
handlePause: (id: string, skipConfirm?: boolean) => void;
|
handlePause: (id: string, skipConfirm?: boolean) => void;
|
||||||
handleResume: (item: DownloadItemType) => void;
|
handleResume: (item: DownloadItemType) => void;
|
||||||
@@ -31,6 +32,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
queueIndex,
|
queueIndex,
|
||||||
queueLength,
|
queueLength,
|
||||||
tableGridTemplate,
|
tableGridTemplate,
|
||||||
|
tableMinWidth,
|
||||||
setContextMenu,
|
setContextMenu,
|
||||||
handlePause,
|
handlePause,
|
||||||
handleResume,
|
handleResume,
|
||||||
@@ -84,7 +86,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
|
||||||
onClick={(e) => onClick(e, download)}
|
onClick={(e) => onClick(e, download)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -30,8 +30,28 @@ interface DownloadTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
|
const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
|
||||||
|
const COLUMN_MINIMUMS = [0, 58, 92, 58, 48, 112];
|
||||||
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||||
|
|
||||||
|
const normalizeColumnWidths = (value: unknown): number[] => {
|
||||||
|
if (!Array.isArray(value) || value.length !== DEFAULT_COLUMN_WIDTHS.length) {
|
||||||
|
return DEFAULT_COLUMN_WIDTHS;
|
||||||
|
}
|
||||||
|
return value.map((width, index) =>
|
||||||
|
typeof width === 'number' && Number.isFinite(width)
|
||||||
|
? Math.max(COLUMN_MINIMUMS[index], width)
|
||||||
|
: DEFAULT_COLUMN_WIDTHS[index]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistColumnWidths = (widths: number[]): void => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(widths));
|
||||||
|
} catch {
|
||||||
|
// Local storage can be unavailable in restricted WebView contexts.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||||
@@ -54,6 +74,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||||
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
|
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
|
||||||
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
|
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
|
||||||
|
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||||
const selectedIdsRef = useRef(selectedIds);
|
const selectedIdsRef = useRef(selectedIds);
|
||||||
const lastSelectedIdRef = useRef(lastSelectedId);
|
const lastSelectedIdRef = useRef(lastSelectedId);
|
||||||
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
||||||
@@ -62,38 +83,56 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const [columnWidths, setColumnWidths] = useState(() => {
|
const [columnWidths, setColumnWidths] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
|
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
|
||||||
return Array.isArray(stored) &&
|
return normalizeColumnWidths(stored);
|
||||||
stored.length === DEFAULT_COLUMN_WIDTHS.length &&
|
|
||||||
stored.every(value => typeof value === 'number' && Number.isFinite(value))
|
|
||||||
? stored
|
|
||||||
: DEFAULT_COLUMN_WIDTHS;
|
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_COLUMN_WIDTHS;
|
return DEFAULT_COLUMN_WIDTHS;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const columnMinimums = [0, 58, 92, 58, 48, 112];
|
const columnWidthsRef = useRef(columnWidths);
|
||||||
const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' ');
|
columnWidthsRef.current = columnWidths;
|
||||||
|
const normalizedColumnWidths = columnWidths.map((width, index) =>
|
||||||
|
Math.max(COLUMN_MINIMUMS[index], width)
|
||||||
|
);
|
||||||
|
const tableGridTemplate = [
|
||||||
|
...normalizedColumnWidths.slice(0, -1).map(width => `${width}px`),
|
||||||
|
'minmax(0, 1fr)',
|
||||||
|
`${normalizedColumnWidths[normalizedColumnWidths.length - 1]}px`
|
||||||
|
].join(' ');
|
||||||
|
const tableMinWidth = normalizedColumnWidths.reduce(
|
||||||
|
(total, width) => total + width,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
const startColumnResize = (index: number, event: React.PointerEvent<HTMLDivElement>) => {
|
const startColumnResize = (index: number, event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
resizeCleanupRef.current?.();
|
||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
const startWidth = columnWidths[index];
|
const startWidth = columnWidths[index];
|
||||||
|
|
||||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||||
const nextWidth = Math.max(columnMinimums[index], startWidth + moveEvent.clientX - startX);
|
const nextWidth = Math.max(COLUMN_MINIMUMS[index], startWidth + moveEvent.clientX - startX);
|
||||||
setColumnWidths(widths => widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width));
|
setColumnWidths(widths => {
|
||||||
|
const nextWidths = widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width);
|
||||||
|
columnWidthsRef.current = nextWidths;
|
||||||
|
return nextWidths;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = () => {
|
const handlePointerUp = () => {
|
||||||
window.removeEventListener('pointermove', handlePointerMove);
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
window.removeEventListener('pointerup', handlePointerUp);
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
window.removeEventListener('pointercancel', handlePointerUp);
|
||||||
|
persistColumnWidths(columnWidthsRef.current);
|
||||||
document.body.classList.remove('is-resizing');
|
document.body.classList.remove('is-resizing');
|
||||||
|
resizeCleanupRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
resizeCleanupRef.current = handlePointerUp;
|
||||||
document.body.classList.add('is-resizing');
|
document.body.classList.add('is-resizing');
|
||||||
window.addEventListener('pointermove', handlePointerMove);
|
window.addEventListener('pointermove', handlePointerMove);
|
||||||
window.addEventListener('pointerup', handlePointerUp);
|
window.addEventListener('pointerup', handlePointerUp);
|
||||||
|
window.addEventListener('pointercancel', handlePointerUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -109,9 +148,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => {
|
||||||
window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths));
|
resizeCleanupRef.current?.();
|
||||||
}, [columnWidths]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -539,7 +578,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
<div className="downloads-table flex-1 flex flex-col">
|
<div className="downloads-table flex-1 flex flex-col">
|
||||||
<div className="download-table-scroll">
|
<div className="download-table-scroll">
|
||||||
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
|
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}>
|
||||||
{[
|
{[
|
||||||
{ key: 'File Name' as const, label: t($ => $.downloadTable.headers.fileName) },
|
{ key: 'File Name' as const, label: t($ => $.downloadTable.headers.fileName) },
|
||||||
{ key: 'Size' as const, label: t($ => $.downloadTable.headers.size) },
|
{ key: 'Size' as const, label: t($ => $.downloadTable.headers.size) },
|
||||||
@@ -569,7 +608,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="download-table-body">
|
<div className="download-table-body" style={{ minWidth: tableMinWidth }}>
|
||||||
<div className="download-table-list" ref={animationParent}>
|
<div className="download-table-list" ref={animationParent}>
|
||||||
{sortedDownloads.length === 0 ? (
|
{sortedDownloads.length === 0 ? (
|
||||||
<div className="downloads-empty-state">
|
<div className="downloads-empty-state">
|
||||||
@@ -609,6 +648,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
|
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
|
||||||
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
|
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
|
||||||
tableGridTemplate={tableGridTemplate}
|
tableGridTemplate={tableGridTemplate}
|
||||||
|
tableMinWidth={tableMinWidth}
|
||||||
setContextMenu={handleContextMenu}
|
setContextMenu={handleContextMenu}
|
||||||
handlePause={handlePause}
|
handlePause={handlePause}
|
||||||
handleResume={handleResume}
|
handleResume={handleResume}
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ export const PropertiesModal = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
|
||||||
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="password" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} autoComplete="off" placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
|
|
||||||
<div className="col-span-2 mt-2">
|
<div className="col-span-2 mt-2">
|
||||||
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
|
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function SchedulerView() {
|
|||||||
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
|
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) {
|
||||||
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -562,7 +562,6 @@ runEngineChecks(false);
|
|||||||
});
|
});
|
||||||
if (base && typeof base === 'string') {
|
if (base && typeof base === 'string') {
|
||||||
const approvedBase = await settings.approveDownloadRoot(base);
|
const approvedBase = await settings.approveDownloadRoot(base);
|
||||||
settings.setBaseDownloadFolder(approvedBase);
|
|
||||||
try {
|
try {
|
||||||
if (settings.categorySubfoldersEnabled) {
|
if (settings.categorySubfoldersEnabled) {
|
||||||
const safeSubfolders = Object.fromEntries(
|
const safeSubfolders = Object.fromEntries(
|
||||||
@@ -586,6 +585,7 @@ runEngineChecks(false);
|
|||||||
showToast(t($ => $.settings.locations.baseFolderCreateFailed, { detail: String(e) }), 'warning');
|
showToast(t($ => $.settings.locations.baseFolderCreateFailed, { detail: String(e) }), 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
settings.setBaseDownloadFolder(approvedBase);
|
||||||
showToast(t($ => $.settings.locations.baseFolderUpdated), 'success');
|
showToast(t($ => $.settings.locations.baseFolderUpdated), 'success');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+8
-2
@@ -1902,7 +1902,7 @@ html[data-list-density="relaxed"] {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1935,6 +1935,11 @@ html[data-list-density="relaxed"] {
|
|||||||
padding-inline-end: 0;
|
padding-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-table-header > div:last-child,
|
||||||
|
.download-row > div:last-child {
|
||||||
|
grid-column: 7;
|
||||||
|
}
|
||||||
|
|
||||||
.download-table-header > div > span {
|
.download-table-header > div > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1991,7 +1996,8 @@ body.is-resizing .column-resize-handle:hover::after {
|
|||||||
.download-table-list {
|
.download-table-list {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1879,7 +1879,7 @@ describe('useDownloadStore', () => {
|
|||||||
referer: 'https://adult.example/watch/123',
|
referer: 'https://adult.example/watch/123',
|
||||||
silent: false,
|
silent: false,
|
||||||
filename: null,
|
filename: null,
|
||||||
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
|
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox Test`,
|
||||||
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
||||||
cookie_scopes: null,
|
cookie_scopes: null,
|
||||||
media: true,
|
media: true,
|
||||||
|
|||||||
@@ -209,12 +209,21 @@ export class SystemProxyResolutionError extends Error {
|
|||||||
const isSystemProxyConfigurationError = (error: unknown): boolean =>
|
const isSystemProxyConfigurationError = (error: unknown): boolean =>
|
||||||
error instanceof SystemProxyResolutionError;
|
error instanceof SystemProxyResolutionError;
|
||||||
|
|
||||||
const stripCookieHeaders = (value: string | null | undefined): string =>
|
const stripSensitiveMediaHeaders = (value: string | null | undefined): string =>
|
||||||
(value || '')
|
(value || '')
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter(line => {
|
.filter(line => {
|
||||||
const separator = line.indexOf(':');
|
const separator = line.indexOf(':');
|
||||||
return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie';
|
if (separator < 0) return true;
|
||||||
|
const name = line.slice(0, separator).trim().toLowerCase();
|
||||||
|
return ![
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'cookie2',
|
||||||
|
'proxy-authorization',
|
||||||
|
'set-cookie',
|
||||||
|
'set-cookie2'
|
||||||
|
].includes(name);
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -1035,7 +1044,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
// extension builds; ordinary captured downloads retain their cookies.
|
// extension builds; ordinary captured downloads retain their cookies.
|
||||||
const cookies = request.media === true ? null : request.cookies;
|
const cookies = request.media === true ? null : request.cookies;
|
||||||
const headers = request.media === true
|
const headers = request.media === true
|
||||||
? stripCookieHeaders(request.headers) || null
|
? stripSensitiveMediaHeaders(request.headers) || null
|
||||||
: request.headers;
|
: request.headers;
|
||||||
|
|
||||||
get().openAddModalWithUrls(
|
get().openAddModalWithUrls(
|
||||||
|
|||||||
Reference in New Issue
Block a user