diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index c4720dd5e..e8ef3d09e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -275,12 +275,10 @@ 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. + // Probe URLs must be http(s), but may target private/LAN Xtream hosts + // for self-hosted setups. Redirects stay disabled below so a validated + // URL cannot bounce to a different private target. 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, }); diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index 8e97951e6..b9684dbac 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -5,6 +5,7 @@ function createDatabaseMock(exec: jest.Mock) { const database = { close: jest.fn(), exec, + pragma: jest.fn(), }; const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3; @@ -14,10 +15,11 @@ function createDatabaseMock(exec: jest.Mock) { describe('EpgDatabaseClearOperation', () => { it('clears programs and channels in one transaction', () => { const exec = jest.fn(); - const { Database } = createDatabaseMock(exec); + const { Database, database } = createDatabaseMock(exec); new EpgDatabaseClearOperation(Database).run(); + expect(database.pragma).toHaveBeenCalledWith('busy_timeout = 5000'); expect(exec.mock.calls.map(([statement]) => statement)).toEqual([ 'BEGIN', 'DELETE FROM epg_programs', diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index bcf5d18e4..27391ac67 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -126,6 +126,7 @@ export class EpgDatabaseClearOperation { constructor(Database: typeof BetterSqlite3) { this.db = new Database(getIptvnatorDatabasePath()); + this.db.pragma('busy_timeout = 5000'); } run(): void { diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index c26cb294e..d970093cf 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -12,7 +12,6 @@ import { DownloadsService, } from './downloads.service'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; -import { SettingsStore } from './settings-store.service'; type TestDownloadsService = { downloads: WritableSignal; @@ -22,16 +21,15 @@ type TestDownloadsService = { hasLoadedDownloads: Signal; loadDownloads: DownloadsService['loadDownloads']; loadDownloadFolder: DownloadsService['loadDownloadFolder']; + selectFolder: DownloadsService['selectFolder']; _isLoadingDownloads: WritableSignal; _hasLoadedDownloads: WritableSignal; loadDownloadsRequestId: number; - settingsStore: { - getDownloadFolder: () => string; - }; }; type DownloadsElectronStub = { downloadsGetDefaultFolder?: jest.Mock, []>; + downloadsSelectFolder?: jest.Mock, []>; downloadsGetList: jest.Mock, [string?]>; }; @@ -87,9 +85,6 @@ describe('DownloadsService', () => { _hasLoadedDownloads: hasLoadedDownloads, hasLoadedDownloads: hasLoadedDownloads.asReadonly(), loadDownloadsRequestId: 0, - settingsStore: { - getDownloadFolder: () => '/renderer-controlled', - }, }); return service; @@ -99,7 +94,6 @@ describe('DownloadsService', () => { const injector = createEnvironmentInjector( [ DownloadsService, - { provide: SettingsStore, useValue: {} }, { provide: RuntimeCapabilitiesService, useValue: { supportsDownloads: false }, @@ -156,6 +150,19 @@ describe('DownloadsService', () => { expect(electron.downloadsGetDefaultFolder).toHaveBeenCalledTimes(1); }); + it('stores a selected download folder returned by the main process', async () => { + const electron = { + downloadsSelectFolder: jest.fn(async () => '/selected'), + downloadsGetList: jest.fn(async () => []), + }; + testWindow.electron = electron; + const service = createService(); + + await expect(service.selectFolder()).resolves.toBe('/selected'); + expect(service.downloadFolder()).toBe('/selected'); + expect(electron.downloadsSelectFolder).toHaveBeenCalledTimes(1); + }); + it('marks downloads as loaded after a failed request while preserving existing data', async () => { const existing = createDownload(1); const error = new Error('download query failed'); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index fd550208a..4564e189c 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -1,6 +1,5 @@ import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; -import { SettingsStore } from './settings-store.service'; export type DownloadStatus = | 'queued' @@ -33,7 +32,6 @@ export interface DownloadItem { @Injectable({ providedIn: 'root' }) export class DownloadsService implements OnDestroy { private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly settingsStore = inject(SettingsStore); private unsubscribe?: () => void; private loadDownloadsRequestId = 0; @@ -330,8 +328,6 @@ export class DownloadsService implements OnDestroy { const folder = await window.electron.downloadsSelectFolder(); if (folder) { this.downloadFolder.set(folder); - // Save to settings - await this.settingsStore.updateSettings({ downloadFolder: folder }); } return folder; } catch (error) {