From d438f5c6550bf8e360a1da69bc438fe75888c4d5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:04:41 +0200 Subject: [PATCH] feat(epg): accept local XMLTV files as EPG sources (#1600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(epg): accept local XMLTV files as EPG sources Settings → EPG and the playlist dialog accepted `file://` in their form pattern, but the main process rejected everything except http(s), so a local XMLTV entry saved fine and then failed on import. Both surfaces now take a remote link, a `file:` URL, an absolute POSIX path or a Windows drive/UNC path (`classifyEpgSourceReference` in shared/interfaces), and the settings section spells out the accepted formats with examples. The EPG worker opens every source through `openEpgSourceStream`: remote links keep the validated-redirect client and trust policy, local files are read from disk behind the signature-sniffing optional gunzip stage, so .xml, .xml.gz and extension-less gzip all parse. Only hand-typed sources may be local: `extractM3uEpgUrls` harvests http(s) links only from M3U headers, since the local branch bypasses `validateRemoteUrl`. Co-Authored-By: Claude Fable 5.1 * feat(epg): pick local XMLTV files with a native file dialog A folder button beside each EPG source row (Settings → EPG and the playlist dialog) opens the native open-file dialog and writes the chosen absolute path into the row. New `EPG_OPEN_FILE_DIALOG` IPC behind `ElectronBridgeApi.openEpgFileDialog`, gated in the renderer by `RuntimeCapabilitiesService.supportsEpgFilePicker`. The row's refresh/remove buttons carry `data-test-id`s now, and the EPG e2e suites address them by id instead of index, since the folder button became the first button in a row. Co-Authored-By: Claude Fable 5.1 * fix(epg): authorize local XMLTV files in the main process Review follow-up (Greptile P1, Codex P1). The renderer hands source strings to FETCH_EPG/EPG_FORCE_FETCH unchanged, so the form validator alone could not enforce the provenance rule: a compromised renderer, or a legacy `file://` entry an older version stored from an M3U header, could name any file on disk. `EpgWorkerService.startFetch` now asks a main-process `EpgLocalSourceAuthorizer` before a local path reaches the worker: a path the native picker returned is trusted at once, a hand-typed path is confirmed once in a native message box the renderer cannot fake, and a refusal is reported in the progress panel. Allowed paths persist under TRUSTED_LOCAL_EPG_SOURCES in the main-process config. The worker opens its local branch only when main set `allowLocalFile`; the service defaults to deny-all until epg.events installs the persisted authorizer. `resolvePlaylistEpgSourceState` and `filterPlaylistEpgUrlsForFetch` drop a stored non-remote entry unless it is also in `manualEpgUrls`. Co-Authored-By: Claude Fable 5.1 * fix(epg): fail a refused local EPG fetch instead of resolving it Review follow-up (Codex P2). A denied native confirmation now rejects the fetch after reporting the error row, so handleFetchEpg and the renderer's fetch result cannot claim the file was read. Also restores the unrelated CLAUDE.md paragraph an earlier formatter pass had reflowed into a list. Co-Authored-By: Claude Fable 5.1 * fix(epg): cancel a local source retired during its authorization prompt Review follow-up (Codex P2). startFetch keeps the request generation captured before awaiting the native confirmation and rechecks it afterwards: a source retired meanwhile ends as cancelled instead of starting an import that a pending clear would then have to await. Co-Authored-By: Claude Fable 5.1 * test(epg): leave the local XMLTV e2e with a pristine settings form The local-file test ended with the EPG source field still dirty, which arms the main-process close guard: the app then waited for the unsaved changes dialog instead of closing, the close timeout killed it, and on Windows the killed process kept iptvnator.db busy (EBUSY on the data-dir cleanup) and hung the Playwright worker teardown. Discarding the form before the app closes takes the test from 15 s to 4 s locally. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5.1 --- .changes/epg-local-xmltv-sources.md | 7 + CLAUDE.md | 2 +- apps/electron-backend-e2e/src/epg.e2e.ts | 142 +++++++++++---- .../src/xtream-epg.e2e.ts | 4 +- .../src/app/api/main.preload.spec-data.ts | 6 + .../src/app/api/main.preload.ts | 1 + .../src/app/events/epg-guide.events.spec.ts | 5 + .../epg-local-source-authorizer.spec.ts | 106 +++++++++++ .../app/events/epg-local-source-authorizer.ts | 96 ++++++++++ .../src/app/events/epg-mapping.events.spec.ts | 5 + .../src/app/events/epg-worker.service.spec.ts | 111 ++++++++++++ .../src/app/events/epg-worker.service.ts | 76 +++++++- .../src/app/events/epg.events.spec.ts | 5 + .../src/app/events/epg.events.ts | 41 ++++- .../src/app/services/store.service.ts | 9 + .../app/util/epg-local-source-path.spec.ts | 24 +++ .../src/app/util/epg-local-source-path.ts | 25 +++ .../src/app/workers/epg-parser.worker.ts | 81 +-------- .../src/app/workers/epg-source-stream.spec.ts | 164 ++++++++++++++++++ .../src/app/workers/epg-source-stream.ts | 151 ++++++++++++++++ .../settings-epg-section.component.html | 36 +++- .../settings-epg-section.component.ts | 2 + .../app/settings/settings-epg.facade.spec.ts | 46 +++++ .../src/app/settings/settings-epg.facade.ts | 23 +++ .../app/settings/settings-form.utils.spec.ts | 22 +++ .../src/app/settings/settings-form.utils.ts | 10 +- .../src/app/settings/settings.component.html | 2 + .../src/app/settings/settings.component.scss | 16 ++ .../test-stubs/settings-test-harness.stub.ts | 2 + apps/web/src/assets/i18n/ar.json | 4 + apps/web/src/assets/i18n/ary.json | 4 + apps/web/src/assets/i18n/by.json | 4 + apps/web/src/assets/i18n/de.json | 14 +- apps/web/src/assets/i18n/el.json | 4 + apps/web/src/assets/i18n/en.json | 14 +- apps/web/src/assets/i18n/es.json | 4 + apps/web/src/assets/i18n/fr.json | 4 + apps/web/src/assets/i18n/hu.json | 4 + apps/web/src/assets/i18n/it.json | 4 + apps/web/src/assets/i18n/ja.json | 4 + apps/web/src/assets/i18n/ko.json | 4 + apps/web/src/assets/i18n/nl.json | 4 + apps/web/src/assets/i18n/pl.json | 4 + apps/web/src/assets/i18n/pt.json | 4 + apps/web/src/assets/i18n/ru.json | 14 +- apps/web/src/assets/i18n/tr.json | 4 + apps/web/src/assets/i18n/zh.json | 4 + apps/web/src/assets/i18n/zhtw.json | 4 + docs/architecture/m3u-playlist-module.md | 57 ++++++ .../lib/epg-runtime-bridge.service.spec.ts | 19 ++ .../src/lib/epg-runtime-bridge.service.ts | 14 ++ libs/m3u-state/src/lib/effects.ts | 2 +- .../playlist-scoped-epg-fetch.util.spec.ts | 19 ++ .../src/lib/playlist-scoped-epg-fetch.util.ts | 11 +- .../playlist-info.component.html | 20 +++ .../playlist-info.component.spec.ts | 21 +++ .../playlist-info/playlist-info.component.ts | 24 ++- .../lib/runtime-capabilities.service.spec.ts | 3 + .../src/lib/runtime-capabilities.service.ts | 5 + libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 2 + .../src/lib/epg-source-reference.util.spec.ts | 80 +++++++++ .../src/lib/epg-source-reference.util.ts | 72 ++++++++ .../m3u-utils/src/lib/playlist.utils.spec.ts | 59 +++++++ .../m3u-utils/src/lib/playlist.utils.ts | 35 +++- 65 files changed, 1621 insertions(+), 149 deletions(-) create mode 100644 .changes/epg-local-xmltv-sources.md create mode 100644 apps/electron-backend/src/app/events/epg-local-source-authorizer.spec.ts create mode 100644 apps/electron-backend/src/app/events/epg-local-source-authorizer.ts create mode 100644 apps/electron-backend/src/app/util/epg-local-source-path.spec.ts create mode 100644 apps/electron-backend/src/app/util/epg-local-source-path.ts create mode 100644 apps/electron-backend/src/app/workers/epg-source-stream.spec.ts create mode 100644 apps/electron-backend/src/app/workers/epg-source-stream.ts create mode 100644 libs/shared/interfaces/src/lib/epg-source-reference.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/epg-source-reference.util.ts 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()) { + + } + }