fix(downloads): harden add flow and URL validation

This commit is contained in:
NimBold
2026-07-16 22:20:12 +03:30
parent 79ce0c18a1
commit 469faed7b9
8 changed files with 375 additions and 80 deletions
+98 -33
View File
@@ -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(&current_url).await?;
let parsed_current_url = reqwest::Url::parse(&current_url)
.map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
let resolved_addr = match parsed_current_url.scheme() {
"http" | "https" => validate_url_ssrf(&current_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();
+27 -10
View File
@@ -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">
+39 -15
View File
@@ -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);
}
};
+26 -4
View File
@@ -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);
}
+84 -1
View File
@@ -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');
+84 -14
View File
@@ -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
+6
View File
@@ -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');
+11 -3
View File
@@ -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.`;
}