Files
iptvnator/apps/electron-backend/src/app/events/database/download-runtime.ts
T
Salem 2c032cd3c8 fix(security): complete Electron hardening and review follow-ups
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks

S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts.
S2: write-file IPC restricted to save-dialog-authorized paths.
S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests).
S4: EPG titles rendered via interpolation, not [innerHTML].
S5: downloads reveal/play limited to recorded download paths.
S6: Stalker cmd encoded (slash-preserving) to block query injection.
EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed.

* perf(player): lazy-load web video players via @defer

Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js,
artplayer and mpegts.js split into a deferred chunk loaded on first playback
instead of eagerly on the player route. Embedded MPV (native) stays eager.
Spec uses DeferBlockBehavior.Playthrough.

* fix(player): remove leaked HTML video listeners on destroy

volumechange used a mismatched removeEventListener reference, while
loadedmetadata and timeupdate were never removed at all. Bind all three to
stable handler fields used for both add and remove, and add a teardown
regression test asserting each listener is detached on destroy.

* refactor(dashboard): extract pure navigation helpers from DashboardDataService

Move the 8 stateless link/navigation-state/type-kind helpers into a new
dashboard-navigation.util.ts so the routing logic is independently testable and
the 1260-line god-service shrinks. DashboardDataService keeps the public methods
as thin delegators (facade) so the public API and the single consumer
(workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService
decomposition; verified by the existing service spec (33/33) and the app typecheck.

* fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder)

- url-safety: broaden IPv6 link-local detection to the full fe80::/10 range
  (fe80:: through febf::), not just the fe80:: prefix (+ regression tests).
- playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save
  dialog opened without a following write cannot accumulate entries until restart.
- web-player-view: add a @placeholder to each @defer (on immediate) player block
  to avoid the one-frame blank/layout-shift before the chunk resolves.

* fix(security): close Electron network and download gaps

* test(downloads): cover cancellation and restart cleanup

* fix(downloads): address Greptile review gaps

* test(security): reproduce remaining Greptile findings

* fix(security): close remaining Greptile findings

* test(downloads): reproduce early database queue stall

* fix(downloads): release queue after setup failures

* test(downloads): reproduce completion queue stall

* fix(downloads): release queue after completion failures
2026-06-12 15:24:29 +02:00

296 lines
9.1 KiB
TypeScript

import { eq, sql } from 'drizzle-orm';
import type { BrowserWindow } from 'electron';
import { CancelError, download } from 'electron-dl';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import {
removePartialDownload,
reserveAvailableDownloadFile,
} from './download-file-path';
import {
attachDownloadItem,
requestDownloadCancellation,
type DownloadTask,
} from './download-task';
const downloadQueue: DownloadTask[] = [];
let activeDownload: DownloadTask | null = null;
let mainWindow: BrowserWindow | null = null;
export function setMainWindow(win: BrowserWindow): void {
mainWindow = win;
}
export function broadcastDownloadUpdate(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
}
}
export function enqueueDownload(task: DownloadTask): void {
downloadQueue.push(task);
broadcastDownloadUpdate();
void processQueue();
}
export async function cancelDownload(downloadId: number): Promise<boolean> {
if (activeDownload?.id === downloadId) {
requestDownloadCancellation(activeDownload);
return true;
}
const queueIndex = downloadQueue.findIndex(
(task) => task.id === downloadId
);
if (queueIndex === -1) {
return false;
}
downloadQueue.splice(queueIndex, 1);
const db = await getDatabase();
await db
.update(schema.downloads)
.set({
errorMessage: null,
filePath: null,
status: 'canceled',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
broadcastDownloadUpdate();
return true;
}
export function removeDownloadFromRuntime(downloadId: number): void {
if (activeDownload?.id === downloadId) {
requestDownloadCancellation(activeDownload);
}
const queueIndex = downloadQueue.findIndex(
(task) => task.id === downloadId
);
if (queueIndex !== -1) {
downloadQueue.splice(queueIndex, 1);
}
}
async function processQueue(): Promise<void> {
if (activeDownload || downloadQueue.length === 0) {
return;
}
const task = downloadQueue.shift();
if (!task) {
return;
}
activeDownload = task;
try {
await startDownload(task);
} catch (error) {
console.error(
`[Downloads] Unhandled error for ${task.fileName}:`,
error
);
finishTask(task);
}
}
function finishTask(task: DownloadTask): void {
if (activeDownload === task) {
activeDownload = null;
}
broadcastDownloadUpdate();
void processQueue();
}
function createCancellationHandler(
task: DownloadTask,
db: Awaited<ReturnType<typeof getDatabase>>,
reservation: ReturnType<typeof reserveAvailableDownloadFile>
): (item: Parameters<typeof removePartialDownload>[0]) => Promise<void> {
let cancellationPromise: Promise<void> | undefined;
return (item) => {
cancellationPromise ??= (async () => {
console.log(`[Downloads] Canceled: ${reservation.filename}`);
removePartialFile(item);
try {
await db
.update(schema.downloads)
.set({
errorMessage: null,
filePath: null,
status: 'canceled',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error(
'[Downloads] Failed to persist cancellation:',
error
);
} finally {
finishTask(task);
}
})();
return cancellationPromise;
};
}
async function startDownload(task: DownloadTask): Promise<void> {
const db = await getDatabase();
await db
.update(schema.downloads)
.set({
errorMessage: null,
filePath: null,
status: 'downloading',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastDownloadUpdate();
if (!mainWindow || mainWindow.isDestroyed()) {
console.error('[Downloads] No main window available');
await db
.update(schema.downloads)
.set({
errorMessage: 'No window available for download',
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
finishTask(task);
return;
}
let lastProgressUpdate = 0;
const progressThrottleMs = 500;
let handleCancellation:
| ReturnType<typeof createCancellationHandler>
| undefined;
try {
const reservation = reserveAvailableDownloadFile(
task.directory,
task.fileName
);
task.reservedPath = reservation.path;
handleCancellation = createCancellationHandler(task, db, reservation);
await db
.update(schema.downloads)
.set({
errorMessage: null,
fileName: reservation.filename,
filePath: reservation.path,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
const downloadOptions: Parameters<typeof download>[2] = {
directory: task.directory,
filename: reservation.filename,
onStarted: (item) => {
console.log(`[Downloads] Started: ${reservation.filename}`);
attachDownloadItem(task, item);
},
onProgress: async (progress) => {
const now = Date.now();
if (now - lastProgressUpdate < progressThrottleMs) {
return;
}
lastProgressUpdate = now;
await db
.update(schema.downloads)
.set({
bytesDownloaded: progress.transferredBytes,
totalBytes: progress.totalBytes,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastDownloadUpdate();
},
onCompleted: async (file) => {
console.log(`[Downloads] Completed: ${file.filename}`);
try {
await db
.update(schema.downloads)
.set({
bytesDownloaded: file.fileSize,
errorMessage: null,
fileName: file.filename,
filePath: file.path,
status: 'completed',
totalBytes: file.fileSize,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error(
'[Downloads] Failed to persist completion:',
error
);
} finally {
finishTask(task);
}
},
onCancel: handleCancellation,
};
if (task.headers) {
(
downloadOptions as typeof downloadOptions & {
headers: Record<string, string>;
}
).headers = task.headers;
}
await download(mainWindow, task.url, downloadOptions);
} catch (error) {
if (error instanceof CancelError) {
const reservedPath = task.reservedPath;
if (handleCancellation) {
await handleCancellation(
task.downloadItem ??
(reservedPath
? { getSavePath: () => reservedPath }
: undefined)
);
} else {
finishTask(task);
}
return;
}
console.error(`[Downloads] Error downloading ${task.fileName}:`, error);
const reservedPath = task.reservedPath;
removePartialFile(
task.downloadItem ??
(reservedPath ? { getSavePath: () => reservedPath } : undefined)
);
await db
.update(schema.downloads)
.set({
errorMessage:
error instanceof Error ? error.message : String(error),
filePath: null,
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
finishTask(task);
}
}
function removePartialFile(
item: Parameters<typeof removePartialDownload>[0]
): void {
try {
removePartialDownload(item);
} catch (error) {
console.error('[Downloads] Failed to delete partial file:', error);
}
}