From c2868c8ac8b422da234d411f6c5d9425387d47ca Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 8 Sep 2026 04:00:29 +0200 Subject: [PATCH] fix(downloads): recover local archives before remote transfer checks --- .../events/database/download-requests.spec.ts | 10 ++++++++++ .../app/events/database/download-requests.ts | 19 ++++++++++++------- docs/architecture/download-manager.md | 4 +++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 653bad146..f10e253a9 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -1174,6 +1174,7 @@ it.each([ channelName: 'News', startTimestamp: 100, stopTimestamp: 200, + expiresAt: 201, }; jest.doMock('./download-catchup-journal', () => ({ ...jest.requireActual('./download-catchup-journal'), @@ -1203,6 +1204,13 @@ it.each([ programmeStart: 100, }) ); + const { assertRemoteUrlAllowed } = await import('../url-safety'); + jest.mocked(assertRemoteUrlAllowed).mockRejectedValue( + new Error('ENOTFOUND') + ); + jest.mocked(h.authorizer.requireAuthorized).mockRejectedValue( + new Error('Folder unavailable') + ); const result = action === 'start' ? h.startDownloadRequest( @@ -1240,6 +1248,8 @@ it.each([ expect.objectContaining({ filePath: null }) ); expect(h.enqueueDownload).not.toHaveBeenCalled(); + expect(assertRemoteUrlAllowed).not.toHaveBeenCalled(); + expect(h.authorizer.requireAuthorized).not.toHaveBeenCalled(); expect(await readFile(filePath, 'utf8')).toBe('complete archive'); } finally { jest.dontMock('./download-catchup-journal'); diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 5a543ddf4..c5b968c5c 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -31,7 +31,6 @@ export async function startDownloadRequest( data: StartDownloadRequest, authorizer: DownloadDirectoryAuthorizer ): Promise { - const catchup = catchupForDownload(data); const encodedMetadataSnapshot = data.metadataSnapshot === undefined ? undefined @@ -47,8 +46,6 @@ export async function startDownloadRequest( throw new Error('Invalid download metadata snapshot'); } console.log('[Downloads] Enqueue download:', data.title); - const directory = await authorizer.requireAuthorized(data.downloadFolder); - await assertRemoteUrlAllowed(data.url, { allowPrivateNetworks: true }); const db = await getDatabase(); if (!data.playlistId) { @@ -67,10 +64,6 @@ export async function startDownloadRequest( success: false, }; } - const fileName = catchup - ? sanitizeFilename(data.title) + '.ts' - : createFileName(data.title, data.url); - const headers = createHeaders(data.headers); if (identity.kind === 'match') { const item = identity.item; @@ -132,7 +125,19 @@ export async function startDownloadRequest( reason: ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyDownloaded, }; } + } + // These checks authorize another remote transfer, not reuse of a proven file. + const catchup = catchupForDownload(data); + const directory = await authorizer.requireAuthorized(data.downloadFolder); + await assertRemoteUrlAllowed(data.url, { allowPrivateNetworks: true }); + const fileName = catchup + ? sanitizeFilename(data.title) + '.ts' + : createFileName(data.title, data.url); + const headers = createHeaders(data.headers); + + if (identity.kind === 'match') { + const item = identity.item; if ( ['completed', 'failed', 'canceled'].includes(item.status) && item.filePath diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index c02ee50f7..eaef239a9 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -95,7 +95,9 @@ journal cleanup before row deletion. Remove/Clear preserve a final file whose journaled identity and full size prove completed promotion, even when completion status writes failed and its stored status is stale. Repeat submissions, Retry and Resume restore such a journal-proven completion in place before any new -transfer or ownership reset; retained cleanup failures keep their journal. Remove, Clear completed and missing-file +transfer or ownership reset, before expiry, provider DNS and new-folder checks +that apply only to another remote transfer; retained cleanup failures keep their +journal. Remove, Clear completed and missing-file re-download and repeated programme submissions use journal-backed private capture for archive partial cleanup; unknown or replaced entries are preserved. Before capture, a synchronous SQLite