mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-12 12:37:04 +00:00
fix(downloads): harden add flow and URL validation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user