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
This commit is contained in:
Salem authored and GitHub committed 2026-06-12 15:24:29 +02:00
1 parent 7775553a6b
commit 2c032cd3c8
44 files changed
+3646 -1124

No files matched your search

@@ -0,0 +1,76 @@
import { resolve } from 'node:path';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
describe('DownloadDirectoryAuthorizer', () => {
it('returns a previously authorized native-dialog selection', async () => {
const authorizer = new DownloadDirectoryAuthorizer({
getDefaultDirectory: () => '/downloads',
loadSelectedDirectory: async () => '/media/iptv',
saveSelectedDirectory: jest.fn(),
});
await expect(authorizer.getPreferredDirectory()).resolves.toBe(
resolve('/media/iptv')
);
await expect(authorizer.requireAuthorized('/media/iptv')).resolves.toBe(
resolve('/media/iptv')
);
});
it('rejects a renderer-supplied directory that was never authorized', async () => {
const authorizer = new DownloadDirectoryAuthorizer({
getDefaultDirectory: () => '/downloads',
loadSelectedDirectory: async () => null,
saveSelectedDirectory: jest.fn(),
});
await expect(
authorizer.requireAuthorized('/tmp/renderer-controlled')
).rejects.toThrow(/not authorized/i);
});
it('persists and authorizes a directory selected by the native dialog', async () => {
const saveSelectedDirectory = jest.fn().mockResolvedValue(undefined);
const authorizer = new DownloadDirectoryAuthorizer({
getDefaultDirectory: () => '/downloads',
loadSelectedDirectory: async () => null,
saveSelectedDirectory,
});
await expect(
authorizer.authorizeSelectedDirectory('/media/iptv')
).resolves.toBe(resolve('/media/iptv'));
expect(saveSelectedDirectory).toHaveBeenCalledWith(
resolve('/media/iptv')
);
await expect(authorizer.requireAuthorized('/media/iptv')).resolves.toBe(
resolve('/media/iptv')
);
});
it('matches authorized paths case-insensitively on Windows', async () => {
const authorizer = new DownloadDirectoryAuthorizer({
getDefaultDirectory: () => 'C:\\Downloads',
loadSelectedDirectory: async () => 'C:\\Media\\IPTV',
saveSelectedDirectory: jest.fn(),
platform: 'win32',
});
await expect(
authorizer.requireAuthorized('c:\\media\\iptv')
).resolves.toBe(resolve('c:\\media\\iptv'));
});
it('keeps authorized path matching case-sensitive on POSIX', async () => {
const authorizer = new DownloadDirectoryAuthorizer({
getDefaultDirectory: () => '/downloads',
loadSelectedDirectory: async () => '/media/IPTV',
saveSelectedDirectory: jest.fn(),
platform: 'linux',
});
await expect(
authorizer.requireAuthorized('/media/iptv')
).rejects.toThrow(/not authorized/i);
});
});
@@ -0,0 +1,80 @@
import { resolve } from 'node:path';
export interface DownloadDirectoryAuthorizerOptions {
getDefaultDirectory: () => string;
loadSelectedDirectory: () => Promise<string | null>;
saveSelectedDirectory: (directory: string) => Promise<void>;
platform?: NodeJS.Platform;
}
function normalizeDirectory(directory: string): string {
const value = String(directory ?? '').trim();
if (!value) {
throw new Error('Download directory is required');
}
return resolve(value);
}
function directoryKey(directory: string, platform: NodeJS.Platform): string {
const normalized = normalizeDirectory(directory);
return platform === 'win32' ? normalized.toLowerCase() : normalized;
}
/**
* Keeps download-directory authorization in the Electron main process.
* Only the OS default or a directory persisted after a native dialog may be
* used by renderer-triggered download IPC calls.
*/
export class DownloadDirectoryAuthorizer {
private selectedDirectory: string | null | undefined;
constructor(private readonly options: DownloadDirectoryAuthorizerOptions) {}
private async loadSelectedDirectory(): Promise<string | null> {
if (this.selectedDirectory !== undefined) {
return this.selectedDirectory;
}
const storedDirectory = await this.options.loadSelectedDirectory();
this.selectedDirectory = storedDirectory
? normalizeDirectory(storedDirectory)
: null;
return this.selectedDirectory;
}
async getPreferredDirectory(): Promise<string> {
return (
(await this.loadSelectedDirectory()) ??
normalizeDirectory(this.options.getDefaultDirectory())
);
}
async authorizeSelectedDirectory(directory: string): Promise<string> {
const normalized = normalizeDirectory(directory);
await this.options.saveSelectedDirectory(normalized);
this.selectedDirectory = normalized;
return normalized;
}
async requireAuthorized(directory: string): Promise<string> {
const normalized = normalizeDirectory(directory);
const defaultDirectory = normalizeDirectory(
this.options.getDefaultDirectory()
);
const selectedDirectory = await this.loadSelectedDirectory();
const platform = this.options.platform ?? process.platform;
const requestedKey = directoryKey(normalized, platform);
if (
requestedKey !== directoryKey(defaultDirectory, platform) &&
(!selectedDirectory ||
requestedKey !== directoryKey(selectedDirectory, platform))
) {
throw new Error(
'Download directory was not authorized by a native folder dialog'
);
}
return normalized;
}
}
@@ -0,0 +1,90 @@
import { join } from 'node:path';
import {
removePartialDownload,
reserveAvailableDownloadFile,
} from './download-file-path';
describe('reserveAvailableDownloadFile', () => {
it('atomically reserves the requested filename when it is unused', () => {
const reserveFile = jest.fn();
expect(
reserveAvailableDownloadFile('/downloads', 'movie.mp4', reserveFile)
).toEqual({
filename: 'movie.mp4',
path: join('/downloads', 'movie.mp4'),
});
expect(reserveFile).toHaveBeenCalledWith(
join('/downloads', 'movie.mp4')
);
});
it('retries with a numbered filename after exclusive-create collisions', () => {
const reserveFile = jest.fn((filePath: string) => {
if (!filePath.endsWith('movie (2).mp4')) {
const error = new Error(
'already exists'
) as NodeJS.ErrnoException;
error.code = 'EEXIST';
throw error;
}
});
expect(
reserveAvailableDownloadFile('/downloads', 'movie.mp4', reserveFile)
).toEqual({
filename: 'movie (2).mp4',
path: join('/downloads', 'movie (2).mp4'),
});
expect(reserveFile).toHaveBeenCalledTimes(3);
});
it('does not hide non-collision filesystem errors', () => {
const error = new Error('permission denied') as NodeJS.ErrnoException;
error.code = 'EACCES';
expect(() =>
reserveAvailableDownloadFile('/downloads', 'movie.mp4', () => {
throw error;
})
).toThrow(error);
});
});
describe('removePartialDownload', () => {
it('removes only the actual partial save path', () => {
const requestedPath = join('/downloads', 'movie.mp4');
const partialPath = join('/downloads', 'movie (1).mp4');
const removeFile = jest.fn();
expect(
removePartialDownload(
{ getSavePath: () => partialPath },
(filePath) => filePath === partialPath,
removeFile
)
).toBe(true);
expect(removeFile).toHaveBeenCalledWith(partialPath);
expect(removeFile).not.toHaveBeenCalledWith(requestedPath);
});
it('does not remove a missing or unavailable partial path', () => {
const removeFile = jest.fn();
expect(
removePartialDownload(
{ getSavePath: () => '' },
() => true,
removeFile
)
).toBe(false);
expect(
removePartialDownload(
{ getSavePath: () => '/downloads/missing.mp4' },
() => false,
removeFile
)
).toBe(false);
expect(removeFile).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,57 @@
import { closeSync, existsSync, openSync, unlinkSync } from 'node:fs';
import { extname, join } from 'node:path';
export interface ReservedDownloadFile {
filename: string;
path: string;
}
function createExclusiveFile(filePath: string): void {
const descriptor = openSync(filePath, 'wx');
closeSync(descriptor);
}
export function reserveAvailableDownloadFile(
directory: string,
requestedFilename: string,
reserveFile: (filePath: string) => void = createExclusiveFile
): ReservedDownloadFile {
const extension = extname(requestedFilename);
const stem = extension
? requestedFilename.slice(0, -extension.length)
: requestedFilename;
let candidate = requestedFilename;
let suffix = 1;
for (;;) {
const filePath = join(directory, candidate);
try {
reserveFile(filePath);
return { filename: candidate, path: filePath };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
candidate = `${stem} (${suffix})${extension}`;
suffix += 1;
}
}
}
export interface DownloadSavePathItem {
getSavePath(): string;
}
export function removePartialDownload(
downloadItem: DownloadSavePathItem | undefined,
pathExists: (filePath: string) => boolean = existsSync,
removeFile: (filePath: string) => void = unlinkSync
): boolean {
const savePath = downloadItem?.getSavePath();
if (!savePath || !pathExists(savePath)) {
return false;
}
removeFile(savePath);
return true;
}
@@ -0,0 +1,48 @@
import { cleanupStaleDownloadFiles } from './stale-download-files';
describe('cleanupStaleDownloadFiles', () => {
it('removes every persisted partial path and ignores empty paths', () => {
const removeFile = jest.fn();
cleanupStaleDownloadFiles(
[
{ filePath: '/downloads/one.mp4' },
{ filePath: null },
{ filePath: '/downloads/two.mp4' },
],
removeFile
);
expect(removeFile).toHaveBeenCalledTimes(2);
expect(removeFile).toHaveBeenNthCalledWith(1, '/downloads/one.mp4');
expect(removeFile).toHaveBeenNthCalledWith(2, '/downloads/two.mp4');
});
it('continues cleaning other stale files after one removal fails', () => {
const removeFile = jest.fn((filePath: string) => {
if (filePath.endsWith('one.mp4')) {
throw new Error('locked');
}
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
cleanupStaleDownloadFiles(
[
{ filePath: '/downloads/one.mp4' },
{ filePath: '/downloads/two.mp4' },
],
removeFile
);
expect(removeFile).toHaveBeenCalledTimes(2);
expect(consoleError).toHaveBeenCalledWith(
'[Downloads] Failed to delete stale partial file:',
'/downloads/one.mp4',
expect.any(Error)
);
consoleError.mockRestore();
});
});
@@ -0,0 +1,45 @@
import { inArray, sql } from 'drizzle-orm';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { cleanupStaleDownloadFiles } from './stale-download-files';
export async function resetStaleDownloads(): Promise<void> {
try {
const db = await getDatabase();
const staleDownloads = await db
.select({
filePath: schema.downloads.filePath,
id: schema.downloads.id,
})
.from(schema.downloads)
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
const ownedReservations = staleDownloads.filter(
(item) => item.filePath
);
cleanupStaleDownloadFiles(ownedReservations);
await db
.update(schema.downloads)
.set({
errorMessage: 'Download interrupted by application restart',
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
if (ownedReservations.length > 0) {
await db
.update(schema.downloads)
.set({ filePath: null })
.where(
inArray(
schema.downloads.id,
ownedReservations.map((item) => item.id)
)
);
}
console.log('[Downloads] Reset stale downloads');
} catch (error) {
console.error('[Downloads] Error resetting stale downloads:', error);
}
}
@@ -0,0 +1,206 @@
import { and, eq, sql } from 'drizzle-orm';
import { extname } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import { enqueueDownload } from './download-runtime';
export interface StartDownloadRequest {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
downloadFolder: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}
function getExtensionFromUrl(url: string): string {
try {
return extname(new URL(url).pathname) || '.mp4';
} catch {
return '.mp4';
}
}
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
function createFileName(title: string, url: string): string {
return sanitizeFilename(title) + getExtensionFromUrl(url);
}
function createHeaders(
headers: StartDownloadRequest['headers']
): Record<string, string> | undefined {
return headers
? {
'User-Agent': headers.userAgent || '',
Origin: headers.origin || '',
Referer: headers.referer || '',
}
: undefined;
}
export async function startDownloadRequest(
data: StartDownloadRequest,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string; id?: number }> {
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) {
throw new Error('playlistId is required for downloads');
}
const existingPlaylist = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, data.playlistId))
.limit(1);
if (existingPlaylist.length === 0) {
console.log(
'[Downloads] Creating playlist entry for:',
data.playlistId
);
await db.insert(schema.playlists).values({
id: data.playlistId,
macAddress: data.macAddress,
name: data.playlistName || 'Unknown Playlist',
serverUrl: data.serverUrl,
type: data.playlistType || 'stalker',
url: data.portalUrl,
});
}
const existing = await db
.select()
.from(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, data.playlistId),
eq(schema.downloads.xtreamId, data.xtreamId),
eq(schema.downloads.contentType, data.contentType)
)
)
.limit(1);
const fileName = createFileName(data.title, data.url);
if (existing.length > 0) {
const item = existing[0];
if (!['completed', 'failed', 'canceled'].includes(item.status)) {
return {
error: 'Download already in progress',
id: item.id,
success: false,
};
}
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
fileName,
filePath: null,
status: 'queued',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
url: data.url,
})
.where(eq(schema.downloads.id, item.id));
enqueueDownload({
directory,
fileName,
headers: createHeaders(data.headers),
id: item.id,
url: data.url,
});
return { id: item.id, success: true };
}
const result = await db.insert(schema.downloads).values({
contentType: data.contentType,
episodeNumber: data.episodeNumber,
fileName,
playlistId: data.playlistId,
posterUrl: data.posterUrl,
seasonNumber: data.seasonNumber,
seriesXtreamId: data.seriesXtreamId,
status: 'queued',
title: data.title,
url: data.url,
xtreamId: data.xtreamId,
});
const insertedId = Number(result.lastInsertRowid);
enqueueDownload({
directory,
fileName,
headers: createHeaders(data.headers),
id: insertedId,
url: data.url,
});
return { id: insertedId, success: true };
}
export async function retryDownloadRequest(
downloadId: number,
downloadFolder: string,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string }> {
console.log('[Downloads] Retry download:', downloadId);
const directory = await authorizer.requireAuthorized(downloadFolder);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { error: 'Download not found', success: false };
}
const item = existing[0];
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
if (!['failed', 'canceled'].includes(item.status)) {
return {
error: 'Can only retry failed or canceled downloads',
success: false,
};
}
const fileName = createFileName(item.title, item.url);
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
fileName,
filePath: null,
status: 'queued',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
enqueueDownload({
directory,
fileName,
id: item.id,
url: item.url,
});
return { success: true };
}
@@ -0,0 +1,326 @@
import type { DownloadItem } from 'electron';
import {
attachDownloadItem,
requestDownloadCancellation,
type DownloadTask,
} from './download-task';
function createTask(): DownloadTask {
return {
directory: '/downloads',
fileName: 'movie.mp4',
id: 42,
url: 'https://example.test/movie.mp4',
};
}
async function waitForCallCount(
mock: jest.Mock,
expectedCallCount: number
): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
if (mock.mock.calls.length === expectedCallCount) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(mock).toHaveBeenCalledTimes(expectedCallCount);
}
describe('download runtime cancellation', () => {
it('cancels the item when an earlier cancellation request reaches onStarted', () => {
const task = createTask();
const cancel = jest.fn();
requestDownloadCancellation(task);
attachDownloadItem(task, { cancel } as unknown as DownloadItem);
expect(task.cancelRequested).toBe(true);
expect(cancel).toHaveBeenCalledTimes(1);
});
it('cancels an already-started item immediately', () => {
const cancel = jest.fn();
const task = {
...createTask(),
downloadItem: { cancel } as unknown as DownloadItem,
};
requestDownloadCancellation(task);
expect(task.cancelRequested).toBe(true);
expect(cancel).toHaveBeenCalledTimes(1);
});
it('continues the queue when cancellation persistence fails', async () => {
jest.resetModules();
class TestCancelError extends Error {}
let cancellationCallback: Promise<void> | undefined;
const download = jest
.fn()
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCancel: (item: DownloadItem) => Promise<void>;
}
) => {
cancellationCallback = options.onCancel({
getSavePath: () => '/downloads/movie.mp4',
} as unknown as DownloadItem);
throw new TestCancelError();
}
)
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
await options.onCompleted({
fileSize: 1,
filename: 'second.mp4',
path: '/downloads/second.mp4',
});
}
);
const where = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('database is busy'))
.mockResolvedValue(undefined);
const db = {
update: jest.fn(() => ({
set: jest.fn(() => ({ where })),
})),
};
jest.doMock('electron-dl', () => ({
CancelError: TestCancelError,
download,
}));
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownload: jest.fn(),
reserveAvailableDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
path: `${directory}/${filename}`,
})
),
}));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
runtime.enqueueDownload({
...createTask(),
fileName: 'second.mp4',
id: 43,
});
await waitForCallCount(download, 2);
await expect(cancellationCallback).resolves.toBeUndefined();
consoleError.mockRestore();
});
it('continues the queue when completion persistence fails', async () => {
jest.resetModules();
class TestCancelError extends Error {}
let completionCallback: Promise<void> | undefined;
const download = jest
.fn()
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
completionCallback = options.onCompleted({
fileSize: 1,
filename: 'movie.mp4',
path: '/downloads/movie.mp4',
});
}
)
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
await options.onCompleted({
fileSize: 1,
filename: 'second.mp4',
path: '/downloads/second.mp4',
});
}
);
const where = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('database is busy'))
.mockResolvedValue(undefined);
const db = {
update: jest.fn(() => ({
set: jest.fn(() => ({ where })),
})),
};
jest.doMock('electron-dl', () => ({
CancelError: TestCancelError,
download,
}));
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownload: jest.fn(),
reserveAvailableDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
path: `${directory}/${filename}`,
})
),
}));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
runtime.enqueueDownload({
...createTask(),
fileName: 'second.mp4',
id: 43,
});
await waitForCallCount(download, 2);
await expect(completionCallback).resolves.toBeUndefined();
} finally {
consoleError.mockRestore();
}
});
it('continues the queue when initial database access fails', async () => {
jest.resetModules();
class TestCancelError extends Error {}
const download = jest.fn(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
await options.onCompleted({
fileSize: 1,
filename: 'second.mp4',
path: '/downloads/second.mp4',
});
}
);
const where = jest.fn().mockResolvedValue(undefined);
const db = {
update: jest.fn(() => ({
set: jest.fn(() => ({ where })),
})),
};
const getDatabase = jest
.fn()
.mockRejectedValueOnce(new Error('database unavailable'))
.mockResolvedValue(db);
jest.doMock('electron-dl', () => ({
CancelError: TestCancelError,
download,
}));
jest.doMock('../../database/connection', () => ({ getDatabase }));
jest.doMock('./download-file-path', () => ({
removePartialDownload: jest.fn(),
reserveAvailableDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
path: `${directory}/${filename}`,
})
),
}));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
runtime.enqueueDownload({
...createTask(),
fileName: 'second.mp4',
id: 43,
});
await waitForCallCount(download, 1);
expect(download).toHaveBeenCalledWith(
expect.anything(),
'https://example.test/movie.mp4',
expect.objectContaining({ filename: 'second.mp4' })
);
} finally {
consoleError.mockRestore();
}
});
});
@@ -0,0 +1,295 @@
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);
}
}
@@ -0,0 +1,27 @@
import type { DownloadItem } from 'electron';
export interface DownloadTask {
id: number;
url: string;
fileName: string;
directory: string;
headers?: Record<string, string>;
cancelRequested?: boolean;
downloadItem?: DownloadItem;
reservedPath?: string;
}
export function attachDownloadItem(
task: DownloadTask,
item: DownloadItem
): void {
task.downloadItem = item;
if (task.cancelRequested) {
item.cancel();
}
}
export function requestDownloadCancellation(task: DownloadTask): void {
task.cancelRequested = true;
task.downloadItem?.cancel();
}
@@ -1,373 +1,90 @@
/**
* Downloads IPC event handlers
* Uses electron-dl for download management with queue control and progress tracking
*/
import { and, eq, inArray, sql } from 'drizzle-orm';
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
import { download, File as ElectronDlFile } from 'electron-dl';
import { existsSync, unlinkSync } from 'fs';
import { basename, extname, join } from 'path';
import { and, eq, inArray } from 'drizzle-orm';
import { app, dialog, ipcMain, shell } from 'electron';
import { existsSync } from 'node:fs';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import {
retryDownloadRequest,
startDownloadRequest,
type StartDownloadRequest,
} from './download-requests';
import { resetStaleDownloads } from './download-recovery';
import {
broadcastDownloadUpdate,
cancelDownload,
removeDownloadFromRuntime,
setMainWindow,
} from './download-runtime';
type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled';
interface DownloadTask {
id: number;
url: string;
fileName: string;
directory: string;
headers?: Record<string, string>;
downloadItem?: ElectronDlFile;
function getDownloadAuthorizationPath(): string {
return join(
app.getPath('userData'),
'download-directory-authorization.json'
);
}
// Download queue management
const downloadQueue: DownloadTask[] = [];
let activeDownload: DownloadTask | null = null;
let mainWindow: BrowserWindow | null = null;
/**
* Set the main window reference for sending updates
*/
export function setMainWindow(win: BrowserWindow) {
mainWindow = win;
}
/**
* Broadcast download updates to renderer
*/
function broadcastUpdate() {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
}
}
/**
* Get file extension from URL
*/
function getExtensionFromUrl(url: string): string {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const ext = extname(pathname);
return ext || '.mp4';
} catch {
return '.mp4';
}
}
/**
* Sanitize filename for filesystem
*/
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
/**
* Process the download queue - starts next download if none active
*/
async function processQueue() {
if (activeDownload || downloadQueue.length === 0) {
return;
}
const task = downloadQueue.shift();
if (!task) return;
activeDownload = task;
await startDownload(task);
}
/**
* Start a download using electron-dl
*/
async function startDownload(task: DownloadTask) {
const db = await getDatabase();
// Update status to downloading
await db
.update(schema.downloads)
.set({
status: 'downloading',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastUpdate();
if (!mainWindow || mainWindow.isDestroyed()) {
console.error('[Downloads] No main window available');
await db
.update(schema.downloads)
.set({
status: 'failed',
errorMessage: 'No window available for download',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
return;
}
let lastProgressUpdate = 0;
const PROGRESS_THROTTLE_MS = 500;
try {
const downloadOptions: Parameters<typeof download>[2] = {
directory: task.directory,
filename: task.fileName,
overwrite: true,
onStarted: (item) => {
console.log(`[Downloads] Started: ${task.fileName}`);
task.downloadItem = item as unknown as ElectronDlFile;
},
onProgress: async (progress) => {
const now = Date.now();
if (now - lastProgressUpdate < PROGRESS_THROTTLE_MS) {
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));
broadcastUpdate();
},
onCompleted: async (file) => {
console.log(`[Downloads] Completed: ${task.fileName}`);
await db
.update(schema.downloads)
.set({
status: 'completed',
filePath: file.path,
fileName: file.filename,
bytesDownloaded: file.fileSize,
totalBytes: file.fileSize,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
},
onCancel: async () => {
console.log(`[Downloads] Canceled: ${task.fileName}`);
// Delete partial file if it exists
const partialPath = join(task.directory, task.fileName);
if (existsSync(partialPath)) {
try {
unlinkSync(partialPath);
} catch (e) {
console.error('[Downloads] Failed to delete partial file:', e);
}
}
await db
.update(schema.downloads)
.set({
status: 'canceled',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
},
};
// Add headers if provided (user-agent, referer, origin)
if (task.headers) {
(downloadOptions as any).headers = task.headers;
}
await download(mainWindow, task.url, downloadOptions);
} catch (error) {
console.error(`[Downloads] Error downloading ${task.fileName}:`, error);
// Delete partial file if it exists
const partialPath = join(task.directory, task.fileName);
if (existsSync(partialPath)) {
try {
unlinkSync(partialPath);
} catch (e) {
console.error('[Downloads] Failed to delete partial file:', e);
const downloadDirectoryAuthorizer = new DownloadDirectoryAuthorizer({
getDefaultDirectory: () => app.getPath('downloads'),
loadSelectedDirectory: async () => {
try {
const stored = JSON.parse(
await readFile(getDownloadAuthorizationPath(), 'utf-8')
) as { directory?: unknown };
return typeof stored.directory === 'string'
? stored.directory
: null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(
'[Downloads] Ignoring invalid folder authorization:',
error
);
}
return null;
}
},
saveSelectedDirectory: async (directory) => {
const authorizationPath = getDownloadAuthorizationPath();
const temporaryPath = `${authorizationPath}.${process.pid}.tmp`;
await mkdir(app.getPath('userData'), { recursive: true });
await writeFile(
temporaryPath,
JSON.stringify({ directory, version: 1 }),
'utf-8'
);
await rename(temporaryPath, authorizationPath);
},
});
await db
.update(schema.downloads)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : String(error),
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
activeDownload = null;
broadcastUpdate();
processQueue();
async function isManagedDownloadFile(filePath: string): Promise<boolean> {
if (!filePath) {
return false;
}
try {
const db = await getDatabase();
const rows = await db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.filePath, filePath))
.limit(1);
return rows.length > 0;
} catch (error) {
console.error('Error verifying managed download path:', error);
return false;
}
}
/**
* Enqueue a new download
*/
ipcMain.handle(
'DOWNLOADS_START',
async (
_event,
data: {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
downloadFolder: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
// Playlist info for auto-creation if needed
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}
) => {
async (_event, data: StartDownloadRequest) => {
try {
console.log('[Downloads] Enqueue download:', data.title);
const db = await getDatabase();
// Ensure playlist exists in database (required for foreign key constraint)
if (data.playlistId) {
const existingPlaylist = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, data.playlistId))
.limit(1);
if (existingPlaylist.length === 0) {
// Create playlist entry for downloads to work
console.log('[Downloads] Creating playlist entry for:', data.playlistId);
await db.insert(schema.playlists).values({
id: data.playlistId,
name: data.playlistName || 'Unknown Playlist',
type: data.playlistType || 'stalker',
serverUrl: data.serverUrl,
macAddress: data.macAddress,
url: data.portalUrl,
});
}
} else {
throw new Error('playlistId is required for downloads');
}
// Check if already exists
const existing = await db
.select()
.from(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, data.playlistId),
eq(schema.downloads.xtreamId, data.xtreamId),
eq(schema.downloads.contentType, data.contentType)
)
)
.limit(1);
if (existing.length > 0) {
const item = existing[0];
// If completed or failed/canceled, allow retry by updating
if (['completed', 'failed', 'canceled'].includes(item.status)) {
await db
.update(schema.downloads)
.set({
status: 'queued',
url: data.url,
bytesDownloaded: 0,
totalBytes: null,
errorMessage: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, item.id));
const ext = getExtensionFromUrl(data.url);
const fileName = sanitizeFilename(data.title) + ext;
downloadQueue.push({
id: item.id,
url: data.url,
fileName,
directory: data.downloadFolder,
headers: data.headers
? {
'User-Agent': data.headers.userAgent || '',
Referer: data.headers.referer || '',
Origin: data.headers.origin || '',
}
: undefined,
});
broadcastUpdate();
processQueue();
return { success: true, id: item.id };
}
// Already queued or downloading
return { success: false, error: 'Download already in progress', id: item.id };
}
// Create new download entry
const ext = getExtensionFromUrl(data.url);
const fileName = sanitizeFilename(data.title) + ext;
const result = await db.insert(schema.downloads).values({
playlistId: data.playlistId,
xtreamId: data.xtreamId,
contentType: data.contentType,
title: data.title,
url: data.url,
posterUrl: data.posterUrl,
fileName,
status: 'queued',
seriesXtreamId: data.seriesXtreamId,
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
});
const insertedId = Number(result.lastInsertRowid);
downloadQueue.push({
id: insertedId,
url: data.url,
fileName,
directory: data.downloadFolder,
headers: data.headers
? {
'User-Agent': data.headers.userAgent || '',
Referer: data.headers.referer || '',
Origin: data.headers.origin || '',
}
: undefined,
});
broadcastUpdate();
processQueue();
return { success: true, id: insertedId };
return await startDownloadRequest(
data,
downloadDirectoryAuthorizer
);
} catch (error) {
console.error('[Downloads] Error enqueuing download:', error);
throw error;
@@ -375,95 +92,27 @@ ipcMain.handle(
}
);
/**
* Cancel a download
*/
ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Cancel download:', downloadId);
const db = await getDatabase();
// Check if it's the active download
if (activeDownload && activeDownload.id === downloadId) {
if (activeDownload.downloadItem) {
(activeDownload.downloadItem as any).cancel?.();
}
return { success: true };
}
// Remove from queue
const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId);
if (queueIndex !== -1) {
downloadQueue.splice(queueIndex, 1);
await db
.update(schema.downloads)
.set({
status: 'canceled',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
broadcastUpdate();
return { success: true };
}
return { success: false, error: 'Download not found in queue' };
return (await cancelDownload(downloadId))
? { success: true }
: { error: 'Download not found in queue', success: false };
} catch (error) {
console.error('[Downloads] Error canceling download:', error);
throw error;
}
});
/**
* Retry a failed/canceled download
*/
ipcMain.handle(
'DOWNLOADS_RETRY',
async (_event, downloadId: number, downloadFolder: string) => {
try {
console.log('[Downloads] Retry download:', downloadId);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { success: false, error: 'Download not found' };
}
const item = existing[0];
if (!['failed', 'canceled'].includes(item.status)) {
return { success: false, error: 'Can only retry failed or canceled downloads' };
}
await db
.update(schema.downloads)
.set({
status: 'queued',
bytesDownloaded: 0,
totalBytes: null,
errorMessage: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
const ext = getExtensionFromUrl(item.url);
const fileName = sanitizeFilename(item.title) + ext;
downloadQueue.push({
id: item.id,
url: item.url,
fileName,
directory: downloadFolder,
});
broadcastUpdate();
processQueue();
return { success: true };
return await retryDownloadRequest(
downloadId,
downloadFolder,
downloadDirectoryAuthorizer
);
} catch (error) {
console.error('[Downloads] Error retrying download:', error);
throw error;
@@ -471,31 +120,15 @@ ipcMain.handle(
}
);
/**
* Remove a download from the list
*/
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Remove download:', downloadId);
removeDownloadFromRuntime(downloadId);
const db = await getDatabase();
// Cancel if active
if (activeDownload && activeDownload.id === downloadId) {
if (activeDownload.downloadItem) {
(activeDownload.downloadItem as any).cancel?.();
}
}
// Remove from queue
const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId);
if (queueIndex !== -1) {
downloadQueue.splice(queueIndex, 1);
}
// Delete from database
await db.delete(schema.downloads).where(eq(schema.downloads.id, downloadId));
broadcastUpdate();
await db
.delete(schema.downloads)
.where(eq(schema.downloads.id, downloadId));
broadcastDownloadUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error removing download:', error);
@@ -503,36 +136,21 @@ ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
}
});
/**
* Get all downloads for a playlist
*/
ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
if (playlistId) {
const result = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId))
.orderBy(schema.downloads.createdAt);
return result;
}
const result = await db
.select()
.from(schema.downloads)
.orderBy(schema.downloads.createdAt);
return result;
const query = db.select().from(schema.downloads);
return playlistId
? query
.where(eq(schema.downloads.playlistId, playlistId))
.orderBy(schema.downloads.createdAt)
: query.orderBy(schema.downloads.createdAt);
} catch (error) {
console.error('[Downloads] Error getting download list:', error);
throw error;
}
});
/**
* Get download by ID
*/
ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => {
try {
const db = await getDatabase();
@@ -548,102 +166,67 @@ ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => {
}
});
/**
* Get default download folder
*/
ipcMain.handle('DOWNLOADS_GET_DEFAULT_FOLDER', async () => {
return app.getPath('downloads');
return downloadDirectoryAuthorizer.getPreferredDirectory();
});
/**
* Select download folder via dialog
*/
ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => {
const result = await dialog.showOpenDialog({
defaultPath: app.getPath('downloads'),
properties: ['openDirectory', 'createDirectory'],
title: 'Select Download Folder',
defaultPath: app.getPath('downloads'),
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
return downloadDirectoryAuthorizer.authorizeSelectedDirectory(
result.filePaths[0]
);
});
/**
* Reveal file in system file manager
*/
ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => {
if (existsSync(filePath)) {
shell.showItemInFolder(filePath);
return { success: true };
if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) {
return { error: 'File not found', success: false };
}
return { success: false, error: 'File not found' };
shell.showItemInFolder(filePath);
return { success: true };
});
/**
* Play downloaded file
*/
ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => {
if (existsSync(filePath)) {
await shell.openPath(filePath);
return { success: true };
if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) {
return { error: 'File not found', success: false };
}
return { success: false, error: 'File not found' };
await shell.openPath(filePath);
return { success: true };
});
/**
* Clear all completed downloads
*/
ipcMain.handle('DOWNLOADS_CLEAR_COMPLETED', async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
if (playlistId) {
ipcMain.handle(
'DOWNLOADS_CLEAR_COMPLETED',
async (_event, playlistId?: string) => {
try {
const db = await getDatabase();
const terminalStatus = inArray(schema.downloads.status, [
'completed',
'failed',
'canceled',
]);
await db
.delete(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, playlistId),
inArray(schema.downloads.status, ['completed', 'failed', 'canceled'])
)
);
} else {
await db
.delete(schema.downloads)
.where(
inArray(schema.downloads.status, ['completed', 'failed', 'canceled'])
playlistId
? and(
eq(schema.downloads.playlistId, playlistId),
terminalStatus
)
: terminalStatus
);
broadcastDownloadUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
throw error;
}
broadcastUpdate();
return { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
throw error;
}
});
);
/**
* Reset stale downloads on startup (downloading -> failed)
*/
export async function resetStaleDownloads() {
try {
const db = await getDatabase();
await db
.update(schema.downloads)
.set({
status: 'failed',
errorMessage: 'Download interrupted by application restart',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
inArray(schema.downloads.status, ['queued', 'downloading'])
);
console.log('[Downloads] Reset stale downloads');
} catch (error) {
console.error('[Downloads] Error resetting stale downloads:', error);
}
}
export { resetStaleDownloads, setMainWindow };
@@ -0,0 +1,29 @@
import { removePartialDownload } from './download-file-path';
interface StaleDownloadFile {
filePath: string | null;
}
function removePersistedPartial(filePath: string): void {
removePartialDownload({ getSavePath: () => filePath });
}
export function cleanupStaleDownloadFiles(
downloads: readonly StaleDownloadFile[],
removeFile: (filePath: string) => void = removePersistedPartial
): void {
for (const download of downloads) {
if (!download.filePath) {
continue;
}
try {
removeFile(download.filePath);
} catch (error) {
console.error(
'[Downloads] Failed to delete stale partial file:',
download.filePath,
error
);
}
}
}
@@ -0,0 +1,63 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
import {
createPlaylistObject,
getFilenameFromUrl,
} from '@iptvnator/shared/m3u-utils';
import { parse } from 'iptv-playlist-parser';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { createPlaylistAgentFactory } from '../util/secure-https';
import { requestWithValidatedRedirects } from '../util/validated-axios';
export async function fetchPlaylistFromUrl(
url: string,
title?: string
): Promise<Playlist> {
const result = await requestWithValidatedRedirects<string>(
url,
{
agentFactory: createPlaylistAgentFactory(),
method: 'GET',
},
{ allowPrivateNetworks: true }
);
const parsedPlaylist = parse(result.data);
const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
return createPlaylistObject(
title ?? playlistName,
parsedPlaylist,
url,
'URL'
);
}
export async function fetchPlaylistFromFile(
filePath: string,
title: string
): Promise<Playlist> {
const fileContent = await readFile(filePath, 'utf-8');
return createPlaylistObject(title, parse(fileContent), filePath, 'FILE');
}
export function derivePlaylistTitleFromFilePath(filePath: string): string {
const filename = basename(filePath);
return filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file';
}
export function preserveAutoUpdatedPlaylistFields(
playlistObject: Playlist,
playlist: Playlist
): Playlist {
return {
...playlistObject,
_id: playlist._id,
autoRefresh: playlist.autoRefresh,
favorites: playlist.favorites || [],
userAgent: playlist.userAgent,
};
}
@@ -0,0 +1,36 @@
import { resolve } from 'node:path';
const MAX_AUTHORIZED_WRITE_PATHS = 32;
export class PlaylistWriteAuthorizer {
private readonly pathsBySender = new Map<number, Set<string>>();
authorize(senderId: number, filePath: string): void {
const senderPaths =
this.pathsBySender.get(senderId) ?? new Set<string>();
senderPaths.add(resolve(filePath));
this.pathsBySender.set(senderId, senderPaths);
while (senderPaths.size > MAX_AUTHORIZED_WRITE_PATHS) {
const oldest = senderPaths.values().next().value;
if (oldest === undefined) {
break;
}
senderPaths.delete(oldest);
}
}
consume(senderId: number, filePath: string): string {
const normalizedPath = resolve(String(filePath ?? ''));
const senderPaths = this.pathsBySender.get(senderId);
if (!senderPaths?.delete(normalizedPath)) {
throw new Error(
'Refusing to write to a path not authorized by a save dialog'
);
}
if (senderPaths.size === 0) {
this.pathsBySender.delete(senderId);
}
return normalizedPath;
}
}
@@ -9,11 +9,13 @@ import {
PLAYLIST_REFRESH,
PLAYLIST_REFRESH_EVENT,
} from '@iptvnator/shared/interfaces';
import { resolve } from 'node:path';
type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise<unknown>;
type MockIpcEvent = {
sender: {
id: number;
isDestroyed: jest.Mock<boolean, []>;
send: jest.Mock;
};
@@ -57,9 +59,7 @@ jest.mock('electron', () => ({
jest.mock('axios', () => ({
__esModule: true,
default: {
get: (...args: unknown[]) => mockAxiosGet(...args),
},
default: (...args: unknown[]) => mockAxiosGet(...args),
}));
jest.mock('iptv-playlist-parser', () => ({
@@ -118,9 +118,10 @@ function createPlaylist(overrides: Partial<Playlist> = {}): Playlist {
};
}
function createIpcEvent(): MockIpcEvent {
function createIpcEvent(senderId = 1): MockIpcEvent {
return {
sender: {
id: senderId,
isDestroyed: jest.fn(() => false),
send: jest.fn(),
},
@@ -190,8 +191,12 @@ describe('playlist IPC events', () => {
);
expect(mockAxiosGet).toHaveBeenCalledWith(
'https://example.test/remote.m3u',
{ httpsAgent: expect.any(Object) }
expect.objectContaining({
httpsAgent: expect.any(Object),
maxRedirects: 0,
method: 'GET',
url: 'https://example.test/remote.m3u',
})
);
expect(mockParse).toHaveBeenCalledWith('#EXTM3U');
expect(mockCreatePlaylistObject).toHaveBeenCalledWith(
@@ -323,8 +328,12 @@ describe('playlist IPC events', () => {
}),
]);
expect(mockAxiosGet).toHaveBeenCalledWith(
'https://example.test/list.m3u',
{ httpsAgent: expect.any(Object) }
expect.objectContaining({
httpsAgent: expect.any(Object),
maxRedirects: 0,
method: 'GET',
url: 'https://example.test/list.m3u',
})
);
expect(mockReadFile).toHaveBeenCalledWith(
'/playlists/local.m3u',
@@ -521,9 +530,63 @@ describe('playlist IPC events', () => {
)
).resolves.toEqual({ success: true });
expect(mockWriteFile).toHaveBeenCalledWith(
'/exports/list.m3u',
resolve('/exports/list.m3u'),
'#EXTM3U',
'utf-8'
);
});
it('rejects write-file for a path not authorized by a save dialog', async () => {
mockWriteFile.mockClear();
await expect(
getHandler('write-file')(
createIpcEvent(),
'/unauthorized/evil.sh',
'payload'
)
).rejects.toThrow(/not authorized/i);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('scopes save-dialog write authorization to the requesting renderer', async () => {
mockShowSaveDialog.mockResolvedValue({
canceled: false,
filePath: '/exports/private.m3u',
});
await getHandler('save-file-dialog')(
createIpcEvent(1),
'/exports/private.m3u',
[]
);
await expect(
getHandler('write-file')(
createIpcEvent(2),
'/exports/private.m3u',
'#EXTM3U'
)
).rejects.toThrow(/not authorized/i);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('consumes write authorization even when the filesystem write fails', async () => {
mockShowSaveDialog.mockResolvedValue({
canceled: false,
filePath: '/exports/failure.m3u',
});
mockWriteFile.mockRejectedValueOnce(new Error('disk full'));
const event = createIpcEvent(3);
await getHandler('save-file-dialog')(event, '/exports/failure.m3u', []);
await expect(
getHandler('write-file')(event, '/exports/failure.m3u', '#EXTM3U')
).rejects.toThrow('disk full');
await expect(
getHandler('write-file')(event, '/exports/failure.m3u', '#EXTM3U')
).rejects.toThrow(/not authorized/i);
expect(mockWriteFile).toHaveBeenCalledTimes(1);
});
});
@@ -3,12 +3,8 @@
* between the frontend to the electron backend.
*/
import axios from 'axios';
import { app, dialog, ipcMain, WebContents } from 'electron';
import { parse } from 'iptv-playlist-parser';
import { createPlaylistObject, getFilenameFromUrl } from '@iptvnator/shared/m3u-utils';
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import {
@@ -25,6 +21,13 @@ import type {
PlaylistRefreshWorkerMessage,
PlaylistRefreshWorkerResponseMessage,
} from '../workers/playlist-refresh.worker.types';
import {
derivePlaylistTitleFromFilePath,
fetchPlaylistFromFile,
fetchPlaylistFromUrl,
preserveAutoUpdatedPlaylistFields,
} from './playlist-source';
import { PlaylistWriteAuthorizer } from './playlist-write-authorization';
export default class PlaylistEvents {
static bootstrapPlaylistEvents(): Electron.IpcMain {
@@ -32,7 +35,7 @@ export default class PlaylistEvents {
}
}
const https = require('https');
const playlistWriteAuthorizer = new PlaylistWriteAuthorizer();
type ActivePlaylistRefresh = {
reject: (reason?: unknown) => void;
@@ -43,68 +46,13 @@ type ActivePlaylistRefresh = {
const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
/**
* Fetches and parses a playlist from a URL
* @param url - The URL to fetch the playlist from
* @param title - Optional title for the playlist
* @returns Parsed playlist object
*/
async function fetchPlaylistFromUrl(
url: string,
title?: string
): Promise<Playlist> {
const agent = new https.Agent({
rejectUnauthorized: false,
});
const result = await axios.get(url, { httpsAgent: agent });
const parsedPlaylist = parse(result.data);
const extractedName =
url && url.length > 1 ? getFilenameFromUrl(url) : '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
const playlistObject = createPlaylistObject(
title ?? playlistName,
parsedPlaylist,
url,
'URL'
);
return playlistObject;
}
/**
* Reads and parses a playlist from a file path
* @param filePath - The path to the playlist file
* @param title - Title for the playlist
* @returns Parsed playlist object
*/
async function fetchPlaylistFromFile(
filePath: string,
title: string
): Promise<Playlist> {
const fileContent = await readFile(filePath, 'utf-8');
const parsedPlaylist = parse(fileContent);
const playlistObject = createPlaylistObject(
title,
parsedPlaylist,
filePath,
'FILE'
);
return playlistObject;
}
function resolvePlaylistRefreshWorker(): Worker {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: app.isPackaged,
workerFilename: 'playlist-refresh.worker.js',
developmentWorkerDir: __dirname + '/workers',
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
resourcesPath: (process as NodeJS.Process & { resourcesPath?: string })
.resourcesPath,
appPath: app.getAppPath(),
});
@@ -137,24 +85,6 @@ function createPlaylistRefreshError(error: {
return workerError;
}
function derivePlaylistTitleFromFilePath(filePath: string): string {
const filename = basename(filePath);
return filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file';
}
function preserveAutoUpdatedPlaylistFields(
playlistObject: Playlist,
playlist: Playlist
): Playlist {
return {
...playlistObject,
_id: playlist._id,
autoRefresh: playlist.autoRefresh,
favorites: playlist.favorites || [],
userAgent: playlist.userAgent,
};
}
ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => {
try {
return await fetchPlaylistFromUrl(url, title);
@@ -254,75 +184,83 @@ ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => {
return updatedPlaylists;
});
ipcMain.handle(PLAYLIST_REFRESH, async (event, payload: PlaylistRefreshPayload) => {
const worker = resolvePlaylistRefreshWorker();
ipcMain.handle(
PLAYLIST_REFRESH,
async (event, payload: PlaylistRefreshPayload) => {
const worker = resolvePlaylistRefreshWorker();
return await new Promise<Playlist>((resolve, reject) => {
const cleanup = async (): Promise<void> => {
activePlaylistRefreshes.delete(payload.operationId);
worker.removeAllListeners();
await worker.terminate().catch(() => undefined);
};
return await new Promise<Playlist>((resolve, reject) => {
const cleanup = async (): Promise<void> => {
activePlaylistRefreshes.delete(payload.operationId);
worker.removeAllListeners();
await worker.terminate().catch(() => undefined);
};
activePlaylistRefreshes.set(payload.operationId, {
worker,
sender: event.sender,
resolve,
reject,
});
activePlaylistRefreshes.set(payload.operationId, {
worker,
sender: event.sender,
resolve,
reject,
});
worker.on('message', async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
if (message.type === 'ready') {
worker.postMessage({
type: 'request',
payload,
});
return;
}
if (message.type === 'event') {
emitPlaylistRefreshEvent(event.sender, message.event);
return;
}
await cleanup();
const response = message as PlaylistRefreshWorkerResponseMessage<Playlist>;
if (response.success && response.result) {
resolve(response.result);
return;
}
reject(
createPlaylistRefreshError(
response.error ?? {
message: 'Playlist refresh worker request failed',
worker.on(
'message',
async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
if (message.type === 'ready') {
worker.postMessage({
type: 'request',
payload,
});
return;
}
)
if (message.type === 'event') {
emitPlaylistRefreshEvent(event.sender, message.event);
return;
}
await cleanup();
const response =
message as PlaylistRefreshWorkerResponseMessage<Playlist>;
if (response.success && response.result) {
resolve(response.result);
return;
}
reject(
createPlaylistRefreshError(
response.error ?? {
message:
'Playlist refresh worker request failed',
}
)
);
}
);
});
worker.on('error', async (error) => {
await cleanup();
reject(error);
});
worker.on('error', async (error) => {
await cleanup();
reject(error);
});
worker.on('exit', async (code) => {
if (!activePlaylistRefreshes.has(payload.operationId)) {
return;
}
worker.on('exit', async (code) => {
if (!activePlaylistRefreshes.has(payload.operationId)) {
return;
}
await cleanup();
reject(
new Error(
code === 0
? 'Playlist refresh worker exited unexpectedly'
: `Playlist refresh worker stopped with exit code ${code}`
)
);
await cleanup();
reject(
new Error(
code === 0
? 'Playlist refresh worker exited unexpectedly'
: `Playlist refresh worker stopped with exit code ${code}`
)
);
});
});
});
});
}
);
ipcMain.handle(
PLAYLIST_CANCEL_REFRESH,
@@ -345,15 +283,14 @@ ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => {
try {
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath,
filters: filters || [
{ name: 'All Files', extensions: ['*'] },
],
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
});
if (canceled || !filePath) {
return null;
}
playlistWriteAuthorizer.authorize(event.sender.id, filePath);
return filePath;
} catch (error) {
console.error('Error showing save dialog:', error);
@@ -362,8 +299,18 @@ ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => {
});
ipcMain.handle('write-file', async (event, filePath, content) => {
let normalizedPath: string;
try {
await writeFile(filePath, content, 'utf-8');
normalizedPath = playlistWriteAuthorizer.consume(
event.sender.id,
filePath
);
} catch (error) {
console.error('Blocked unauthorized write-file path:', filePath);
throw error;
}
try {
await writeFile(normalizedPath, content, 'utf-8');
return { success: true };
} catch (error) {
console.error('Error writing file:', error);
@@ -5,10 +5,15 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { PortalDebugEvent, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import {
PortalDebugEvent,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
import { emitPortalDebugEvent } from './portal-debug.events';
import { buildStalkerIdentityRequestContext } from './stalker-identity';
import { assertRemoteUrlAllowed } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
export default class StalkerEvents {
static bootstrapStalkerEvents(): Electron.IpcMain {
@@ -16,6 +21,13 @@ export default class StalkerEvents {
}
}
type StalkerResponseData = {
js?: {
cmd?: unknown;
};
[key: string]: unknown;
};
/**
* Handle Stalker API requests with MAC address cookie and optional Bearer token
*/
@@ -48,14 +60,24 @@ ipcMain.handle(
// Build URL with query parameters
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
// to preserve forward slashes, matching stalker-to-m3u implementation
// SSRF/LFI guard: block non-http(s)/credentialed portal URLs.
// Private/LAN targets remain allowed (users run local Stalker servers).
await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true });
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(requestParams).forEach(([key, value]) => {
if (key === 'cmd') {
// Don't encode cmd - it's already a path like /media/12345.mpg
// Encoding would break the path format expected by the server
queryParts.push(`${key}=${String(value)}`);
// Encode cmd but preserve forward slashes so the path format
// (e.g. /media/12345.mpg) the server expects still survives.
// Encoding the remaining characters prevents a malicious portal
// from injecting extra query parameters (&, =, #) into the URL.
queryParts.push(
`${key}=${encodeURIComponent(String(value)).replace(
/%2F/gi,
'/'
)}`
);
} else {
// Use encodeURIComponent for other params
queryParts.push(
@@ -93,7 +115,12 @@ ipcMain.handle(
params: requestParams,
};
const response = await axios(config);
const response =
await requestWithValidatedRedirects<StalkerResponseData>(
fullUrl,
config,
{ allowPrivateNetworks: true }
);
// Check if response is successful
if (response.status >= 400) {
@@ -0,0 +1,124 @@
import {
assertRemoteUrlAllowed,
isPrivateOrReservedIp,
isPrivateNetworkUrlAccessAllowed,
UnsafeUrlError,
validateRemoteUrl,
} from './url-safety';
describe('url-safety', () => {
describe('isPrivateOrReservedIp', () => {
it.each([
'127.0.0.1',
'10.0.0.5',
'192.168.1.1',
'172.16.0.1',
'169.254.169.254',
'::1',
'fd00::1',
'fe80::1',
'febf::1',
'::ffff:127.0.0.1',
'::ffff:7f00:1',
'::ffff:c0a8:101',
])('flags %s as private/reserved', (ip) => {
expect(isPrivateOrReservedIp(ip)).toBe(true);
});
it.each(['93.184.216.34', '8.8.8.8', '1.1.1.1'])(
'allows public address %s',
(ip) => {
expect(isPrivateOrReservedIp(ip)).toBe(false);
}
);
});
describe('assertRemoteUrlAllowed', () => {
const publicResolver = async () => ['93.184.216.34'];
it('rejects non-http(s) protocols', async () => {
await expect(
assertRemoteUrlAllowed('file:///etc/passwd')
).rejects.toBeInstanceOf(UnsafeUrlError);
});
it('rejects embedded credentials', async () => {
await expect(
assertRemoteUrlAllowed('http://user:pass@example.com', {
resolveHostname: publicResolver,
})
).rejects.toBeInstanceOf(UnsafeUrlError);
});
it('rejects loopback literal', async () => {
await expect(
assertRemoteUrlAllowed('http://127.0.0.1/admin')
).rejects.toBeInstanceOf(UnsafeUrlError);
});
it('rejects localhost hostname', async () => {
await expect(
assertRemoteUrlAllowed('http://localhost:8080')
).rejects.toBeInstanceOf(UnsafeUrlError);
});
it('rejects the cloud metadata address', async () => {
await expect(
assertRemoteUrlAllowed(
'http://169.254.169.254/latest/meta-data'
)
).rejects.toBeInstanceOf(UnsafeUrlError);
});
it('rejects hex-form IPv4-mapped IPv6 loopback literals', async () => {
await expect(
assertRemoteUrlAllowed('http://[::ffff:7f00:1]/admin')
).rejects.toBeInstanceOf(UnsafeUrlError);
});
it('rejects a hostname that resolves to a private IP', async () => {
await expect(
assertRemoteUrlAllowed('http://rebind.example', {
resolveHostname: async () => ['10.0.0.1'],
})
).rejects.toBeInstanceOf(UnsafeUrlError);
});
it('allows a public URL', async () => {
const target = await validateRemoteUrl(
'https://example.com/playlist.m3u',
{ resolveHostname: publicResolver }
);
expect(target.url.hostname).toBe('example.com');
expect(target.addresses).toEqual(['93.184.216.34']);
});
it('honors the allowPrivateNetworks override', async () => {
const url = await assertRemoteUrlAllowed('http://127.0.0.1/probe', {
allowPrivateNetworks: true,
});
expect(url.hostname).toBe('127.0.0.1');
});
});
describe('isPrivateNetworkUrlAccessAllowed', () => {
const originalValue = process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS;
afterEach(() => {
if (originalValue === undefined) {
delete process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS;
} else {
process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS =
originalValue;
}
});
it('requires an explicit environment opt-in', () => {
delete process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS;
expect(isPrivateNetworkUrlAccessAllowed()).toBe(false);
process.env.IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS = ' TRUE ';
expect(isPrivateNetworkUrlAccessAllowed()).toBe(true);
});
});
});
@@ -0,0 +1,272 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const PRIVATE_NETWORK_URLS_ENV = 'IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS';
/**
* Raised when a renderer-supplied remote URL is rejected by the SSRF guard.
*/
export class UnsafeUrlError extends Error {
readonly status: number;
constructor(message: string, status = 400) {
super(message);
this.name = 'UnsafeUrlError';
this.status = status;
}
}
export interface RemoteUrlPolicy {
/** When true, private/reserved targets are permitted (default false). */
allowPrivateNetworks?: boolean;
/** Injectable DNS resolver. Defaults to `dns.lookup`; overridable in tests. */
resolveHostname?: (hostname: string) => Promise<readonly string[]>;
}
export interface ValidatedRemoteUrl {
/** Parsed URL, retaining the original hostname for TLS SNI and Host. */
url: URL;
/** Validated addresses that socket-level DNS lookup must be pinned to. */
addresses?: readonly string[];
}
function isTruthyEnvironmentOptIn(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === '1' || normalized === 'true';
}
export function isPrivateNetworkUrlAccessAllowed(): boolean {
return isTruthyEnvironmentOptIn(process.env[PRIVATE_NETWORK_URLS_ENV]);
}
function normalizeHostname(hostname: string): string {
let host = hostname.trim().toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
return host;
}
export function isLocalHostname(hostname: string): boolean {
return hostname === 'localhost' || hostname.endsWith('.localhost');
}
export function isPrivateOrReservedIpv4(address: string): boolean {
const parts = address.split('.').map((part) => Number(part));
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return true;
}
const [first, second, third] = parts;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 192 && second === 0) ||
(first === 198 && (second === 18 || second === 19)) ||
(first === 198 && second === 51 && third === 100) ||
(first === 203 && second === 0 && third === 113) ||
first >= 224
);
}
function parseIpv6Words(address: string): number[] | null {
const halves = address.split('::');
if (halves.length > 2) {
return null;
}
const parseHalf = (half: string): number[] | null => {
if (!half) {
return [];
}
const words: number[] = [];
for (const part of half.split(':')) {
if (part.includes('.')) {
const octets = part.split('.').map((octet) => Number(octet));
if (
octets.length !== 4 ||
octets.some(
(octet) =>
!Number.isInteger(octet) || octet < 0 || octet > 255
)
) {
return null;
}
words.push(
(octets[0] << 8) | octets[1],
(octets[2] << 8) | octets[3]
);
continue;
}
if (!/^[0-9a-f]{1,4}$/i.test(part)) {
return null;
}
words.push(Number.parseInt(part, 16));
}
return words;
};
const left = parseHalf(halves[0]);
const right = parseHalf(halves[1] ?? '');
if (!left || !right) {
return null;
}
if (halves.length === 1) {
return left.length === 8 ? left : null;
}
const omittedWordCount = 8 - left.length - right.length;
if (omittedWordCount < 1) {
return null;
}
return [...left, ...Array<number>(omittedWordCount).fill(0), ...right];
}
function getMappedIpv4Address(address: string): string | null {
const words = parseIpv6Words(address);
if (
!words ||
words.slice(0, 5).some((word) => word !== 0) ||
words[5] !== 0xffff
) {
return null;
}
return [
words[6] >> 8,
words[6] & 0xff,
words[7] >> 8,
words[7] & 0xff,
].join('.');
}
export function isPrivateOrReservedIpv6(address: string): boolean {
const normalized = address.toLowerCase();
if (
normalized === '::' ||
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
// Link-local fe80::/10 spans fe80:: through febf:: (3rd nibble 8-b).
/^fe[89ab]/.test(normalized)
) {
return true;
}
const mappedIpv4 = getMappedIpv4Address(normalized);
return mappedIpv4 ? isPrivateOrReservedIpv4(mappedIpv4) : false;
}
export function isPrivateOrReservedIp(address: string): boolean {
const version = isIP(address);
if (version === 4) {
return isPrivateOrReservedIpv4(address);
}
if (version === 6) {
return isPrivateOrReservedIpv6(address);
}
return false;
}
async function defaultResolveHostname(
hostname: string
): Promise<readonly string[]> {
const records = await lookup(hostname, { all: true, verbatim: true });
return records.map((record) => record.address);
}
/**
* Validates a renderer-supplied remote URL before the Electron main process
* fetches it, preventing SSRF to loopback / private / reserved network
* targets (e.g. `http://127.0.0.1`, `http://169.254.169.254` metadata).
*
* Returns the parsed {@link URL} when allowed, otherwise throws
* {@link UnsafeUrlError}.
*/
export async function validateRemoteUrl(
rawUrl: string,
policy: RemoteUrlPolicy = {}
): Promise<ValidatedRemoteUrl> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new UnsafeUrlError('Invalid URL');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new UnsafeUrlError('Only http and https URLs are supported');
}
if (url.username || url.password) {
throw new UnsafeUrlError('URL credentials are not supported');
}
if (policy.allowPrivateNetworks) {
return { url };
}
const hostname = normalizeHostname(url.hostname);
if (isLocalHostname(hostname) || isPrivateOrReservedIp(hostname)) {
throw new UnsafeUrlError(
'URL points to a private or local network address'
);
}
if (isIP(hostname) !== 0) {
return { url, addresses: [hostname] };
}
{
const resolveHostname =
policy.resolveHostname ?? defaultResolveHostname;
let addresses: readonly string[];
try {
addresses = await resolveHostname(hostname);
} catch {
throw new UnsafeUrlError('URL host could not be resolved');
}
if (
addresses.length === 0 ||
addresses.some((address) => {
const normalizedAddress = normalizeHostname(address);
return (
isIP(normalizedAddress) === 0 ||
isPrivateOrReservedIp(normalizedAddress)
);
})
) {
throw new UnsafeUrlError(
'URL points to a private or local network address'
);
}
return {
url,
addresses: addresses.map((address) => normalizeHostname(address)),
};
}
}
/**
* Validates a URL and returns its parsed representation.
*/
export async function assertRemoteUrlAllowed(
rawUrl: string,
policy: RemoteUrlPolicy = {}
): Promise<URL> {
return (await validateRemoteUrl(rawUrl, policy)).url;
}
@@ -18,9 +18,11 @@ function createDeferred<T>() {
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(channel, handler);
}),
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(channel, handler);
}
),
},
}));
@@ -53,7 +55,10 @@ describe('XtreamEvents session cancellation', () => {
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
const pendingRequest = createDeferred<{ status: number; data: unknown }>();
const pendingRequest = createDeferred<{
status: number;
data: unknown;
}>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
@@ -70,23 +75,27 @@ describe('XtreamEvents session cancellation', () => {
(error: unknown) => error === cancelError
);
const requestPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-1',
suppressErrorLog: true,
}) as Promise<unknown>;
const requestPromise = requestHandler?.(
{},
{
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-1',
suppressErrorLog: true,
}
) as Promise<unknown>;
await Promise.resolve();
expect(abortSignal?.aborted).toBe(false);
const cancelResult = (await cancelHandler?.(
{},
'session-1'
)) as { success: boolean; cancelled: number };
const cancelResult = (await cancelHandler?.({}, 'session-1')) as {
success: boolean;
cancelled: number;
};
expect(cancelResult).toEqual({ success: true, cancelled: 1 });
expect(abortSignal?.aborted).toBe(true);
@@ -102,8 +111,14 @@ describe('XtreamEvents session cancellation', () => {
it('counts every matching in-flight request for the same session', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
const firstRequest = createDeferred<{ status: number; data: unknown }>();
const secondRequest = createDeferred<{ status: number; data: unknown }>();
const firstRequest = createDeferred<{
status: number;
data: unknown;
}>();
const secondRequest = createDeferred<{
status: number;
data: unknown;
}>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
@@ -129,31 +144,38 @@ describe('XtreamEvents session cancellation', () => {
(error: unknown) => error === cancelError
);
const firstPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}) as Promise<unknown>;
const secondPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_vod_streams',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}) as Promise<unknown>;
const cancelResult = (await cancelHandler?.(
const firstPromise = requestHandler?.(
{},
'session-2'
)) as { success: boolean; cancelled: number };
{
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}
) as Promise<unknown>;
const secondPromise = requestHandler?.(
{},
{
url: 'http://localhost:3211',
params: {
action: 'get_vod_streams',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}
) as Promise<unknown>;
await Promise.resolve();
const cancelResult = (await cancelHandler?.({}, 'session-2')) as {
success: boolean;
cancelled: number;
};
expect(cancelResult).toEqual({ success: true, cancelled: 2 });
expect(abortSignals).toHaveLength(2);
@@ -162,7 +184,11 @@ describe('XtreamEvents session cancellation', () => {
firstRequest.reject(cancelError);
secondRequest.reject(cancelError);
await expect(firstPromise).rejects.toMatchObject({ name: 'AbortError' });
await expect(secondPromise).rejects.toMatchObject({ name: 'AbortError' });
await expect(firstPromise).rejects.toMatchObject({
name: 'AbortError',
});
await expect(secondPromise).rejects.toMatchObject({
name: 'AbortError',
});
});
});
@@ -5,8 +5,13 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { PortalDebugEvent, XTREAM_CANCEL_SESSION } from '@iptvnator/shared/interfaces';
import {
PortalDebugEvent,
XTREAM_CANCEL_SESSION,
} from '@iptvnator/shared/interfaces';
import { emitPortalDebugEvent } from './portal-debug.events';
import { assertRemoteUrlAllowed, UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
export default class XtreamEvents {
static bootstrapXtreamEvents(): Electron.IpcMain {
@@ -14,7 +19,11 @@ export default class XtreamEvents {
}
}
function formatXtreamError(error: unknown, requestUrl: string, action?: string) {
function formatXtreamError(
error: unknown,
requestUrl: string,
action?: string
) {
const parsedUrl = new URL(requestUrl);
const base = {
action,
@@ -101,7 +110,11 @@ ipcMain.handle(
signal: controller.signal,
};
const response = await axios(config);
const response = await requestWithValidatedRedirects<unknown>(
apiUrl.toString(),
config,
{ allowPrivateNetworks: true }
);
// Check if response is successful
if (response.status >= 400) {
@@ -172,7 +185,11 @@ ipcMain.handle(
if (!payload.suppressErrorLog) {
console.error(
'[XTREAM_REQUEST] Failed',
formatXtreamError(error, payload.url, payload.params?.action)
formatXtreamError(
error,
payload.url,
payload.params?.action
)
);
}
@@ -218,7 +235,10 @@ ipcMain.handle(
ipcMain.handle(
XTREAM_CANCEL_SESSION,
async (_event, sessionId: string): Promise<{ success: boolean; cancelled: number }> => {
async (
_event,
sessionId: string
): Promise<{ success: boolean; cancelled: number }> => {
if (!sessionId) {
return { success: false, cancelled: 0 };
}
@@ -255,6 +275,26 @@ ipcMain.handle(
method?: 'GET' | 'HEAD';
}
) => {
// Guard against SSRF: a malicious portal/playlist could ask the main
// process to probe loopback/private/metadata addresses. Only allow
// public http(s) targets.
try {
// Private/LAN targets are allowed (users probe self-hosted Xtream
// servers); maxRedirects:0 below blocks redirect-based SSRF.
await assertRemoteUrlAllowed(payload.url, {
allowPrivateNetworks: true,
});
} catch (error) {
return {
status: 0,
url: payload.url,
error:
error instanceof UnsafeUrlError
? error.message
: 'Invalid URL',
};
}
const config: AxiosRequestConfig = {
method: payload.method ?? 'HEAD',
url: payload.url,
@@ -263,7 +303,11 @@ ipcMain.handle(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
timeout: 10000,
maxRedirects: 5,
// SSRF hardening: do NOT follow redirects. assertRemoteUrlAllowed
// validated payload.url, but a validated public host could 3xx to an
// internal address that would never be re-checked. validateStatus
// below returns the 3xx as the probe result instead of following it.
maxRedirects: 0,
validateStatus: () => true,
};
@@ -0,0 +1,71 @@
import { Agent } from 'node:https';
import type { LookupFunction } from 'node:net';
import {
createPlaylistAgentFactory,
isInsecureTlsAllowed,
} from './secure-https';
jest.mock('node:https', () => ({
Agent: jest.fn(() => ({ protocol: 'https:' })),
}));
describe('secure-https', () => {
const originalValue = process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
const agentConstructorMock = Agent as unknown as jest.Mock;
const lookup = jest.fn() as unknown as LookupFunction;
beforeEach(() => {
agentConstructorMock.mockClear();
});
afterEach(() => {
if (originalValue === undefined) {
delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
} else {
process.env.IPTVNATOR_ALLOW_INSECURE_TLS = originalValue;
}
});
it('validates certificates by default', () => {
delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
expect(isInsecureTlsAllowed()).toBe(false);
createPlaylistAgentFactory().createHttpsAgent(lookup);
expect(agentConstructorMock).toHaveBeenCalledWith({
lookup,
rejectUnauthorized: true,
});
});
it.each(['1', 'true', ' TRUE '])(
'allows an explicit insecure TLS opt-in via %s',
(value) => {
process.env.IPTVNATOR_ALLOW_INSECURE_TLS = value;
expect(isInsecureTlsAllowed()).toBe(true);
createPlaylistAgentFactory().createHttpsAgent(lookup);
expect(agentConstructorMock).toHaveBeenCalledWith({
lookup,
rejectUnauthorized: false,
});
}
);
it('does not accept unrelated truthy values', () => {
process.env.IPTVNATOR_ALLOW_INSECURE_TLS = 'yes';
expect(isInsecureTlsAllowed()).toBe(false);
});
it('preserves TLS policy when no pinned lookup is required', () => {
delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
createPlaylistAgentFactory().createHttpsAgent();
expect(agentConstructorMock).toHaveBeenCalledWith({
rejectUnauthorized: true,
});
});
});
@@ -0,0 +1,36 @@
import { Agent } from 'node:https';
import type { LookupFunction } from 'node:net';
import type { ValidatedRequestAgentFactory } from './validated-axios';
const INSECURE_TLS_ENV = 'IPTVNATOR_ALLOW_INSECURE_TLS';
/**
* Whether TLS certificate validation is disabled for remote playlist fetches.
*
* Secure by default. Validation is only disabled when the operator explicitly
* opts in via `IPTVNATOR_ALLOW_INSECURE_TLS=1` (or `=true`). Some IPTV
* providers serve self-signed or otherwise invalid certificates, so the
* opt-out exists — but it must never be the default, otherwise every playlist
* fetch is silently exposed to network MITM.
*/
export function isInsecureTlsAllowed(): boolean {
const value = process.env[INSECURE_TLS_ENV]?.trim().toLowerCase();
return value === '1' || value === 'true';
}
/**
* Builds the agent factory used for remote playlist fetches.
*
* Certificates are validated unless insecure TLS has been explicitly opted in
* (see {@link isInsecureTlsAllowed}). The validated request layer supplies a
* DNS lookup pinned to the addresses approved for each redirect hop.
*/
export function createPlaylistAgentFactory(): ValidatedRequestAgentFactory & {
createHttpsAgent(lookup?: LookupFunction): Agent;
} {
const rejectUnauthorized = !isInsecureTlsAllowed();
return {
createHttpsAgent: (lookup) => new Agent({ lookup, rejectUnauthorized }),
};
}
@@ -0,0 +1,384 @@
import axios from 'axios';
import type { LookupAddress, LookupOptions } from 'node:dns';
import { Agent as HttpAgent } from 'node:http';
import { Agent as HttpsAgent } from 'node:https';
import type { LookupFunction } from 'node:net';
import { UnsafeUrlError } from '../events/url-safety';
import {
requestWithValidatedRedirects,
type ValidatedRequestAgentFactory,
} from './validated-axios';
jest.mock('axios', () => ({
__esModule: true,
default: jest.fn(),
}));
const axiosMock = axios as unknown as jest.Mock;
describe('requestWithValidatedRedirects', () => {
const publicResolver = async () => ['93.184.216.34'];
function createCapturingAgentFactory() {
const lookups: LookupFunction[] = [];
const factory: ValidatedRequestAgentFactory = {
createHttpsAgent: jest.fn((lookup) => {
if (!lookup) {
throw new Error('Expected a pinned lookup');
}
lookups.push(lookup);
return new HttpsAgent({ lookup });
}),
};
return { factory, lookups };
}
beforeEach(() => {
axiosMock.mockReset();
});
it('rejects a redirect target that violates the URL policy', async () => {
axiosMock.mockResolvedValueOnce({
status: 302,
headers: {
location: 'http://169.254.169.254/latest/meta-data',
},
});
await expect(
requestWithValidatedRedirects(
'https://example.com/player_api.php',
{ method: 'GET' },
{ resolveHostname: publicResolver }
)
).rejects.toBeInstanceOf(UnsafeUrlError);
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('pins the socket lookup to the address validated by the URL policy', async () => {
axiosMock.mockResolvedValueOnce({
status: 200,
headers: {},
data: '#EXTM3U',
});
const { factory, lookups } = createCapturingAgentFactory();
await requestWithValidatedRedirects(
'https://epg.example/guide.xml',
{ agentFactory: factory, method: 'GET' },
{
resolveHostname: async () => ['93.184.216.34'],
}
);
const requestConfig = axiosMock.mock.calls[0][0];
const resolvedAddress = await new Promise<{
address: string | LookupAddress[];
family?: number;
}>((resolve, reject) => {
lookups[0](
'epg.example',
{ all: false, family: 0 } as LookupOptions,
(error, address, family) => {
if (error) {
reject(error);
return;
}
resolve({ address, family });
}
);
});
expect(factory.createHttpsAgent).toHaveBeenCalledWith(lookups[0]);
expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent);
expect(requestConfig).not.toHaveProperty('agentFactory');
expect(resolvedAddress).toEqual({
address: '93.184.216.34',
family: 4,
});
expect(requestConfig.proxy).toBe(false);
expect(requestConfig.url).toBe('https://epg.example/guide.xml');
});
it('supplies the validated lookup to an explicit HTTP agent factory', async () => {
axiosMock.mockResolvedValueOnce({
status: 200,
headers: {},
data: '#EXTM3U',
});
let pinnedLookup: LookupFunction | undefined;
const factory: ValidatedRequestAgentFactory = {
createHttpAgent: jest.fn((lookup) => {
pinnedLookup = lookup;
return new HttpAgent({ lookup });
}),
};
await requestWithValidatedRedirects(
'http://playlist.example/list.m3u',
{ agentFactory: factory, method: 'GET' },
{ resolveHostname: publicResolver }
);
const requestConfig = axiosMock.mock.calls[0][0];
expect(factory.createHttpAgent).toHaveBeenCalledWith(pinnedLookup);
expect(requestConfig.httpAgent).toBeInstanceOf(HttpAgent);
expect(requestConfig).not.toHaveProperty('agentFactory');
});
it('uses a custom HTTPS agent factory when private networks are allowed', async () => {
axiosMock.mockResolvedValueOnce({
status: 200,
headers: {},
data: '#EXTM3U',
});
const factory: ValidatedRequestAgentFactory = {
createHttpsAgent: jest.fn((lookup) => new HttpsAgent({ lookup })),
};
await requestWithValidatedRedirects(
'https://192.168.1.10/list.m3u',
{ agentFactory: factory, method: 'GET' },
{ allowPrivateNetworks: true }
);
const requestConfig = axiosMock.mock.calls[0][0];
expect(factory.createHttpsAgent).toHaveBeenCalledWith();
expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent);
expect(requestConfig).not.toHaveProperty('agentFactory');
});
it('revalidates and repins every redirect hop', async () => {
axiosMock
.mockResolvedValueOnce({
status: 302,
headers: { location: 'https://cdn.example/guide.xml' },
})
.mockResolvedValueOnce({
status: 200,
headers: {},
data: '<tv />',
});
const { factory, lookups } = createCapturingAgentFactory();
const resolveHostname = jest.fn(async (hostname: string) =>
hostname === 'epg.example' ? ['93.184.216.34'] : ['142.250.191.110']
);
await requestWithValidatedRedirects(
'https://epg.example/guide.xml',
{ agentFactory: factory, method: 'GET' },
{ resolveHostname }
);
const resolvePinnedAddress = async (callIndex: number) => {
return new Promise<string | LookupAddress[]>((resolve, reject) => {
lookups[callIndex](
'ignored.example',
{ all: false, family: 0 } as LookupOptions,
(error, address) => {
if (error) {
reject(error);
return;
}
resolve(address);
}
);
});
};
expect(resolveHostname).toHaveBeenNthCalledWith(1, 'epg.example');
expect(resolveHostname).toHaveBeenNthCalledWith(2, 'cdn.example');
expect(factory.createHttpsAgent).toHaveBeenCalledTimes(2);
await expect(resolvePinnedAddress(0)).resolves.toBe('93.184.216.34');
await expect(resolvePinnedAddress(1)).resolves.toBe('142.250.191.110');
});
it('removes sensitive headers when a redirect changes origin', async () => {
axiosMock
.mockResolvedValueOnce({
status: 302,
headers: { location: 'https://cdn.example.net/playlist.m3u' },
})
.mockResolvedValueOnce({
status: 200,
headers: {},
data: '#EXTM3U',
});
await requestWithValidatedRedirects(
'https://example.com/playlist.m3u',
{
headers: {
Authorization: 'Bearer secret',
Cookie: 'session=secret',
Accept: 'text/plain',
},
method: 'GET',
},
{ resolveHostname: publicResolver }
);
const redirectedConfig = axiosMock.mock.calls[1][0];
expect(redirectedConfig.headers).toMatchObject({
Accept: 'text/plain',
});
expect(redirectedConfig.headers).not.toHaveProperty('Authorization');
expect(redirectedConfig.headers).not.toHaveProperty('Cookie');
});
it('does not forward axios params to a cross-origin redirect', async () => {
axiosMock
.mockResolvedValueOnce({
status: 302,
headers: { location: 'https://cdn.example.net/playlist.m3u' },
})
.mockResolvedValueOnce({
status: 200,
headers: {},
data: '#EXTM3U',
});
await requestWithValidatedRedirects(
'https://example.com/playlist.m3u',
{
method: 'GET',
params: { token: 'secret' },
},
{ resolveHostname: publicResolver }
);
expect(axiosMock.mock.calls[1][0].params).toBeUndefined();
});
it('does not forward axios basic auth to a cross-origin redirect', async () => {
axiosMock
.mockResolvedValueOnce({
status: 302,
headers: { location: 'https://cdn.example.net/playlist.m3u' },
})
.mockResolvedValueOnce({
status: 200,
headers: {},
data: '#EXTM3U',
});
await requestWithValidatedRedirects(
'https://example.com/playlist.m3u',
{
auth: { password: 'secret', username: 'provider' },
method: 'GET',
},
{ resolveHostname: publicResolver }
);
expect(axiosMock.mock.calls[1][0].auth).toBeUndefined();
});
it('rejects a cross-origin redirect that would replay a request body', async () => {
axiosMock.mockResolvedValueOnce({
status: 307,
headers: { location: 'https://other.example/submit' },
});
await expect(
requestWithValidatedRedirects(
'https://example.com/submit',
{ data: { secret: true }, method: 'POST' },
{ resolveHostname: publicResolver }
)
).rejects.toThrow(/request bodies/i);
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('rejects redirects without a location header', async () => {
axiosMock.mockResolvedValueOnce({
status: 302,
headers: {},
});
await expect(
requestWithValidatedRedirects(
'https://example.com/start',
{ method: 'GET' },
{ resolveHostname: publicResolver }
)
).rejects.toMatchObject({
message: expect.stringMatching(/location/i),
status: 502,
});
});
it('stops after the configured redirect limit', async () => {
axiosMock.mockResolvedValue({
status: 302,
headers: { location: '/again' },
});
await expect(
requestWithValidatedRedirects(
'https://example.com/start',
{ method: 'GET' },
{ resolveHostname: publicResolver },
1
)
).rejects.toMatchObject({
message: expect.stringMatching(/too many redirects/i),
status: 502,
});
expect(axiosMock).toHaveBeenCalledTimes(2);
});
it('converts a 303 redirect to GET and removes the request body', async () => {
axiosMock
.mockResolvedValueOnce({
status: 303,
headers: { location: '/result' },
})
.mockResolvedValueOnce({
status: 200,
headers: {},
data: 'ok',
});
await requestWithValidatedRedirects(
'https://example.com/submit',
{ data: { value: 1 }, method: 'POST' },
{ resolveHostname: publicResolver }
);
expect(axiosMock.mock.calls[1][0]).toMatchObject({
data: undefined,
method: 'GET',
url: 'https://example.com/result',
});
});
it.each([301, 302])(
'converts a POST redirected with %i to GET and removes the request body',
async (status) => {
axiosMock
.mockResolvedValueOnce({
status,
headers: { location: '/result' },
})
.mockResolvedValueOnce({
status: 200,
headers: {},
data: 'ok',
});
await requestWithValidatedRedirects(
'https://example.com/submit',
{ data: { value: 1 }, method: 'POST' },
{ resolveHostname: publicResolver }
);
expect(axiosMock.mock.calls[1][0]).toMatchObject({
data: undefined,
method: 'GET',
url: 'https://example.com/result',
});
}
);
});
@@ -0,0 +1,215 @@
import axios, {
AxiosRequestConfig,
AxiosResponse,
RawAxiosRequestHeaders,
} from 'axios';
import type { LookupAddress } from 'node:dns';
import { Agent as HttpAgent } from 'node:http';
import { Agent as HttpsAgent } from 'node:https';
import { isIP, LookupFunction } from 'node:net';
import {
RemoteUrlPolicy,
UnsafeUrlError,
validateRemoteUrl,
} from '../events/url-safety';
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set([
'authorization',
'cookie',
'proxy-authorization',
]);
export interface ValidatedRequestAgentFactory {
createHttpAgent?(lookup?: LookupFunction): HttpAgent;
createHttpsAgent?(lookup?: LookupFunction): HttpsAgent;
}
export type ValidatedAxiosRequestConfig = Omit<
AxiosRequestConfig,
'httpAgent' | 'httpsAgent'
> & {
agentFactory?: ValidatedRequestAgentFactory;
};
function copyHeadersWithoutSensitiveValues(
headers: AxiosRequestConfig['headers']
): AxiosRequestConfig['headers'] {
if (!headers) {
return headers;
}
const source =
typeof (headers as { toJSON?: () => RawAxiosRequestHeaders }).toJSON ===
'function'
? (
headers as {
toJSON: () => RawAxiosRequestHeaders;
}
).toJSON()
: headers;
const sanitized: RawAxiosRequestHeaders = {};
for (const [name, value] of Object.entries(source)) {
if (!SENSITIVE_HEADERS.has(name.toLowerCase())) {
sanitized[name] = value;
}
}
return sanitized;
}
function createPinnedLookup(addresses: readonly string[]): LookupFunction {
const records: LookupAddress[] = addresses.map((address) => ({
address,
family: isIP(address),
}));
return (_hostname, options, callback) => {
const requestedFamily = options.family;
const eligibleRecords = requestedFamily
? records.filter((record) => record.family === requestedFamily)
: records;
if (eligibleRecords.length === 0) {
const error = new Error(
'Validated URL has no connectable address'
) as NodeJS.ErrnoException;
error.code = 'ENOTFOUND';
callback(error, []);
return;
}
if (options.all) {
callback(null, eligibleRecords);
return;
}
const selected = eligibleRecords[0];
callback(null, selected.address, selected.family);
};
}
function pinRequestToValidatedAddresses(
config: ValidatedAxiosRequestConfig,
url: URL,
addresses: readonly string[] | undefined
): AxiosRequestConfig {
const { agentFactory, ...axiosConfig } = config;
if (!addresses) {
if (url.protocol === 'https:' && agentFactory?.createHttpsAgent) {
return {
...axiosConfig,
httpsAgent: agentFactory.createHttpsAgent(),
};
}
if (url.protocol === 'http:' && agentFactory?.createHttpAgent) {
return {
...axiosConfig,
httpAgent: agentFactory.createHttpAgent(),
};
}
return axiosConfig;
}
const lookup = createPinnedLookup(addresses);
if (url.protocol === 'https:') {
return {
...axiosConfig,
httpsAgent:
agentFactory?.createHttpsAgent?.(lookup) ??
new HttpsAgent({ lookup }),
proxy: false,
};
}
return {
...axiosConfig,
httpAgent:
agentFactory?.createHttpAgent?.(lookup) ??
new HttpAgent({ lookup }),
proxy: false,
};
}
/**
* Runs an Axios request while validating the initial URL and every redirect.
* Redirects are followed manually so each target passes through the same
* private-network and protocol policy.
*/
export async function requestWithValidatedRedirects<T = unknown>(
rawUrl: string,
config: ValidatedAxiosRequestConfig = {},
policy: RemoteUrlPolicy = {},
maxRedirects = 5
): Promise<AxiosResponse<T>> {
const originalValidateStatus =
config.validateStatus ??
((status: number) => status >= 200 && status < 300);
let currentUrl = rawUrl;
let requestConfig = { ...config };
for (let redirectCount = 0; ; redirectCount += 1) {
const validatedTarget = await validateRemoteUrl(currentUrl, policy);
const validatedUrl = validatedTarget.url;
const pinnedConfig = pinRequestToValidatedAddresses(
requestConfig,
validatedUrl,
validatedTarget.addresses
);
const response = await axios<T>({
...pinnedConfig,
maxRedirects: 0,
url: validatedUrl.toString(),
validateStatus: (status) =>
REDIRECT_STATUSES.has(status) || originalValidateStatus(status),
});
if (!REDIRECT_STATUSES.has(response.status)) {
return response;
}
const location = response.headers?.location;
if (!location) {
throw new UnsafeUrlError(
'Redirect response did not include a location',
502
);
}
if (redirectCount >= maxRedirects) {
throw new UnsafeUrlError('Too many redirects', 502);
}
const nextUrl = new URL(location, validatedUrl);
const method = requestConfig.method?.toUpperCase();
const shouldRewriteToGet =
(response.status === 303 && method !== 'HEAD') ||
((response.status === 301 || response.status === 302) &&
method === 'POST');
if (shouldRewriteToGet) {
requestConfig = {
...requestConfig,
data: undefined,
method: 'GET',
};
}
if (nextUrl.origin !== validatedUrl.origin) {
if (requestConfig.data !== undefined) {
throw new UnsafeUrlError(
'Cross-origin redirects with request bodies are not supported',
502
);
}
requestConfig = {
...requestConfig,
auth: undefined,
headers: copyHeadersWithoutSensitiveValues(
requestConfig.headers
),
params: undefined,
};
}
currentUrl = nextUrl.toString();
}
}
@@ -0,0 +1,48 @@
import type BetterSqlite3 from 'better-sqlite3';
import { EpgDatabaseClearOperation } from './epg-database';
function createDatabaseMock(exec: jest.Mock) {
const database = {
close: jest.fn(),
exec,
};
const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3;
return { Database, database };
}
describe('EpgDatabaseClearOperation', () => {
it('clears programs and channels in one transaction', () => {
const exec = jest.fn();
const { Database } = createDatabaseMock(exec);
new EpgDatabaseClearOperation(Database).run();
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
'BEGIN',
'DELETE FROM epg_programs',
'DELETE FROM epg_channels',
'COMMIT',
]);
});
it('rolls back when either delete fails', () => {
const failure = new Error('database is busy');
const exec = jest.fn((statement: string) => {
if (statement === 'DELETE FROM epg_channels') {
throw failure;
}
});
const { Database } = createDatabaseMock(exec);
expect(() => new EpgDatabaseClearOperation(Database).run()).toThrow(
failure
);
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
'BEGIN',
'DELETE FROM epg_programs',
'DELETE FROM epg_channels',
'ROLLBACK',
]);
});
});
@@ -0,0 +1,146 @@
import type BetterSqlite3 from 'better-sqlite3';
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser';
/**
* Database helper for worker-owned EPG operations.
* Creates its own connection to avoid blocking the main thread.
*/
export class EpgDatabase {
private readonly db: BetterSqlite3.Database;
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteChannelsStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL');
this.db.pragma('busy_timeout = 5000');
this.insertChannelStmt = this.db.prepare(`
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
`);
}
/**
* Insert a batch of channels. When `clearFirst` is true, the existing rows
* for `sourceUrl` are deleted inside the same transaction as the insert so
* old data is preserved if the fetch/parse never produces any channels.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearFirst = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
for (const channel of channels) {
const displayName =
channel.displayName?.[0]?.value || channel.id;
const iconUrl = channel.icon?.[0]?.src || null;
const url = channel.url?.[0] || null;
this.insertChannelStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.knownChannelIds.add(channel.id);
}
});
insertMany(channels);
}
/**
* Insert programs for channels already seen during the current parse.
*/
insertPrograms(programs: ParsedProgram[]): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
for (const program of programs) {
if (!this.knownChannelIds.has(program.channel)) continue;
const title = program.title?.[0]?.value || 'Unknown';
const description = program.desc?.[0]?.value || null;
const category = program.category?.[0]?.value || null;
const iconUrl = program.icon?.[0]?.src || null;
const rating = program.rating?.[0]?.value || null;
const episodeNum = program.episodeNum?.[0]?.value || null;
try {
this.insertProgramStmt.run(
program.channel,
program.start,
program.stop,
title,
description,
category,
iconUrl,
rating,
episodeNum
);
insertedCount++;
} catch {
// Skip individual failures such as FK constraint errors.
}
}
});
insertMany(programs);
return insertedCount;
}
close(): void {
this.db.close();
}
}
export class EpgDatabaseClearOperation {
private readonly db: BetterSqlite3.Database;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
}
run(): void {
this.db.exec('BEGIN');
try {
this.db.exec('DELETE FROM epg_programs');
this.db.exec('DELETE FROM epg_channels');
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
close(): void {
this.db.close();
}
}
@@ -1,15 +1,12 @@
import type BetterSqlite3 from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
import { Readable } from 'stream';
import { parentPort, workerData } from 'worker_threads';
import { createGunzip } from 'zlib';
import {
ParsedChannel,
ParsedProgram,
StreamingEpgParser,
} from './epg-streaming-parser';
import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database';
import { StreamingEpgParser } from './epg-streaming-parser';
import { shouldGunzipEpgResponse } from './epg-response-utils';
import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import {
getNativeModuleSearchPaths,
getWorkerDataNativeModuleSearchPaths,
@@ -17,14 +14,11 @@ import {
registerNativeModuleSearchPaths,
} from './worker-runtime-paths';
let Database: typeof BetterSqlite3;
const nativeModuleSearchPaths = [
...getWorkerDataNativeModuleSearchPaths(workerData),
...getNativeModuleSearchPaths({
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
resourcesPath: (process as NodeJS.Process & { resourcesPath?: string })
.resourcesPath,
}),
];
@@ -36,12 +30,11 @@ function loadBetterSqlite3(): typeof BetterSqlite3 {
loggerLabel: '[EPG Worker]',
searchPaths: nativeModuleSearchPaths,
fallbackRequire: () =>
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('better-sqlite3') as typeof BetterSqlite3,
});
}
Database = loadBetterSqlite3();
const Database = loadBetterSqlite3();
/**
* Streaming EPG Parser Worker
@@ -76,143 +69,6 @@ const loggerLabel = '[EPG Worker]';
const CHANNEL_BATCH_SIZE = 100;
const PROGRAM_BATCH_SIZE = 1000;
/**
* Database helper class for EPG operations
* Creates its own connection to avoid blocking main thread
*/
class EpgDatabase {
private db: BetterSqlite3.Database;
private knownChannelIds: Set<string> = new Set();
// Prepared statements for better performance
private insertChannelStmt: BetterSqlite3.Statement;
private insertProgramStmt: BetterSqlite3.Statement;
private deleteChannelsStmt: BetterSqlite3.Statement;
constructor() {
const dbPath = getIptvnatorDatabasePath();
this.db = new Database(dbPath);
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL'); // Better concurrent write performance
this.db.pragma('busy_timeout = 5000');
// Prepare statements
// Use INSERT OR REPLACE to update existing channels and refresh updated_at
// Use strftime with ISO format for consistent date comparison
this.insertChannelStmt = this.db.prepare(`
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
source_url = excluded.source_url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteChannelsStmt = this.db.prepare(`
DELETE FROM epg_channels WHERE source_url = ?
`);
}
/**
* Clear existing EPG data for a source URL
*/
clearSourceData(sourceUrl: string): void {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
/**
* Insert a batch of channels. When `clearFirst` is true, the existing rows
* for `sourceUrl` are deleted inside the same transaction as the insert so
* old data is preserved if the fetch/parse never produces any channels.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearFirst = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteChannelsStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
for (const channel of channels) {
const displayName =
channel.displayName?.[0]?.value || channel.id;
const iconUrl = channel.icon?.[0]?.src || null;
const url = channel.url?.[0] || null;
this.insertChannelStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.knownChannelIds.add(channel.id);
}
});
insertMany(channels);
}
/**
* Insert a batch of programs
* Only inserts programs for known channels to avoid FK constraint failures
*/
insertPrograms(programs: ParsedProgram[]): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
for (const prog of programs) {
// Skip if channel not known
if (!this.knownChannelIds.has(prog.channel)) continue;
const title = prog.title?.[0]?.value || 'Unknown';
const description = prog.desc?.[0]?.value || null;
const category = prog.category?.[0]?.value || null;
const iconUrl = prog.icon?.[0]?.src || null;
const rating = prog.rating?.[0]?.value || null;
const episodeNum = prog.episodeNum?.[0]?.value || null;
try {
this.insertProgramStmt.run(
prog.channel,
prog.start,
prog.stop,
title,
description,
category,
iconUrl,
rating,
episodeNum
);
insertedCount++;
} catch (err) {
// Skip individual failures (e.g., FK constraint)
}
}
});
insertMany(programs);
return insertedCount;
}
/**
* Close the database connection
*/
close(): void {
this.db.close();
}
}
/**
* Fetches and parses EPG data from URL using streaming
* Inserts directly into SQLite to avoid blocking main thread
@@ -221,7 +77,7 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
console.log(loggerLabel, `Fetching EPG from ${url}`);
// Create database connection in worker
const epgDb = new EpgDatabase();
const epgDb = new EpgDatabase(Database);
// Old rows for this source are retained until the first successful insert
// batch arrives — see `hasClearedSource` below. That way, a fetch or parse
@@ -230,13 +86,30 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
let hasClearedSource = false;
try {
const response = await fetch(url.trim());
const isGzipped = shouldGunzipEpgResponse(url, response);
// EPG URLs can originate from an untrusted M3U `url-tvg` attribute.
// Validate every redirect and require an explicit operator opt-in for
// private/LAN sources.
const response = await requestWithValidatedRedirects<Readable>(
url.trim(),
{
decompress: false,
method: 'GET',
responseType: 'stream',
},
{
allowPrivateNetworks: isPrivateNetworkUrlAccessAllowed(),
}
);
const responseUrl = response.config.url;
const isGzipped = shouldGunzipEpgResponse(url, {
headers: response.headers,
url: responseUrl,
});
if (response.url && response.url !== url) {
if (responseUrl && responseUrl !== url) {
console.log(
loggerLabel,
`Resolved EPG redirect: ${url} -> ${response.url}`
`Resolved EPG redirect: ${url} -> ${responseUrl}`
);
}
@@ -245,11 +118,11 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
`EPG response detected as gzipped: ${isGzipped}`
);
if (!response.ok) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
if (!response.data) {
throw new Error('Response body is null');
}
@@ -276,15 +149,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
PROGRAM_BATCH_SIZE
);
// Convert web stream to Node.js stream
const nodeStream = Readable.fromWeb(response.body as any);
return new Promise((resolve, reject) => {
let dataStream: Readable = nodeStream;
let dataStream: Readable = response.data;
if (isGzipped) {
const gunzip = createGunzip();
dataStream = nodeStream.pipe(gunzip);
dataStream = response.data.pipe(gunzip);
gunzip.on('error', (err) => {
console.error(loggerLabel, 'Gunzip error:', err);
@@ -364,16 +234,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
* Runs in worker thread to avoid blocking main thread
*/
function clearAllEpgData(): void {
const dbPath = getIptvnatorDatabasePath();
const db = new Database(dbPath);
const clearOperation = new EpgDatabaseClearOperation(Database);
try {
console.log(loggerLabel, 'Clearing all EPG data...');
// Delete programs first (foreign key constraint)
db.exec('DELETE FROM epg_programs');
// Then delete channels
db.exec('DELETE FROM epg_channels');
clearOperation.run();
console.log(loggerLabel, 'All EPG data cleared');
@@ -387,7 +253,7 @@ function clearAllEpgData(): void {
};
parentPort?.postMessage(errorResponse);
} finally {
db.close();
clearOperation.close();
}
}
@@ -37,11 +37,25 @@ describe('shouldGunzipEpgResponse', () => {
).toBe(true);
});
it('supports plain Axios response header objects', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide', {
headers: {
'Content-Type': 'application/gzip',
},
url: 'https://example.com/guide',
})
).toBe(true);
});
it('returns true when content-disposition advertises a .gz filename', () => {
expect(
shouldGunzipEpgResponse('https://example.com/guide', {
headers: new Headers([
['content-disposition', 'attachment; filename="guide.xml.gz"'],
[
'content-disposition',
'attachment; filename="guide.xml.gz"',
],
]),
url: 'https://example.com/guide',
})
@@ -1,3 +1,20 @@
type HeaderReader =
| Record<string, unknown>
| {
get(name: string): unknown;
};
function getHeaderValue(headers: HeaderReader, name: string): string | null {
const value =
'get' in headers && typeof headers.get === 'function'
? headers.get(name)
: Object.entries(headers).find(
([headerName]) =>
headerName.toLowerCase() === name.toLowerCase()
)?.[1];
return value === null || value === undefined ? null : String(value);
}
function hasGzipPath(url: string | null | undefined): boolean {
if (!url) {
return false;
@@ -16,13 +33,13 @@ function hasGzipPath(url: string | null | undefined): boolean {
*/
export function shouldGunzipEpgResponse(
originalUrl: string,
response: { headers: Headers; url?: string }
response: { headers: HeaderReader; url?: string }
): boolean {
if (hasGzipPath(originalUrl) || hasGzipPath(response.url)) {
return true;
}
const contentType = response.headers.get('content-type');
const contentType = getHeaderValue(response.headers, 'content-type');
if (
contentType &&
/(application\/gzip|application\/x-gzip)/i.test(contentType)
@@ -30,7 +47,10 @@ export function shouldGunzipEpgResponse(
return true;
}
const contentDisposition = response.headers.get('content-disposition');
const contentDisposition = getHeaderValue(
response.headers,
'content-disposition'
);
if (contentDisposition?.toLowerCase().includes('.gz')) {
return true;
}
@@ -1,8 +1,11 @@
import axios from 'axios';
import { parentPort } from 'worker_threads';
import { parse } from 'iptv-playlist-parser';
import { readFile } from 'node:fs/promises';
import { createPlaylistObject, getFilenameFromUrl } from '@iptvnator/shared/m3u-utils';
import {
createPlaylistObject,
getFilenameFromUrl,
} from '@iptvnator/shared/m3u-utils';
import { createPlaylistAgentFactory } from '../util/secure-https';
import type {
Playlist,
PlaylistRefreshEvent,
@@ -12,8 +15,7 @@ import type {
PlaylistRefreshWorkerIncomingMessage,
PlaylistRefreshWorkerMessage,
} from './playlist-refresh.worker.types';
const https = require('https');
import { requestWithValidatedRedirects } from '../util/validated-axios';
type ActiveRefreshState = {
cancelled: boolean;
@@ -23,7 +25,9 @@ type ActiveRefreshState = {
const activeRefreshes = new Map<string, ActiveRefreshState>();
if (!parentPort) {
throw new Error('Playlist refresh worker must be started with a parent port');
throw new Error(
'Playlist refresh worker must be started with a parent port'
);
}
function postMessage(message: PlaylistRefreshWorkerMessage<Playlist>): void {
@@ -78,14 +82,16 @@ async function fetchPlaylistFromUrl(
emitEvent(payload, { status: 'started', phase: 'fetching' });
checkpoint(payload);
const agent = new https.Agent({
rejectUnauthorized: false,
});
const result = await axios.get(payload.url!, {
httpsAgent: agent,
signal: controller.signal,
timeout: 30000,
});
const result = await requestWithValidatedRedirects<string>(
payload.url!,
{
agentFactory: createPlaylistAgentFactory(),
method: 'GET',
signal: controller.signal,
timeout: 30000,
},
{ allowPrivateNetworks: true }
);
checkpoint(payload);
emitEvent(payload, { status: 'progress', phase: 'parsing' });
@@ -129,7 +135,9 @@ async function fetchPlaylistFromFile(
);
}
async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist> {
async function executeRefresh(
payload: PlaylistRefreshPayload
): Promise<Playlist> {
const controller = new AbortController();
activeRefreshes.set(payload.operationId, {
cancelled: false,
@@ -148,41 +156,45 @@ async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist
}
}
parentPort.on('message', async (message: PlaylistRefreshWorkerIncomingMessage) => {
if (message.type === 'cancel') {
const active = activeRefreshes.get(message.operationId);
if (active) {
active.cancelled = true;
active.controller.abort();
parentPort.on(
'message',
async (message: PlaylistRefreshWorkerIncomingMessage) => {
if (message.type === 'cancel') {
const active = activeRefreshes.get(message.operationId);
if (active) {
active.cancelled = true;
active.controller.abort();
}
return;
}
return;
}
try {
const result = await executeRefresh(message.payload);
postMessage({
type: 'response',
success: true,
result,
});
} catch (error) {
const payload = message.payload;
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error: error instanceof Error ? error.message : String(error),
try {
const result = await executeRefresh(message.payload);
postMessage({
type: 'response',
success: true,
result,
});
} catch (error) {
const payload = message.payload;
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error:
error instanceof Error ? error.message : String(error),
});
}
postMessage({
type: 'response',
success: false,
error: serializeError(error),
});
}
postMessage({
type: 'response',
success: false,
error: serializeError(error),
});
}
});
);
postMessage({ type: 'ready' });