diff --git a/.changes/epg-local-xmltv-sources.md b/.changes/epg-local-xmltv-sources.md new file mode 100644 index 000000000..e609a2840 --- /dev/null +++ b/.changes/epg-local-xmltv-sources.md @@ -0,0 +1,7 @@ +--- +type: feature +area: epg +highlight: Local XMLTV files as EPG sources +--- + +EPG sources no longer have to be URLs: point Settings → EPG or a playlist's EPG list at an XMLTV file on your computer, by absolute path, `file://` link, or the new folder button. Plain `.xml` and gzip `.xml.gz` both work. A typed path is confirmed once in a native dialog before it is read; the settings page spells out the accepted formats with examples. diff --git a/CLAUDE.md b/CLAUDE.md index d02196c94..abd136b93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1583,7 +1583,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the **EPG (Electronic Program Guide)**: -- XMLTV format support +- XMLTV format support, from `http(s)` links or local files (Electron only): a `file:` URL, an absolute POSIX path, or a Windows drive/UNC path, plain `.xml` or gzip (detected by signature). A folder button beside each row opens the native picker (`EPG_OPEN_FILE_DIALOG`, `RuntimeCapabilitiesService.supportsEpgFilePicker`). Shape rules: `classifyEpgSourceReference` in `libs/shared/interfaces`; the worker opens both kinds through `openEpgSourceStream` (`workers/epg-source-stream.ts`). Only hand-chosen sources may be local: `extractM3uEpgUrls` harvests only remote links from M3U headers (legacy stored non-remote entries are dropped unless manual), and `EpgWorkerService.startFetch` asks the main-process `EpgLocalSourceAuthorizer` before a local path reaches the worker — picker results are trusted, a typed path is confirmed once in a native message box, allowed paths persist under `TRUSTED_LOCAL_EPG_SOURCES`, and the worker's local branch requires the main-set `allowLocalFile` flag (deny-all until wired). Contract: `docs/architecture/m3u-playlist-module.md` ("Local XMLTV files") - Background parsing in worker thread; HTTP/file gzip compatibility follows `docs/architecture/m3u-playlist-module.md` ("XMLTV response compression"). - Stored in database for quick lookup - Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset") diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index aacfd7ccb..58525a4af 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -1,3 +1,4 @@ +import { writeFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { join } from 'node:path'; @@ -233,10 +234,7 @@ test.describe('Electron EPG', () => { removedUrls: [], })); }); - await app.mainWindow - .locator('.epg-source-row button') - .nth(1) - .click(); + await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await expect( app.mainWindow.getByTestId('settings-unsaved-bar') @@ -296,6 +294,106 @@ test.describe('Electron EPG', () => { } }); + test('@epg @electron imports a local gzipped XMLTV file given as an absolute path', async ({ + dataDir, + }) => { + const localGuide = join(dataDir, 'local guide.xml.gz'); + writeFileSync(localGuide, gzipSync(epgFixtureXml)); + const app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await expect( + app.mainWindow.getByTestId('epg-source-formats') + ).toContainText('file://'); + await app.mainWindow + .getByRole('button', { name: 'Add EPG source' }) + .click(); + const field = app.mainWindow + .locator('.epg-source-row input') + .first(); + await field.fill('guide.xml'); + // Material reveals errors once the control is touched. + await field.blur(); + await expect( + app.mainWindow.locator('.epg-source-row mat-error') + ).toBeVisible(); + await field.fill(localGuide); + await expect( + app.mainWindow.locator('.epg-source-row mat-error') + ).toHaveCount(0); + + // Native dialogs cannot be driven from Playwright: stub them in + // the main process. A hand-typed path is confirmed once through + // the message box; a refused path is reported, never read. + await app.electronApp.evaluate(({ dialog }) => { + dialog.showMessageBox = (async () => ({ + response: 1, + checkboxChecked: false, + })) as typeof dialog.showMessageBox; + }); + await app.mainWindow.getByTestId('epg-source-refresh').click(); + await expect( + app.mainWindow.locator( + '.epg-progress-panel .import-item.status-error' + ) + ).toContainText('was not allowed'); + expect(await getEpgChannelCount(app.mainWindow)).toBe(0); + + await app.electronApp.evaluate(({ dialog }) => { + dialog.showMessageBox = (async () => ({ + response: 0, + checkboxChecked: false, + })) as typeof dialog.showMessageBox; + }); + await app.mainWindow + .locator('.epg-progress-panel .retry-btn') + .click(); + await expect + .poll(() => getEpgChannelCount(app.mainWindow), { + timeout: 30000, + }) + .toBeGreaterThan(0); + await expect( + app.mainWindow.locator( + '.epg-progress-panel .import-item.status-complete' + ) + ).toHaveCount(1); + + // A file chosen in the native picker is trusted without a prompt. + const browsedGuide = join(dataDir, 'browsed guide.xml.gz'); + writeFileSync(browsedGuide, gzipSync(epgFixtureXml)); + await app.electronApp.evaluate(({ dialog }, filePath) => { + dialog.showOpenDialog = (async () => ({ + canceled: false, + filePaths: [filePath], + })) as typeof dialog.showOpenDialog; + dialog.showMessageBox = (async () => { + throw new Error('picked files must not prompt'); + }) as typeof dialog.showMessageBox; + }, browsedGuide); + await app.mainWindow.getByTestId('epg-source-browse').click(); + await expect(field).toHaveValue(browsedGuide); + await app.mainWindow.getByTestId('epg-source-refresh').click(); + await expect( + app.mainWindow.locator( + '.epg-progress-panel .import-item.status-complete' + ) + ).toHaveCount(2); + + // Leave with a pristine form: a dirty settings form arms the + // main-process close guard, and the app would then wait for a + // confirmation dialog instead of closing. + await app.mainWindow.getByTestId('discard-settings').click(); + await expect( + app.mainWindow.getByTestId('settings-unsaved-bar') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({ dataDir, }) => { @@ -316,10 +414,7 @@ test.describe('Electron EPG', () => { .first() .fill(epgServer.resourceUrl); - await app.mainWindow - .locator('.epg-source-row button') - .first() - .click(); + await app.mainWindow.getByTestId('epg-source-refresh').click(); await expect( app.mainWindow.locator('.epg-progress-panel') ).toBeVisible(); @@ -355,10 +450,7 @@ test.describe('Electron EPG', () => { await saveSettings(app.mainWindow); - await app.mainWindow - .locator('.epg-source-row button') - .nth(1) - .click(); + await app.mainWindow.getByTestId('epg-source-remove').click(); await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount( 0 ); @@ -436,10 +528,7 @@ test.describe('Electron EPG', () => { '.epg-progress-panel .import-item.status-error' ) ).toHaveCount(1); - await app.mainWindow - .locator('.epg-source-row button') - .nth(1) - .click(); + await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await expect( app.mainWindow.locator('.epg-progress-panel .import-item') @@ -502,8 +591,7 @@ test.describe('Electron EPG', () => { await app.mainWindow .locator('.epg-source-row') .first() - .locator('button') - .nth(1) + .getByTestId('epg-source-remove') .click(); // A staged removal must not delete data before Save. expect(await programs()).toContain('Removed Bulletin'); @@ -523,10 +611,7 @@ test.describe('Electron EPG', () => { await expect.poll(programs).toEqual(['Retained Bulletin']); await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); - await app.mainWindow - .locator('.epg-source-row button') - .nth(1) - .click(); + await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await expect.poll(programs).toEqual([]); await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0); @@ -592,8 +677,7 @@ test.describe('Electron EPG', () => { await app.mainWindow .locator('.epg-source-row') .nth(1) - .locator('button') - .nth(1) + .getByTestId('epg-source-remove') .click(); await saveSettings(app.mainWindow); const firstMetadata = { @@ -701,10 +785,7 @@ test.describe('Electron EPG', () => { .locator('.epg-source-row input') .fill(epgServer.resourceUrl); await saveSettings(app.mainWindow); - await app.mainWindow - .locator('.epg-source-row button') - .nth(1) - .click(); + await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); // The same URL still belongs to the saved M3U playlist. const retained = await app.mainWindow.evaluate(async () => @@ -771,10 +852,7 @@ test.describe('Electron EPG', () => { .locator('.epg-source-row input') .first() .fill(epgServer.resourceUrl); - await app.mainWindow - .locator('.epg-source-row button') - .first() - .click(); + await app.mainWindow.getByTestId('epg-source-refresh').click(); await expect .poll(() => getEpgChannelCount(app.mainWindow), { diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 02eeb28e3..dec87b886 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -96,7 +96,7 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E .toContain('Temporary XMLTV Bulletin'); await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); - await app.mainWindow.locator('.epg-source-row button').nth(1).click(); + await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); @@ -190,7 +190,7 @@ test('@epg @stalker @electron invalidates a loaded manual XMLTV mapping after so .toContain('Retired Stalker Bulletin'); await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); - await app.mainWindow.locator('.epg-source-row button').nth(1).click(); + await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index a3e895386..c9c597bb3 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -550,6 +550,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [ }, ], }, + { + method: 'openEpgFileDialog', + args: [], + channel: 'EPG_OPEN_FILE_DIALOG', + forwardedArgs: [], + }, { method: 'clearEpgData', args: [], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 580167b15..ea4ad442e 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -680,6 +680,7 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window), forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) => ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }), + openEpgFileDialog: () => ipcRenderer.invoke('EPG_OPEN_FILE_DIALOG'), clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'), reconcileEpgSources: (urls: string[]) => ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }), diff --git a/apps/electron-backend/src/app/events/epg-guide.events.spec.ts b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts index ccf491003..cc8f7eff9 100644 --- a/apps/electron-backend/src/app/events/epg-guide.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts @@ -9,6 +9,11 @@ const ipcHandlers = new Map< (event: unknown, args: unknown) => Promise >(); +jest.mock('../services/store.service', () => ({ + TRUSTED_LOCAL_EPG_SOURCES: 'TRUSTED_LOCAL_EPG_SOURCES', + store: { get: jest.fn(), set: jest.fn() }, +})); + jest.mock('electron', () => ({ app: { isPackaged: false, diff --git a/apps/electron-backend/src/app/events/epg-local-source-authorizer.spec.ts b/apps/electron-backend/src/app/events/epg-local-source-authorizer.spec.ts new file mode 100644 index 000000000..253cd01cb --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-local-source-authorizer.spec.ts @@ -0,0 +1,106 @@ +jest.mock('electron', () => ({ + BrowserWindow: { getFocusedWindow: () => null, getAllWindows: () => [] }, + dialog: { showMessageBox: jest.fn() }, +})); + +import { dialog } from 'electron'; +import { + PersistedEpgLocalSourceAuthorizer, + promptForLocalEpgSource, +} from './epg-local-source-authorizer'; + +describe('PersistedEpgLocalSourceAuthorizer', () => { + let stored: string[]; + let prompt: jest.Mock; + let authorizer: PersistedEpgLocalSourceAuthorizer; + + beforeEach(() => { + stored = []; + prompt = jest.fn(); + authorizer = new PersistedEpgLocalSourceAuthorizer( + { + load: () => [...stored], + save: (paths) => { + stored = [...paths]; + }, + }, + prompt + ); + }); + + it('trusts a picked file without prompting and persists it once', async () => { + authorizer.authorize('/epg/guide.xml'); + authorizer.authorize('/epg/guide.xml'); + + await expect(authorizer.ensureAllowed('/epg/guide.xml')).resolves.toBe( + true + ); + expect(prompt).not.toHaveBeenCalled(); + expect(stored).toEqual(['/epg/guide.xml']); + }); + + it('persists a hand-typed path only after the user allowed it', async () => { + prompt.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + await expect(authorizer.ensureAllowed('/epg/typed.xml')).resolves.toBe( + false + ); + expect(stored).toEqual([]); + + await expect(authorizer.ensureAllowed('/epg/typed.xml')).resolves.toBe( + true + ); + expect(stored).toEqual(['/epg/typed.xml']); + expect(prompt).toHaveBeenCalledTimes(2); + + await expect(authorizer.ensureAllowed('/epg/typed.xml')).resolves.toBe( + true + ); + expect(prompt).toHaveBeenCalledTimes(2); + }); + + it('shares one prompt between concurrent requests for the same file', async () => { + let resolvePrompt: (allowed: boolean) => void = () => undefined; + prompt.mockReturnValue( + new Promise((resolve) => { + resolvePrompt = resolve; + }) + ); + + const first = authorizer.ensureAllowed('/epg/shared.xml'); + const second = authorizer.ensureAllowed('/epg/shared.xml'); + expect(prompt).toHaveBeenCalledTimes(1); + + resolvePrompt(true); + await expect(Promise.all([first, second])).resolves.toEqual([ + true, + true, + ]); + expect(stored).toEqual(['/epg/shared.xml']); + }); +}); + +describe('promptForLocalEpgSource', () => { + it('maps the Allow button to true and anything else to false', async () => { + (dialog.showMessageBox as jest.Mock).mockResolvedValueOnce({ + response: 0, + }); + await expect(promptForLocalEpgSource('/epg/guide.xml')).resolves.toBe( + true + ); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Allow', 'Cancel'], + cancelId: 1, + detail: expect.stringContaining('/epg/guide.xml'), + }) + ); + + (dialog.showMessageBox as jest.Mock).mockResolvedValueOnce({ + response: 1, + }); + await expect(promptForLocalEpgSource('/epg/guide.xml')).resolves.toBe( + false + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-local-source-authorizer.ts b/apps/electron-backend/src/app/events/epg-local-source-authorizer.ts new file mode 100644 index 000000000..63cf56fbe --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-local-source-authorizer.ts @@ -0,0 +1,96 @@ +import { BrowserWindow, dialog } from 'electron'; + +/** + * Main-process gate for local XMLTV files. + * + * The renderer hands EPG source strings to `FETCH_EPG`/`EPG_FORCE_FETCH` + * unchanged, so a compromised renderer could name any file on disk. Like + * `save-file-dialog` → `write-file` for playlist exports, the decision + * therefore lives here: a path is readable only after the native picker + * returned it or the user allowed it in a native confirmation the renderer + * cannot fake. Allowed paths persist in the main-process config so startup + * refreshes need no prompt. + */ +export interface EpgLocalSourceAuthorizer { + /** A file the user chose in the native picker is trusted right away. */ + authorize(filePath: string): void; + /** Whether `filePath` may be read; prompts once for a hand-typed path. */ + ensureAllowed(filePath: string): Promise; +} + +export interface EpgLocalSourcePersistence { + load(): string[]; + save(filePaths: string[]): void; +} + +export const EPG_LOCAL_SOURCE_REFUSED_MESSAGE = + 'Reading this local file was not allowed. Retry to be asked again.'; + +/** Refuses every local file; the real authorizer is wired at registration. */ +export const DENY_ALL_EPG_LOCAL_SOURCES: EpgLocalSourceAuthorizer = { + authorize: () => undefined, + ensureAllowed: () => Promise.resolve(false), +}; + +export class PersistedEpgLocalSourceAuthorizer implements EpgLocalSourceAuthorizer { + private readonly pendingPrompts = new Map>(); + + constructor( + private readonly persistence: EpgLocalSourcePersistence, + private readonly prompt: (filePath: string) => Promise + ) {} + + authorize(filePath: string): void { + const allowed = this.persistence.load(); + if (allowed.includes(filePath)) { + return; + } + this.persistence.save([...allowed, filePath]); + } + + ensureAllowed(filePath: string): Promise { + if (this.persistence.load().includes(filePath)) { + return Promise.resolve(true); + } + // Concurrent fetches of the same file (settings save plus a startup + // refresh) share one prompt instead of stacking dialogs. + const pending = this.pendingPrompts.get(filePath); + if (pending) { + return pending; + } + const decision = this.prompt(filePath) + .then((allowed) => { + if (allowed) { + this.authorize(filePath); + } + return allowed; + }) + .finally(() => { + this.pendingPrompts.delete(filePath); + }); + this.pendingPrompts.set(filePath, decision); + return decision; + } +} + +/** Native confirmation for a hand-typed local EPG path. */ +export async function promptForLocalEpgSource( + filePath: string +): Promise { + const options: Electron.MessageBoxOptions = { + type: 'question', + title: 'Allow local EPG file?', + message: 'Read this file as an EPG source?', + detail: `${filePath}\n\nIPTVnator will read the XMLTV guide data in this file. Only allow files you added yourself.`, + buttons: ['Allow', 'Cancel'], + defaultId: 0, + cancelId: 1, + noLink: true, + }; + const window = + BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const { response } = window + ? await dialog.showMessageBox(window, options) + : await dialog.showMessageBox(options); + return response === 0; +} diff --git a/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts index 0af8125fe..e0110f6b8 100644 --- a/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts @@ -11,6 +11,11 @@ const ipcHandlers = new Map< (event: unknown, args: unknown) => Promise >(); +jest.mock('../services/store.service', () => ({ + TRUSTED_LOCAL_EPG_SOURCES: 'TRUSTED_LOCAL_EPG_SOURCES', + store: { get: jest.fn(), set: jest.fn() }, +})); + jest.mock('electron', () => ({ app: { isPackaged: false, diff --git a/apps/electron-backend/src/app/events/epg-worker.service.spec.ts b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts index 34f501a66..ecb0f278d 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts @@ -38,6 +38,7 @@ jest.mock('../workers/worker-runtime-paths', () => ({ mockResolveWorkerRuntimeBootstrap(...args), })); +import { retireEpgSource } from './epg-source-generation'; import { EpgWorkerService } from './epg-worker.service'; describe('EpgWorkerService worker lifecycle', () => { @@ -166,6 +167,116 @@ describe('EpgWorkerService worker lifecycle', () => { expect(service.hasFetchedUrl(url)).toBe(true); }); + it('refuses a local file the authorizer denies without spawning a worker', async () => { + const localPath = '/home/user/epg/guide.xml.gz'; + const progressSpy = jest.spyOn(service, 'sendProgressToRenderer'); + service.localSourceAuthorizer = { + authorize: jest.fn(), + ensureAllowed: jest.fn().mockResolvedValue(false), + }; + + await expect( + service.fetchEpgFromUrl(localPath, { + allowLocalFile: true, + } as any) + ).rejects.toThrow('was not allowed'); + + expect( + service.localSourceAuthorizer.ensureAllowed + ).toHaveBeenCalledWith(localPath); + expect(mockWorkerInstances).toHaveLength(0); + expect(progressSpy).toHaveBeenCalledWith( + localPath, + 'error', + undefined, + expect.stringContaining('was not allowed') + ); + expect(service.hasFetchedUrl(localPath)).toBe(false); + }); + + it('cancels a local source retired while its authorization prompt is open', async () => { + const localPath = '/home/user/epg/retired.xml.gz'; + const progressSpy = jest.spyOn(service, 'sendProgressToRenderer'); + let answer: (allowed: boolean) => void = () => undefined; + service.localSourceAuthorizer = { + authorize: jest.fn(), + ensureAllowed: jest.fn( + () => + new Promise((resolve) => { + answer = resolve; + }) + ), + }; + + const fetchPromise = service.fetchEpgFromUrl(localPath); + await Promise.resolve(); + retireEpgSource(localPath); + answer(true); + + await expect(fetchPromise).resolves.toBeUndefined(); + expect(mockWorkerInstances).toHaveLength(0); + expect(progressSpy).toHaveBeenCalledWith( + localPath, + 'cancelled', + undefined, + undefined, + undefined, + undefined, + undefined, + 0 + ); + }); + + it('unlocks the worker local branch only for an allowed file', async () => { + const localPath = '/home/user/epg/guide.xml.gz'; + service.localSourceAuthorizer = { + authorize: jest.fn(), + ensureAllowed: jest.fn().mockResolvedValue(true), + }; + + const fetchPromise = service.fetchEpgFromUrl(localPath, { + trustedInsecureTlsHosts: ['example.com'], + }); + await Promise.resolve(); + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + + expect(worker.postMessage).toHaveBeenCalledWith({ + type: 'FETCH_EPG', + url: localPath, + options: { + trustedInsecureTlsHosts: ['example.com'], + allowLocalFile: true, + }, + }); + + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + await expect(fetchPromise).resolves.toBeUndefined(); + }); + + it('never forwards a renderer-supplied allowLocalFile flag for remote sources', async () => { + const fetchPromise = service.fetchEpgFromUrl(url, { + allowLocalFile: true, + } as any); + await Promise.resolve(); + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + + expect(worker.postMessage).toHaveBeenCalledWith({ + type: 'FETCH_EPG', + url, + options: {}, + }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + await expect(fetchPromise).resolves.toBeUndefined(); + }); + it('rejects when the worker emits an error event', async () => { const fetchPromise = service.fetchEpgFromUrl(url); const worker = mockWorkerInstances[0]; diff --git a/apps/electron-backend/src/app/events/epg-worker.service.ts b/apps/electron-backend/src/app/events/epg-worker.service.ts index 0df02d09e..9e6827071 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -7,6 +7,12 @@ import { import { BrowserWindow } from 'electron'; import { EpgWorkerRuntime } from './epg-worker-runtime'; import { runEpgFetch } from './epg-fetch-operation'; +import { + DENY_ALL_EPG_LOCAL_SOURCES, + EPG_LOCAL_SOURCE_REFUSED_MESSAGE, + EpgLocalSourceAuthorizer, +} from './epg-local-source-authorizer'; +import { resolveLocalEpgSourcePath } from '../util/epg-local-source-path'; import { Worker } from 'worker_threads'; import { ElectronBridgeSecurityErrorCode, @@ -118,18 +124,76 @@ export class EpgWorkerService { return; } - const fetchPromise = this.startFetch(url, options).finally(() => { - this.inFlightFetches.delete(url); - }); + const fetchPromise = this.startFetch(url, options, generation).finally( + () => { + this.inFlightFetches.delete(url); + } + ); this.inFlightFetches.set(url, fetchPromise); return fetchPromise; } - private startFetch( + /** + * Gate for local XMLTV files; `epg.events.ts` installs the persisted, + * prompting authorizer at registration. Until then every local path is + * refused, so a missing wiring fails closed rather than open. + */ + localSourceAuthorizer: EpgLocalSourceAuthorizer = + DENY_ALL_EPG_LOCAL_SOURCES; + + private async startFetch( url: string, - options: ElectronBridgeTrustOptions + options: ElectronBridgeTrustOptions, + generation: number ): Promise { - return runEpgFetch(url, options, { + // The renderer's options must not be able to unlock the worker's + // local branch: the flag is set here, only for an allowed path. + const { allowLocalFile: _rendererFlag, ...trustOptions } = + options as ElectronBridgeTrustOptions & { + allowLocalFile?: unknown; + }; + let workerOptions: ElectronBridgeTrustOptions & { + allowLocalFile?: boolean; + } = trustOptions; + const localPath = resolveLocalEpgSourcePath(url); + if (localPath) { + const allowed = + await this.localSourceAuthorizer.ensureAllowed(localPath); + // The prompt can stay open for a while; a source retired + // meanwhile (settings save, reconciliation) must end as + // cancelled rather than start an import the clear then awaits. + if (generation !== epgSourceGeneration(url)) { + this.sendProgressToRenderer( + url, + 'cancelled', + undefined, + undefined, + undefined, + undefined, + undefined, + generation + ); + return; + } + if (!allowed) { + epgLogger.log( + this.loggerLabel, + 'Local EPG file was not allowed by the user' + ); + this.sendProgressToRenderer( + url, + 'error', + undefined, + EPG_LOCAL_SOURCE_REFUSED_MESSAGE + ); + // A refusal is a failed fetch, not a completed one: the + // renderer's fetch result and EPG availability must not + // claim the file was read. + throw new Error(EPG_LOCAL_SOURCE_REFUSED_MESSAGE); + } + workerOptions = { ...trustOptions, allowLocalFile: true }; + } + return runEpgFetch(url, workerOptions, { runtime: this.runtime, workers: this.workers, fetchedUrls: this.fetchedUrls, diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index c1c2cc226..b39bdfe1a 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -39,6 +39,11 @@ jest.mock('../workers/worker-runtime-paths', () => ({ resolveWorkerRuntimeBootstrap(...args), })); +jest.mock('../services/store.service', () => ({ + TRUSTED_LOCAL_EPG_SOURCES: 'TRUSTED_LOCAL_EPG_SOURCES', + store: { get: jest.fn(), set: jest.fn() }, +})); + jest.mock('../database/connection', () => ({ getDatabase: (...args: unknown[]) => getDatabase(...args), })); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 91801b10b..c84c6db11 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,5 +1,10 @@ import { reconcileEpgSources } from './epg-source-settings.service'; -import { ipcMain } from 'electron'; +import { dialog, ipcMain } from 'electron'; +import { store, TRUSTED_LOCAL_EPG_SOURCES } from '../services/store.service'; +import { + PersistedEpgLocalSourceAuthorizer, + promptForLocalEpgSource, +} from './epg-local-source-authorizer'; import { ElectronBridgeCurrentProgramsOptions, ElectronBridgeEpgGuideWindow, @@ -131,6 +136,24 @@ export default class EpgEvents { } ); + epgWorkerService.localSourceAuthorizer = + new PersistedEpgLocalSourceAuthorizer( + { + load: () => store.get(TRUSTED_LOCAL_EPG_SOURCES) ?? [], + save: (filePaths) => + store.set(TRUSTED_LOCAL_EPG_SOURCES, filePaths), + }, + promptForLocalEpgSource + ); + + ipcMain.handle('EPG_OPEN_FILE_DIALOG', async () => { + const filePath = await this.pickEpgSourceFile(); + if (filePath) { + epgWorkerService.localSourceAuthorizer.authorize(filePath); + } + return filePath; + }); + ipcMain.handle('EPG_CLEAR_ALL', async () => { await this.clearEpgData(); return { success: true }; @@ -207,6 +230,22 @@ export default class EpgEvents { return ipcMain; } + /** + * Native picker for a local XMLTV file. Resolves the absolute path the + * user chose, or null on cancel; the renderer stores it as the source + * value and the worker reads it through `openEpgSourceStream`. + */ + private static async pickEpgSourceFile(): Promise { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [ + { name: 'XMLTV', extensions: ['xml', 'gz', 'xmltv'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + return canceled || filePaths.length === 0 ? null : filePaths[0]; + } + private static async checkEpgFreshness( urls: string[], maxAgeHours: number diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index d53338efb..9bdb10d30 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -28,6 +28,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE'; /** Desktop portal request cooldown; absent means enabled. */ export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD'; +/** + * Local XMLTV files the EPG importer may read: every path the native picker + * returned plus every hand-typed path the user allowed in the main-process + * confirmation (`epg-local-source-authorizer.ts`). Owned by main, never by + * the renderer, because the renderer is what this list defends against. + */ +export const TRUSTED_LOCAL_EPG_SOURCES = 'TRUSTED_LOCAL_EPG_SOURCES'; + /** * Extra libmpv options for embedded sessions, one "key=value" per line, as * typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler @@ -56,6 +64,7 @@ export type StoreType = { [EMBEDDED_MPV_AUTO_RECONNECT]: boolean; [STARTUP_WINDOW_MODE]: StartupWindowMode; [PORTAL_CONNECTIVITY_GUARD]: boolean; + [TRUSTED_LOCAL_EPG_SOURCES]: string[]; }; // Export singleton store instance diff --git a/apps/electron-backend/src/app/util/epg-local-source-path.spec.ts b/apps/electron-backend/src/app/util/epg-local-source-path.spec.ts new file mode 100644 index 000000000..3ed9b7b39 --- /dev/null +++ b/apps/electron-backend/src/app/util/epg-local-source-path.spec.ts @@ -0,0 +1,24 @@ +import { join } from 'path'; +import { pathToFileURL } from 'url'; +import { resolveLocalEpgSourcePath } from './epg-local-source-path'; + +describe('resolveLocalEpgSourcePath', () => { + const file = join(process.cwd(), 'epg', 'guide v2.xml.gz'); + + it('maps a file URL and the plain path to the same normalized path', () => { + expect(resolveLocalEpgSourcePath(pathToFileURL(file).href)).toBe(file); + expect(resolveLocalEpgSourcePath(` ${file} `)).toBe(file); + expect( + resolveLocalEpgSourcePath(join(file, '..', 'guide v2.xml.gz')) + ).toBe(file); + }); + + it('returns null for remote links, relative paths and empty values', () => { + expect( + resolveLocalEpgSourcePath('https://epg.example.org/guide.xml') + ).toBeNull(); + expect(resolveLocalEpgSourcePath('epg/guide.xml')).toBeNull(); + expect(resolveLocalEpgSourcePath('')).toBeNull(); + expect(resolveLocalEpgSourcePath('file://')).toBeNull(); + }); +}); diff --git a/apps/electron-backend/src/app/util/epg-local-source-path.ts b/apps/electron-backend/src/app/util/epg-local-source-path.ts new file mode 100644 index 000000000..97977bd16 --- /dev/null +++ b/apps/electron-backend/src/app/util/epg-local-source-path.ts @@ -0,0 +1,25 @@ +import { isAbsolute, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { classifyEpgSourceReference } from '@iptvnator/shared/interfaces'; + +/** + * The filesystem path behind an EPG source reference, or null when the + * reference is not a local file (a remote link, an empty value or a relative + * path). Shared by the main-process authorizer and the worker so both agree + * on which string names which file: a `file:` URL and the plain path it + * encodes normalize to the same value. + */ +export function resolveLocalEpgSourcePath(reference: string): string | null { + const trimmed = reference.trim(); + if (classifyEpgSourceReference(trimmed) !== 'local') { + return null; + } + if (/^file:/i.test(trimmed)) { + try { + return resolve(fileURLToPath(trimmed)); + } catch { + return null; + } + } + return isAbsolute(trimmed) ? resolve(trimmed) : null; +} diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index c2567f652..1ab2aa661 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -5,30 +5,20 @@ import { ElectronBridgeSecurityErrorCode, ElectronBridgeTrustOptions, } from '@iptvnator/shared/interfaces'; -import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; import { EpgDatabase, EpgDatabaseClearOperation, EpgDatabaseSourceClearOperation, } from './epg-database'; -import { createDecodedEpgStream } from './epg-stream-decoder'; +import { openEpgSourceStream } from './epg-source-stream'; import { StreamingEpgParser } from './epg-streaming-parser'; -import { - getEpgResponseContentEncoding, - shouldGunzipEpgResponse, -} from './epg-response-utils'; -import { - isPrivateNetworkUrlAccessAllowed, - UnsafeUrlError, -} from '../events/url-safety'; -import { createPlaylistAgentFactory } from '../util/secure-https'; +import { UnsafeUrlError } from '../events/url-safety'; import { getHostnameFromErrorUrl, getHostnameFromUrl, isInvalidTlsCertificateError, } from '../util/security-errors'; -import { requestWithValidatedRedirects } from '../util/validated-axios'; import { getNativeModuleSearchPaths, getWorkerDataNativeModuleSearchPaths, @@ -115,54 +105,7 @@ async function fetchAndParseEpgStreaming( let hasClearedSource = false; try { - // 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( - url.trim(), - { - agentFactory: createPlaylistAgentFactory({ - trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, - }), - decompress: false, - method: 'GET', - responseType: 'stream', - }, - { - allowPrivateNetworks: - isPrivateNetworkUrlAccessAllowed() || - isTrustedPrivateNetworkEpgSource(url, options), - } - ); - const responseUrl = response.config.url; - const isGzipped = shouldGunzipEpgResponse(url, { - headers: response.headers, - url: responseUrl, - }); - const contentEncoding = getEpgResponseContentEncoding(response.headers); - - if (responseUrl && responseUrl !== url) { - epgLogger.log(loggerLabel, 'Resolved EPG redirect'); - } - - epgLogger.log( - loggerLabel, - `EPG response detected as gzipped: ${isGzipped}` - ); - if (contentEncoding) { - epgLogger.log( - loggerLabel, - `EPG response content-encoding: ${contentEncoding}` - ); - } - - if (response.status < 200 || response.status >= 300) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - if (!response.data) { - throw new Error('Response body is null'); - } + const decodedStream = await openEpgSourceStream(url, options); const parser = new StreamingEpgParser( (channels) => { @@ -188,11 +131,7 @@ async function fetchAndParseEpgStreaming( ); return new Promise((resolve, reject) => { - const dataStream = createDecodedEpgStream( - response.data, - response.headers, - isGzipped - ); + const dataStream = decodedStream; dataStream.on('data', (chunk: Buffer) => { try { @@ -260,18 +199,6 @@ async function fetchAndParseEpgStreaming( } } -function isTrustedPrivateNetworkEpgSource( - url: string, - options: ElectronBridgeTrustOptions -): boolean { - const normalizedUrl = url.trim(); - return ( - options.trustedPrivateNetworkEpgUrls?.some( - (trustedUrl) => trustedUrl.trim() === normalizedUrl - ) ?? false - ); -} - function toEpgFetchError( error: unknown, url: string diff --git a/apps/electron-backend/src/app/workers/epg-source-stream.spec.ts b/apps/electron-backend/src/app/workers/epg-source-stream.spec.ts new file mode 100644 index 000000000..f5a4b338e --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-source-stream.spec.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Readable } from 'stream'; +import { pathToFileURL } from 'url'; +import { gzipSync } from 'zlib'; + +const requestWithValidatedRedirects = jest.fn(); + +jest.mock('../util/validated-axios', () => ({ + requestWithValidatedRedirects: (...args: unknown[]) => + requestWithValidatedRedirects(...args), +})); +jest.mock('../util/secure-https', () => ({ + createPlaylistAgentFactory: () => ({}), +})); +jest.mock('../events/url-safety', () => ({ + isPrivateNetworkUrlAccessAllowed: () => false, +})); +jest.mock('../util/epg-logger', () => ({ + epgLogger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +import { openEpgSourceStream } from './epg-source-stream'; + +const LOCAL = { allowLocalFile: true }; + +const xmltv = + '' + + 'One'; + +async function readAll(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +describe('openEpgSourceStream', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'iptvnator-epg-source-')); + requestWithValidatedRedirects.mockReset(); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads a plain XML file from an absolute path', async () => { + const file = join(dir, 'guide.xml'); + writeFileSync(file, xmltv); + + await expect( + readAll(await openEpgSourceStream(file, LOCAL)) + ).resolves.toBe(xmltv); + expect(requestWithValidatedRedirects).not.toHaveBeenCalled(); + }); + + it('gunzips a .xml.gz file', async () => { + const file = join(dir, 'guide.xml.gz'); + writeFileSync(file, gzipSync(xmltv)); + + await expect( + readAll(await openEpgSourceStream(file, LOCAL)) + ).resolves.toBe(xmltv); + }); + + it('detects gzip by signature when the extension is missing', async () => { + const file = join(dir, 'guide'); + writeFileSync(file, gzipSync(xmltv)); + + await expect( + readAll(await openEpgSourceStream(file, LOCAL)) + ).resolves.toBe(xmltv); + }); + + it('accepts a file: URL with percent-encoded characters', async () => { + const file = join(dir, 'guide v2.xml'); + writeFileSync(file, xmltv); + + await expect( + readAll(await openEpgSourceStream(pathToFileURL(file).href, LOCAL)) + ).resolves.toBe(xmltv); + }); + + it('tolerates surrounding whitespace in the stored value', async () => { + const file = join(dir, 'guide.xml'); + writeFileSync(file, xmltv); + + await expect( + readAll(await openEpgSourceStream(` ${file} `, LOCAL)) + ).resolves.toBe(xmltv); + }); + + it('reports a missing file with its path instead of an ENOENT code', async () => { + const file = join(dir, 'missing.xml'); + + await expect(openEpgSourceStream(file, LOCAL)).rejects.toThrow( + `EPG file not found: ${file}` + ); + }); + + it('refuses a directory', async () => { + const folder = join(dir, 'epg'); + mkdirSync(folder); + + await expect(openEpgSourceStream(folder, LOCAL)).rejects.toThrow( + `EPG source is not a file: ${folder}` + ); + }); + + it('fails a truncated gzip file instead of parsing it as XML', async () => { + const file = join(dir, 'guide.xml.gz'); + writeFileSync(file, gzipSync(xmltv).subarray(0, 20)); + + await expect( + readAll(await openEpgSourceStream(file, LOCAL)) + ).rejects.toThrow(); + }); + + it('routes remote links through the validated HTTP client', async () => { + requestWithValidatedRedirects.mockResolvedValue({ + config: { url: 'https://epg.example.org/guide.xml' }, + data: Readable.from([Buffer.from(xmltv)]), + headers: {}, + status: 200, + }); + + const stream = await openEpgSourceStream( + 'https://epg.example.org/guide.xml', + { trustedPrivateNetworkEpgUrls: [] } + ); + + await expect(readAll(stream)).resolves.toBe(xmltv); + expect(requestWithValidatedRedirects).toHaveBeenCalledWith( + 'https://epg.example.org/guide.xml', + expect.objectContaining({ responseType: 'stream' }), + { allowPrivateNetworks: false } + ); + }); + + it('refuses a local file the main process did not authorize', async () => { + const file = join(dir, 'guide.xml'); + writeFileSync(file, xmltv); + + await expect(openEpgSourceStream(file)).rejects.toThrow( + 'Local EPG file was not authorized' + ); + await expect( + openEpgSourceStream(file, { + allowLocalFile: 'yes' as unknown as boolean, + }) + ).rejects.toThrow('Local EPG file was not authorized'); + expect(requestWithValidatedRedirects).not.toHaveBeenCalled(); + }); + + it('never reads a relative path from disk', async () => { + await expect(openEpgSourceStream('guide.xml')).rejects.toThrow(); + expect(requestWithValidatedRedirects).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/workers/epg-source-stream.ts b/apps/electron-backend/src/app/workers/epg-source-stream.ts new file mode 100644 index 000000000..6e3eadc3e --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-source-stream.ts @@ -0,0 +1,151 @@ +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import { PassThrough, Readable, pipeline } from 'stream'; +import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces'; +import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety'; +import { epgLogger } from '../util/epg-logger'; +import { resolveLocalEpgSourcePath } from '../util/epg-local-source-path'; +import { createPlaylistAgentFactory } from '../util/secure-https'; +import { requestWithValidatedRedirects } from '../util/validated-axios'; +import { createOptionalEpgGunzip } from './epg-optional-gunzip'; +import { + getEpgResponseContentEncoding, + shouldGunzipEpgResponse, +} from './epg-response-utils'; +import { createDecodedEpgStream } from './epg-stream-decoder'; + +const loggerLabel = '[EPG Worker]'; + +/** Trust options plus the main-process verdict on a local file. */ +export type EpgWorkerFetchOptions = ElectronBridgeTrustOptions & { + /** + * Set by `EpgWorkerService.startFetch` only after the main-process + * authorizer allowed the path (native picker or native confirmation). + * Never taken from the renderer. + */ + allowLocalFile?: boolean; +}; + +/** + * Opens the decoded XMLTV byte stream for an EPG source. + * + * Remote sources go through the validated-redirect HTTP client with the + * private-network and TLS trust policy. Local sources — a `file:` URL or an + * absolute path — are read from disk; gzip is detected from the file's own + * signature, so `guide.xml`, `guide.xml.gz` and a gzip file without the + * extension all work. + * + * The local branch bypasses `validateRemoteUrl`, which only knows http(s). + * Two things make that safe: header-declared M3U sources are filtered to + * remote links before they are stored (`extractM3uEpgUrls`), and the main + * process opens the branch only for a path its authorizer allowed + * (`allowLocalFile`), so neither a downloaded playlist nor a compromised + * renderer can name an arbitrary file. + */ +export async function openEpgSourceStream( + url: string, + options: EpgWorkerFetchOptions = {} +): Promise { + const localPath = resolveLocalEpgSourcePath(url); + if (localPath) { + if (options.allowLocalFile !== true) { + throw new Error('Local EPG file was not authorized'); + } + return openLocalEpgSource(localPath); + } + return openRemoteEpgSource(url, options); +} + +async function openLocalEpgSource(filePath: string): Promise { + let info: Awaited>; + try { + info = await stat(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + throw new Error(`EPG file not found: ${filePath}`); + } + throw error; + } + if (!info.isFile()) { + throw new Error(`EPG source is not a file: ${filePath}`); + } + + epgLogger.log(loggerLabel, 'Reading local EPG file'); + const output = new PassThrough(); + pipeline( + createReadStream(filePath), + createOptionalEpgGunzip(), + output, + (error) => { + if (error) output.destroy(error); + } + ); + return output; +} + +async function openRemoteEpgSource( + url: string, + options: ElectronBridgeTrustOptions +): Promise { + // 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( + url.trim(), + { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, + }), + decompress: false, + method: 'GET', + responseType: 'stream', + }, + { + allowPrivateNetworks: + isPrivateNetworkUrlAccessAllowed() || + isTrustedPrivateNetworkEpgSource(url, options), + } + ); + const responseUrl = response.config.url; + const isGzipped = shouldGunzipEpgResponse(url, { + headers: response.headers, + url: responseUrl, + }); + const contentEncoding = getEpgResponseContentEncoding(response.headers); + + if (responseUrl && responseUrl !== url) { + epgLogger.log(loggerLabel, 'Resolved EPG redirect'); + } + epgLogger.log( + loggerLabel, + `EPG response detected as gzipped: ${isGzipped}` + ); + if (contentEncoding) { + epgLogger.log( + loggerLabel, + `EPG response content-encoding: ${contentEncoding}` + ); + } + + if (response.status < 200 || response.status >= 300) { + throw new Error(`HTTP error! status: ${response.status}`); + } + if (!response.data) { + throw new Error('Response body is null'); + } + + return createDecodedEpgStream(response.data, response.headers, isGzipped); +} + +function isTrustedPrivateNetworkEpgSource( + url: string, + options: ElectronBridgeTrustOptions +): boolean { + const normalizedUrl = url.trim(); + return ( + options.trustedPrivateNetworkEpgUrls?.some( + (trustedUrl) => trustedUrl.trim() === normalizedUrl + ) ?? false + ); +} diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index c3b7bb875..c8a96c1c1 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -70,6 +70,19 @@
+
+

{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}

+

{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}

+

+ {{ + 'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate + }} + https://example.org/guide.xml.gz + /home/you/epg/guide.xml + C:\epg\guide.xml.gz + file:///…/guide.xml +

+
@if (epgUrl().length === 0) { @@ -102,7 +115,8 @@ }} @@ -115,12 +129,31 @@ [url]="epgField.value" /> } + @if (canBrowseFiles()) { + + } + }