mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 17:08:26 +00:00
fix(downloads): harden add flow and URL validation
This commit is contained in:
+98
-33
@@ -1527,42 +1527,52 @@ fn should_cleanup_media_artifacts_after_failure(
|
||||
!(crate::retry::is_transient_network_error(failure_reason) && strike < max_retries)
|
||||
}
|
||||
|
||||
fn is_blocked_network_address(ip: std::net::IpAddr) -> bool {
|
||||
if ip.is_loopback() || ip.is_multicast() || ip.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
match ip {
|
||||
std::net::IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_link_local(),
|
||||
std::net::IpAddr::V6(ipv6) => {
|
||||
ipv6.to_ipv4().is_some_and(|ipv4| is_blocked_network_address(ipv4.into()))
|
||||
|| (ipv6.segments()[0] & 0xfe00) == 0xfc00
|
||||
|| (ipv6.segments()[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_and_validate_url_host(
|
||||
parsed: &reqwest::Url,
|
||||
) -> Result<(String, std::net::SocketAddr), String> {
|
||||
let host = parsed.host_str().ok_or("SSRF blocked: No host")?;
|
||||
let lookup_host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
let port = parsed.port_or_known_default().unwrap_or_else(|| match parsed.scheme() {
|
||||
"ftp" => 21,
|
||||
"sftp" => 22,
|
||||
_ => 80,
|
||||
});
|
||||
|
||||
let addrs: Vec<_> = if let Ok(ip) = lookup_host.parse::<std::net::IpAddr>() {
|
||||
vec![std::net::SocketAddr::new(ip, port)]
|
||||
} else {
|
||||
tokio::net::lookup_host((lookup_host, port))
|
||||
.await
|
||||
.map_err(|_| "SSRF blocked: DNS resolution failed")?
|
||||
.collect()
|
||||
};
|
||||
let addr = addrs.first().copied().ok_or("SSRF blocked: No DNS records")?;
|
||||
if addrs.iter().any(|candidate| is_blocked_network_address(candidate.ip())) {
|
||||
return Err("SSRF blocked: Private/local IP not allowed".to_string());
|
||||
}
|
||||
Ok((lookup_host.to_string(), addr))
|
||||
}
|
||||
|
||||
async fn validate_url_ssrf(url: &str) -> Result<Option<(String, std::net::SocketAddr)>, String> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|_| "SSRF blocked: Invalid URL")?;
|
||||
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||
return Err("SSRF blocked: Only HTTP/HTTPS schemes allowed".to_string());
|
||||
}
|
||||
let host = parsed.host_str().ok_or("SSRF blocked: No host")?;
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
|
||||
let mut addrs = tokio::net::lookup_host((host, port))
|
||||
.await
|
||||
.map_err(|_| "SSRF blocked: DNS resolution failed")?;
|
||||
|
||||
let addr = addrs.next().ok_or("SSRF blocked: No DNS records")?;
|
||||
let ip = addr.ip();
|
||||
|
||||
if ip.is_loopback() || ip.is_multicast() || ip.is_unspecified() {
|
||||
return Err("SSRF blocked: Private/local IP not allowed".to_string());
|
||||
}
|
||||
match ip {
|
||||
std::net::IpAddr::V4(ipv4) => {
|
||||
if ipv4.is_private() || ipv4.is_link_local() {
|
||||
return Err("SSRF blocked: Private/local IP not allowed".to_string());
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(ipv6) => {
|
||||
if (ipv6.segments()[0] & 0xfe00) == 0xfc00 {
|
||||
// ULA check
|
||||
return Err("SSRF blocked: Private/local IP not allowed".to_string());
|
||||
}
|
||||
if (ipv6.segments()[0] & 0xffc0) == 0xfe80 {
|
||||
// Link-local check
|
||||
return Err("SSRF blocked: Private/local IP not allowed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some((host.to_string(), addr)))
|
||||
resolve_and_validate_url_host(&parsed).await.map(Some)
|
||||
}
|
||||
|
||||
fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool {
|
||||
@@ -1632,7 +1642,15 @@ async fn fetch_metadata(
|
||||
builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36");
|
||||
}
|
||||
|
||||
let resolved_addr = validate_url_ssrf(¤t_url).await?;
|
||||
let parsed_current_url = reqwest::Url::parse(¤t_url)
|
||||
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
||||
let resolved_addr = match parsed_current_url.scheme() {
|
||||
"http" | "https" => validate_url_ssrf(¤t_url).await?,
|
||||
"ftp" | "sftp" => resolve_and_validate_url_host(&parsed_current_url)
|
||||
.await
|
||||
.map(Some)?,
|
||||
_ => return Err("Unsupported URL scheme".to_string()),
|
||||
};
|
||||
|
||||
if let Some((host, addr)) = resolved_addr {
|
||||
builder = builder.resolve(&host, addr);
|
||||
@@ -4985,12 +5003,25 @@ fn enqueue_lifecycle_generation(item: &queue::EnqueueItem) -> Result<u64, String
|
||||
.map(|generation| generation.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn validate_enqueue_url(url: &str) -> Result<(), String> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" | "ftp" | "sftp" => {
|
||||
resolve_and_validate_url_host(&parsed).await.map(|_| ())
|
||||
}
|
||||
_ => Err("Unsupported URL scheme".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enqueue_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
mut item: queue::EnqueueItem,
|
||||
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
|
||||
validate_enqueue_url(&item.url)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
let id = item.id.clone();
|
||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||
let accepted_filename = item.filename.clone();
|
||||
@@ -5054,8 +5085,17 @@ async fn enqueue_many(
|
||||
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
|
||||
let mut results = Vec::with_capacity(items.len());
|
||||
for mut item in items {
|
||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||
let id = item.id.clone();
|
||||
if let Err(error) = validate_enqueue_url(&item.url).await {
|
||||
results.push(crate::ipc::EnqueueResult {
|
||||
id,
|
||||
success: false,
|
||||
filename: None,
|
||||
error: Some(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||
let filename = item.filename.clone();
|
||||
let lifecycle_generation = match enqueue_lifecycle_generation(&item) {
|
||||
Ok(generation) => generation,
|
||||
@@ -6062,10 +6102,35 @@ mod tests {
|
||||
observe_aria2_connections, observe_aria2_connections_with_epoch,
|
||||
Aria2ConnectionObservation, Aria2RecoveryReason,
|
||||
parse_media_playlist_metadata,
|
||||
validate_enqueue_url,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_url_validation_blocks_local_http_but_preserves_ftp() {
|
||||
assert_eq!(
|
||||
validate_enqueue_url("http://127.0.0.1/file.zip").await,
|
||||
Err("SSRF blocked: Private/local IP not allowed".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_enqueue_url("ftp://127.0.0.1/file.zip").await,
|
||||
Err("SSRF blocked: Private/local IP not allowed".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_enqueue_url("sftp://[::1]/file.zip").await,
|
||||
Err("SSRF blocked: Private/local IP not allowed".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_enqueue_url("http://[::ffff:127.0.0.1]/file.zip").await,
|
||||
Err("SSRF blocked: Private/local IP not allowed".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_enqueue_url("file:///tmp/file.zip").await,
|
||||
Err("Unsupported URL scheme".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_nonzero_aria2_throughput_recovers_after_a_sustained_degradation() {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -92,7 +92,8 @@ export const AddDownloadsModal = () => {
|
||||
baseDownloadFolder,
|
||||
perServerConnections,
|
||||
keychainAccessReady,
|
||||
keychainPromptDismissed
|
||||
keychainPromptDismissed,
|
||||
showKeychainModal
|
||||
} = useSettingsStore();
|
||||
|
||||
const [urls, setUrls] = useState('');
|
||||
@@ -168,13 +169,13 @@ export const AddDownloadsModal = () => {
|
||||
row.playlistSourceUrl || row.sourceUrl;
|
||||
|
||||
const closeModalFromDismissAction = useCallback(() => {
|
||||
if (isSubmitting || isSubmittingRef.current) return;
|
||||
if (isSubmitting || isSubmittingRef.current || showKeychainModal) return;
|
||||
const hasPendingInput = Boolean(
|
||||
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
|
||||
);
|
||||
if (hasPendingInput && !window.confirm('Discard this download setup?')) return;
|
||||
toggleAddModal(false);
|
||||
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, toggleAddModal, urls]);
|
||||
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddModalOpen) {
|
||||
@@ -271,6 +272,7 @@ export const AddDownloadsModal = () => {
|
||||
if (!isAddModalOpen) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (showKeychainModal) return;
|
||||
if (showingDuplicates) {
|
||||
setShowingDuplicates(false);
|
||||
} else if (isQueueMenuOpen) {
|
||||
@@ -281,7 +283,7 @@ export const AddDownloadsModal = () => {
|
||||
};
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showingDuplicates]);
|
||||
}, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showKeychainModal, showingDuplicates]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++freeSpaceRequestRef.current;
|
||||
@@ -511,12 +513,23 @@ export const AddDownloadsModal = () => {
|
||||
size: meta.size_bytes ? meta.size : undefined,
|
||||
sizeBytes: meta.size_bytes || undefined,
|
||||
status: 'ready',
|
||||
resumable: meta.resumable
|
||||
resumable: meta.resumable,
|
||||
metadataBlockedReason: undefined
|
||||
})
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Meta fetch failed", e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
const metadataBlockedReason = [
|
||||
'SSRF blocked: Invalid URL',
|
||||
'SSRF blocked: No host',
|
||||
'SSRF blocked: DNS resolution failed',
|
||||
'SSRF blocked: No DNS records',
|
||||
'SSRF blocked: Private/local IP not allowed'
|
||||
].some(prefix => errorMessage.startsWith(prefix))
|
||||
? 'unsafe-url' as const
|
||||
: undefined;
|
||||
setParsedItems(current => updateRowIfCurrent(
|
||||
current,
|
||||
row.id,
|
||||
@@ -530,8 +543,9 @@ export const AddDownloadsModal = () => {
|
||||
status: 'metadata-error',
|
||||
formats: undefined,
|
||||
selectedFormat: undefined,
|
||||
metadataBlockedReason,
|
||||
playlistError: row.isPlaylist
|
||||
? (e instanceof Error ? e.message : String(e))
|
||||
? errorMessage
|
||||
: undefined
|
||||
})
|
||||
));
|
||||
@@ -1003,7 +1017,10 @@ export const AddDownloadsModal = () => {
|
||||
const failedMediaMetadataCount = selectedItems.filter(
|
||||
item => item.status === 'metadata-error' && item.isMedia
|
||||
).length;
|
||||
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount;
|
||||
const blockedMetadataCount = selectedItems.filter(
|
||||
item => item.metadataBlockedReason === 'unsafe-url'
|
||||
).length;
|
||||
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount;
|
||||
const activePlaylistUrls = new Set(
|
||||
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
|
||||
);
|
||||
@@ -1084,7 +1101,7 @@ export const AddDownloadsModal = () => {
|
||||
})}
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<span className="text-[11px] text-text-muted font-medium">
|
||||
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
|
||||
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry, {blockedMetadataCount} blocked
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1166,7 +1183,7 @@ export const AddDownloadsModal = () => {
|
||||
</div>
|
||||
) : (
|
||||
item.status === 'metadata-error'
|
||||
? item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback'
|
||||
? item.metadataBlockedReason === 'unsafe-url' ? 'Unsafe URL' : item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback'
|
||||
: item.status === 'invalid'
|
||||
? 'Invalid'
|
||||
: 'Ready'
|
||||
@@ -1404,7 +1421,7 @@ export const AddDownloadsModal = () => {
|
||||
{metadataSummaryMessage(parsedItems)}
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
<button onClick={closeModalFromDismissAction} disabled={isSubmitting} className="add-download-button add-download-button-cancel px-4 text-xs">
|
||||
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-cancel px-4 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
<div ref={actionMenuRef} className="relative flex gap-2.5">
|
||||
|
||||
@@ -5,6 +5,9 @@ import { KeyRound, ShieldAlert } from 'lucide-react';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { getKeychainConsentVersion } from '../utils/keychainStartup';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
|
||||
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
||||
|
||||
type KeychainPermissionModalProps = {
|
||||
consentVersion: string;
|
||||
@@ -54,27 +57,48 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
setIsGranting(true);
|
||||
setError(null);
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
let persistentGrantApplied = false;
|
||||
const applyPersistentGrant = async (result: PairingTokenHydration): Promise<boolean> => {
|
||||
if (!result.persistent || persistentGrantApplied) return result.persistent;
|
||||
persistentGrantApplied = true;
|
||||
const grantedVersion = consentVersion || getKeychainConsentVersion(await getVersion().catch(() => ''));
|
||||
// Keep state in sync with the grant result instead of rehydrating
|
||||
// before Zustand has persisted keychainAccessGranted.
|
||||
useSettingsStore.setState({
|
||||
keychainAccessGranted: true,
|
||||
keychainAccessVersion: grantedVersion,
|
||||
keychainAccessReady: true,
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const grantRequest = invoke('grant_keychain_access');
|
||||
// A native credential-store call cannot be cancelled by the webview. Keep
|
||||
// a late successful result useful even if the UI timeout has already
|
||||
// restored the Later/retry controls.
|
||||
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
||||
|
||||
try {
|
||||
const result = await invoke('grant_keychain_access');
|
||||
if (result.persistent) {
|
||||
const grantedVersion = consentVersion || getKeychainConsentVersion(await getVersion().catch(() => ''));
|
||||
// Keep state in sync with the grant result instead of rehydrating
|
||||
// before Zustand has persisted keychainAccessGranted.
|
||||
useSettingsStore.setState({
|
||||
keychainAccessGranted: true,
|
||||
keychainAccessVersion: grantedVersion,
|
||||
keychainAccessReady: true,
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: true,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
} else {
|
||||
const result = await Promise.race([
|
||||
grantRequest,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = window.setTimeout(
|
||||
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
|
||||
KEYCHAIN_GRANT_TIMEOUT_MS
|
||||
);
|
||||
})
|
||||
]);
|
||||
if (!(await applyPersistentGrant(result))) {
|
||||
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
} finally {
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
setIsGranting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,6 +38,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const addQueueSubmitRef = useRef(false);
|
||||
const renameQueueSubmitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
@@ -139,15 +141,34 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleAddQueueSubmit = () => {
|
||||
if (newQueueName.trim()) addQueue(newQueueName.trim());
|
||||
if (addQueueSubmitRef.current) return;
|
||||
const normalizedName = newQueueName.trim();
|
||||
if (!normalizedName) {
|
||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (!addQueue(normalizedName)) {
|
||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
addQueueSubmitRef.current = true;
|
||||
setNewQueueName('');
|
||||
setIsAddingQueue(false);
|
||||
};
|
||||
|
||||
const handleRenameQueueSubmit = () => {
|
||||
if (renamingQueueId && editingQueueName.trim()) {
|
||||
renameQueue(renamingQueueId, editingQueueName.trim());
|
||||
if (renameQueueSubmitRef.current) return;
|
||||
const normalizedName = editingQueueName.trim();
|
||||
if (!renamingQueueId) return;
|
||||
if (!normalizedName) {
|
||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (!renameQueue(renamingQueueId, normalizedName)) {
|
||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
renameQueueSubmitRef.current = true;
|
||||
setRenamingQueueId(null);
|
||||
};
|
||||
|
||||
@@ -293,7 +314,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsAddingQueue(true); setNewQueueName(''); }}
|
||||
onClick={() => { addQueueSubmitRef.current = false; setIsAddingQueue(true); setNewQueueName(''); }}
|
||||
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
|
||||
@@ -372,6 +393,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
onClick={() => {
|
||||
const q = queues.find(q => q.id === contextMenu.id);
|
||||
if (q) {
|
||||
renameQueueSubmitRef.current = false;
|
||||
setEditingQueueName(q.name);
|
||||
setRenamingQueueId(q.id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { dispatchItem, getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
||||
import { dispatchItem, getProxyArgs, getSiteLogin, normalizeCustomProxy, normalizePersistedQueueState, normalizePersistedQueues, useDownloadStore } from './useDownloadStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
@@ -111,6 +111,89 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
|
||||
});
|
||||
|
||||
it('replaces stale media intent when an appended handoff reuses a URL', () => {
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://example.com/file.bin', '', '', '', '', true
|
||||
);
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://example.com/file.bin', '', '', '', '', false
|
||||
);
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and duplicate queue names', () => {
|
||||
useDownloadStore.setState({
|
||||
queues: [
|
||||
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||
]
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().addQueue('')).toBe(false);
|
||||
expect(useDownloadStore.getState().addQueue(' downloads ')).toBe(false);
|
||||
expect(useDownloadStore.getState().addQueue('Archive')).toBe(true);
|
||||
expect(useDownloadStore.getState().renameQueue('queue-a', ' archive ')).toBe(false);
|
||||
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes malformed persisted queues around one canonical main queue', () => {
|
||||
expect(normalizePersistedQueues([
|
||||
{ id: 'custom-a', name: ' Downloads ', isMain: false },
|
||||
{ id: 'custom-b', name: 'downloads', isMain: false },
|
||||
{ id: 'custom-a', name: 'Duplicate ID', isMain: false },
|
||||
{ id: 'legacy-main', name: 'Primary', isMain: true },
|
||||
{ id: 'empty-name', name: ' ', isMain: false },
|
||||
{ id: 'main-id', name: 'Ignored Main', isMain: true }
|
||||
])).toEqual([
|
||||
{ id: '00000000-0000-0000-0000-000000000001', name: 'Primary', isMain: true },
|
||||
{ id: 'custom-a', name: 'Downloads', isMain: false },
|
||||
{ id: 'custom-b', name: 'downloads (2)', isMain: false },
|
||||
{ id: 'empty-name', name: 'Queue empty-na', isMain: false }
|
||||
]);
|
||||
expect(normalizePersistedQueueState([
|
||||
{ id: 'legacy-main', name: 'Primary', isMain: true }
|
||||
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
|
||||
});
|
||||
|
||||
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
return [JSON.stringify({ id: 'legacy-main', name: 'Primary', isMain: true })];
|
||||
}
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [
|
||||
JSON.stringify({
|
||||
id: 'legacy-download',
|
||||
url: 'https://example.com/legacy.bin',
|
||||
fileName: 'legacy.bin',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: 'legacy-main'
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'orphan-download',
|
||||
url: 'https://example.com/orphan.bin',
|
||||
fileName: 'orphan.bin',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: 'missing-queue'
|
||||
})
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(useDownloadStore.getState().downloads.map(download => download.queueId))
|
||||
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
|
||||
});
|
||||
|
||||
it('normalizes proxy settings for download dispatch', async () => {
|
||||
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
|
||||
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
|
||||
|
||||
@@ -394,6 +394,50 @@ const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => {
|
||||
|
||||
export type { DownloadStatus };
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
|
||||
|
||||
const queueNameKey = (name: string): string => name.trim().toLowerCase();
|
||||
|
||||
export const normalizePersistedQueueState = (queues: Queue[]) => {
|
||||
const validQueues = queues.filter(queue =>
|
||||
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
|
||||
);
|
||||
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|
||||
|| validQueues.find(queue => queue.isMain);
|
||||
const persistedMainId = persistedMain?.id.trim();
|
||||
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
|
||||
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }];
|
||||
const seenIds = new Set([MAIN_QUEUE_ID]);
|
||||
const seenNames = new Set([queueNameKey(mainName)]);
|
||||
const queueIdRemap = new Map<string, string>();
|
||||
if (persistedMainId && persistedMainId !== MAIN_QUEUE_ID) {
|
||||
queueIdRemap.set(persistedMainId, MAIN_QUEUE_ID);
|
||||
}
|
||||
|
||||
for (const queue of validQueues) {
|
||||
const id = queue.id.trim();
|
||||
if (!id || id === MAIN_QUEUE_ID || id === persistedMainId || seenIds.has(id)) continue;
|
||||
if (queue.isMain) {
|
||||
queueIdRemap.set(id, MAIN_QUEUE_ID);
|
||||
continue;
|
||||
}
|
||||
let name = queue.name.trim() || `Queue ${id.slice(0, 8)}`;
|
||||
const baseName = name;
|
||||
let suffix = 2;
|
||||
while (seenNames.has(queueNameKey(name))) {
|
||||
name = `${baseName} (${suffix})`;
|
||||
suffix += 1;
|
||||
}
|
||||
seenIds.add(id);
|
||||
seenNames.add(queueNameKey(name));
|
||||
normalized.push({ id, name, isMain: false });
|
||||
}
|
||||
|
||||
return { queues: normalized, queueIdRemap };
|
||||
};
|
||||
|
||||
export const normalizePersistedQueues = (queues: Queue[]): Queue[] =>
|
||||
normalizePersistedQueueState(queues).queues;
|
||||
|
||||
export type { DownloadItem, Queue };
|
||||
export type ExtensionDownloadRequest = ExtensionDownload;
|
||||
@@ -461,8 +505,8 @@ interface DownloadState {
|
||||
startAll: () => Promise<number>;
|
||||
pauseAll: () => Promise<number>;
|
||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||
addQueue: (name: string) => void;
|
||||
renameQueue: (id: string, name: string) => void;
|
||||
addQueue: (name: string) => boolean;
|
||||
renameQueue: (id: string, name: string) => boolean;
|
||||
removeQueue: (id: string) => Promise<void>;
|
||||
resumePendingDownloads: () => Promise<void>;
|
||||
initDB: () => Promise<void>;
|
||||
@@ -612,13 +656,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
||||
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
|
||||
const existingMediaUrls = isAppending ? state.pendingAddMediaUrls : [];
|
||||
const pendingAddMediaUrls = media
|
||||
? [...new Set([
|
||||
...existingMediaUrls,
|
||||
...urls.split('\n').map(url => url.trim()).filter(Boolean)
|
||||
])]
|
||||
: existingMediaUrls;
|
||||
const cleanReferer = referer?.trim() || '';
|
||||
const cleanFilename = filename?.trim() || '';
|
||||
const cleanHeaders = headers?.trim() || '';
|
||||
@@ -649,6 +686,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
media
|
||||
};
|
||||
}
|
||||
const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts)
|
||||
.filter(([, context]) => context.media)
|
||||
.map(([url]) => url);
|
||||
return {
|
||||
isAddModalOpen: true,
|
||||
pendingAddUrls: mergedUrls,
|
||||
@@ -1103,22 +1143,37 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
addQueue: (name) => {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) return false;
|
||||
const duplicate = get().queues.some(queue =>
|
||||
queueNameKey(queue.name) === queueNameKey(normalizedName)
|
||||
);
|
||||
if (duplicate) return false;
|
||||
const id = crypto.randomUUID();
|
||||
const q = { id, name, isMain: false };
|
||||
const q = { id, name: normalizedName, isMain: false };
|
||||
set((state) => ({
|
||||
queues: [...state.queues, q]
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
renameQueue: (id, name) => {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) return false;
|
||||
const duplicate = get().queues.some(queue =>
|
||||
queue.id !== id
|
||||
&& queueNameKey(queue.name) === queueNameKey(normalizedName)
|
||||
);
|
||||
if (duplicate || !get().queues.some(queue => queue.id === id)) return false;
|
||||
set((state) => ({
|
||||
queues: state.queues.map(q => {
|
||||
if (q.id === id) {
|
||||
const newQ = { ...q, name };
|
||||
const newQ = { ...q, name: normalizedName };
|
||||
return newQ;
|
||||
}
|
||||
return q;
|
||||
})
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
removeQueue: async (id) => {
|
||||
if (id === MAIN_QUEUE_ID) return;
|
||||
@@ -1290,13 +1345,28 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
const queues = (await invoke('db_get_all_queues')).map(value => JSON.parse(value) as Queue);
|
||||
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
|
||||
try {
|
||||
return [JSON.parse(value) as Queue];
|
||||
} catch {
|
||||
console.warn('Skipping malformed persisted queue record during startup');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const normalizedQueueState = normalizePersistedQueueState(persistedQueues);
|
||||
const queues = normalizedQueueState.queues;
|
||||
const knownQueueIds = new Set(queues.map(queue => queue.id));
|
||||
const downloads = (await invoke('db_get_all_downloads')).map(
|
||||
value => JSON.parse(value) as DownloadItem
|
||||
);
|
||||
).map(download => {
|
||||
const persistedQueueId = download.queueId || MAIN_QUEUE_ID;
|
||||
const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId)
|
||||
|| (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID);
|
||||
return { ...download, queueId };
|
||||
});
|
||||
|
||||
set(state => ({
|
||||
queues: queues.length > 0 ? queues : state.queues,
|
||||
queues,
|
||||
downloads: downloads.length > 0
|
||||
? normalizeQueuePositions(downloads)
|
||||
: state.downloads
|
||||
|
||||
@@ -443,6 +443,9 @@ describe('add download metadata workflow', () => {
|
||||
row(),
|
||||
row({ id: 'fallback', status: 'metadata-error' })
|
||||
])).toBe(true);
|
||||
expect(canSubmitMetadataRows([
|
||||
row({ id: 'unsafe', status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toBe(false);
|
||||
expect(canSubmitMetadataRows([
|
||||
row(),
|
||||
row({ id: 'media-fallback', status: 'metadata-error', isMedia: true })
|
||||
@@ -524,6 +527,9 @@ describe('add download metadata workflow', () => {
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error' })
|
||||
])).toContain('can still be added');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', metadataBlockedReason: 'unsafe-url' })
|
||||
])).toContain('unsafe URL');
|
||||
expect(metadataSummaryMessage([
|
||||
row({ status: 'metadata-error', isMedia: true })
|
||||
])).toContain('Refresh metadata before adding');
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface AddDownloadDraftRow {
|
||||
playlistCount?: number;
|
||||
playlistEntryTitle?: string;
|
||||
playlistError?: string;
|
||||
metadataBlockedReason?: 'unsafe-url';
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
@@ -215,7 +216,8 @@ export const reconcileDownloadRows = (
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
@@ -249,6 +251,7 @@ export const reconcileDownloadRows = (
|
||||
playlistIndex: input.playlistIndex,
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
metadataBlockedReason: undefined,
|
||||
selected: input.selected !== false
|
||||
};
|
||||
});
|
||||
@@ -302,7 +305,8 @@ export const refreshFailedMetadataRows = (
|
||||
? {
|
||||
...row,
|
||||
status: 'loading',
|
||||
generation: row.generation + 1
|
||||
generation: row.generation + 1,
|
||||
metadataBlockedReason: undefined
|
||||
}
|
||||
: row
|
||||
);
|
||||
@@ -312,7 +316,7 @@ export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => {
|
||||
return selectedRows.length > 0
|
||||
&& selectedRows.every(row =>
|
||||
row.status === 'ready'
|
||||
|| (!row.isMedia && row.status === 'metadata-error')
|
||||
|| (!row.isMedia && row.status === 'metadata-error' && !row.metadataBlockedReason)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -375,7 +379,11 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||
|
||||
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||
if (blocked > 0) {
|
||||
return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`;
|
||||
}
|
||||
if (failedMedia > 0) {
|
||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user