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