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
+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);
}