From e8aa7c3a34eba84f56ebaf9c057aeb97796adff8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:46:24 +0200 Subject: [PATCH] [codex] Add scoped EPG security trust controls (#1054) * Add scoped EPG security trust controls * fix: address scoped trust review feedback --------- Co-authored-by: 4gray --- README.md | 13 +- .../src/app/api/main.preload.spec-data.ts | 17 ++- .../src/app/api/main.preload.ts | 21 ++- .../src/app/events/epg-worker.service.ts | 48 ++++-- .../src/app/events/epg.events.spec.ts | 1 + .../src/app/events/epg.events.ts | 49 ++++-- .../src/app/events/playlist-source.ts | 41 +++-- .../src/app/events/playlist.events.ts | 124 +++++++++------ .../src/app/util/secure-https.spec.ts | 41 ++++- .../src/app/util/secure-https.ts | 33 +++- .../src/app/util/security-errors.ts | 93 ++++++++++++ .../src/app/util/validated-axios.spec.ts | 10 +- .../src/app/util/validated-axios.ts | 6 +- .../src/app/workers/epg-parser.worker.ts | 86 ++++++++++- .../app/workers/playlist-refresh.worker.ts | 37 +++-- .../src/app/services/electron.service.spec.ts | 60 +++++++- apps/web/src/app/services/electron.service.ts | 118 ++++++++++++++- .../app/settings/settings.component.spec.ts | 55 ++++--- .../src/app/settings/settings.component.ts | 31 ++-- apps/web/src/assets/i18n/ar.json | 13 +- apps/web/src/assets/i18n/ary.json | 13 +- apps/web/src/assets/i18n/by.json | 13 +- apps/web/src/assets/i18n/de.json | 13 +- apps/web/src/assets/i18n/el.json | 13 +- apps/web/src/assets/i18n/en.json | 13 +- apps/web/src/assets/i18n/es.json | 13 +- apps/web/src/assets/i18n/fr.json | 13 +- apps/web/src/assets/i18n/it.json | 13 +- apps/web/src/assets/i18n/ja.json | 13 +- apps/web/src/assets/i18n/ko.json | 13 +- apps/web/src/assets/i18n/nl.json | 13 +- apps/web/src/assets/i18n/pl.json | 13 +- apps/web/src/assets/i18n/pt.json | 13 +- apps/web/src/assets/i18n/ru.json | 13 +- apps/web/src/assets/i18n/tr.json | 13 +- apps/web/src/assets/i18n/zh.json | 13 +- apps/web/src/assets/i18n/zhtw.json | 13 +- docs/architecture/electron-security.md | 18 ++- .../src/lib/epg-progress.service.spec.ts | 47 +++++- .../src/lib/epg-progress.service.ts | 73 ++++++++- .../lib/epg-runtime-bridge.service.spec.ts | 8 +- .../src/lib/epg-runtime-bridge.service.ts | 17 ++- .../data-access/src/lib/epg.service.spec.ts | 27 +++- libs/epg/data-access/src/lib/epg.service.ts | 13 +- .../playlist-refresh-action.service.spec.ts | 106 +++++++++++-- .../lib/playlist-refresh-action.service.ts | 112 +++++++++++++- .../src/lib/settings-store.service.ts | 18 +++ libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 36 ++++- .../src/lib/playlist-refresh.interface.ts | 1 + .../lib/security-policy-error.utils.spec.ts | 56 +++++++ .../src/lib/security-policy-error.utils.ts | 76 ++++++++++ .../interfaces/src/lib/settings.interface.ts | 12 ++ .../epg-progress-panel.component.html | 35 ++--- .../epg-progress-panel.component.scss | 11 ++ .../epg-progress-panel.component.ts | 141 +++++++++++++++++- 56 files changed, 1683 insertions(+), 243 deletions(-) create mode 100644 apps/electron-backend/src/app/util/security-errors.ts create mode 100644 libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts create mode 100644 libs/shared/interfaces/src/lib/security-policy-error.utils.ts diff --git a/README.md b/README.md index e5f638b03..500f194b2 100644 --- a/README.md +++ b/README.md @@ -306,12 +306,15 @@ Useful narrower flags: Security-sensitive network compatibility flags are opt-in: -- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits EPG URLs that resolve to - localhost, LAN, or other private addresses. Leave this unset for playlists - you do not fully trust. +- `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` permits strict EPG fetches from + playlist metadata (`url-tvg`) to resolve to localhost, LAN, or other private + addresses. Directly configured Xtream/Stalker portals and private playlist + servers remain supported without this flag. Prefer the in-app source-scoped + “Allow source” action for a trusted EPG URL. - `IPTVNATOR_ALLOW_INSECURE_TLS=1` disables certificate validation for remote - playlist imports and refreshes. Use it only for a trusted provider with a - self-signed or otherwise invalid certificate. + playlist imports and refreshes for the whole Electron process. Prefer the + in-app host-scoped trust action for a trusted provider with a self-signed or + otherwise invalid certificate. If the local Nx daemon gets into a bad state before rerunning Electron, reset it: 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 d382aa3e6..bfe134b59 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 @@ -21,6 +21,10 @@ type PreloadInvokeCase = { const playlistId = 'playlist-1'; export const operationId = 'operation-1'; const epgUrls = ['https://example.com/guide.xml']; +const trustOptions = { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], +}; const channelIds = ['channel-1', 'channel-2']; const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' }; const playlists = [playlist]; @@ -338,9 +342,9 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ export const epgPreloadCases: PreloadInvokeCase[] = [ { method: 'fetchEpg', - args: [epgUrls], + args: [epgUrls, trustOptions], channel: 'FETCH_EPG', - forwardedArgs: [{ url: epgUrls }], + forwardedArgs: [{ url: epgUrls, options: trustOptions }], }, { method: 'getChannelPrograms', @@ -374,9 +378,14 @@ export const epgPreloadCases: PreloadInvokeCase[] = [ }, { method: 'forceFetchEpg', - args: ['https://example.com/guide.xml'], + args: ['https://example.com/guide.xml', trustOptions], channel: 'EPG_FORCE_FETCH', - forwardedArgs: ['https://example.com/guide.xml'], + forwardedArgs: [ + { + url: 'https://example.com/guide.xml', + options: trustOptions, + }, + ], }, { method: 'clearEpgData', diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 27b9ece70..38eda2a7b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -13,6 +13,7 @@ import type { ElectronBridgePlaylistUpsertInput, ElectronBridgeRemoteControlCommand, ElectronBridgeRemoteControlStatus, + ElectronBridgeTrustOptions, ElectronBridgeWindowState, ElectronBridgeXtreamContentStream, ExternalPlayerSession, @@ -302,8 +303,11 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.on(WINDOW_STATE_CHANGED, handler); return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler); }, - fetchPlaylistByUrl: (url: string, title?: string) => - ipcRenderer.invoke('fetch-playlist-by-url', url, title), + fetchPlaylistByUrl: ( + url: string, + title?: string, + options?: ElectronBridgeTrustOptions + ) => ipcRenderer.invoke('fetch-playlist-by-url', url, title, options), updatePlaylistFromFilePath: (filePath: string, title: string) => ipcRenderer.invoke('update-playlist-from-file-path', filePath, title), openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'), @@ -448,10 +452,12 @@ const electronApi: ElectronBridgeApi = { sessionId: string ): Promise => ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId), - autoUpdatePlaylists: (playlists) => - ipcRenderer.invoke('AUTO_UPDATE', playlists), - fetchEpg: (urls: string[]) => - ipcRenderer.invoke('FETCH_EPG', { url: urls }), + autoUpdatePlaylists: ( + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => ipcRenderer.invoke('AUTO_UPDATE', playlists, options), + fetchEpg: (urls: string[], options?: ElectronBridgeTrustOptions) => + ipcRenderer.invoke('FETCH_EPG', { url: urls, options }), getChannelPrograms: (channelId: string) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }), getCurrentProgramsBatch: (channelIds: string[]) => @@ -461,7 +467,8 @@ const electronApi: ElectronBridgeApi = { getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'), getEpgChannelsByRange: (skip: number, limit: number) => ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }), - forceFetchEpg: (url: string) => ipcRenderer.invoke('EPG_FORCE_FETCH', url), + forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) => + ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }), clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'), checkEpgFreshness: (urls: string[], maxAgeHours?: number) => ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }), 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 f725b3a61..1542335ef 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -2,6 +2,10 @@ import { app, BrowserWindow } from 'electron'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { Worker } from 'worker_threads'; +import { + ElectronBridgeSecurityErrorCode, + ElectronBridgeTrustOptions, +} from '@iptvnator/shared/interfaces'; import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths'; export type EpgProgressStatus = 'queued' | 'loading' | 'complete' | 'error'; @@ -14,6 +18,8 @@ export interface EpgProgressStats { interface EpgWorkerMessage { type: string; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; url?: string; stats?: EpgProgressStats; } @@ -45,7 +51,9 @@ export class EpgWorkerService { status: EpgProgressStatus, stats?: EpgProgressStats, error?: string, - queuePosition?: number + queuePosition?: number, + errorCode?: ElectronBridgeSecurityErrorCode, + errorHost?: string ): void { const windows = BrowserWindow.getAllWindows(); windows.forEach((win) => { @@ -55,11 +63,16 @@ export class EpgWorkerService { stats, error, queuePosition, + errorCode, + errorHost, }); }); } - async fetchEpgFromUrl(url: string): Promise { + async fetchEpgFromUrl( + url: string, + options: ElectronBridgeTrustOptions = {} + ): Promise { // A second request for an URL that is already being fetched must not // spawn a competing worker: both would parse and write the same EPG // data, and the late one would overwrite the early one's entry in @@ -84,14 +97,17 @@ export class EpgWorkerService { return; } - const fetchPromise = this.startFetch(url).finally(() => { + const fetchPromise = this.startFetch(url, options).finally(() => { this.inFlightFetches.delete(url); }); this.inFlightFetches.set(url, fetchPromise); return fetchPromise; } - private startFetch(url: string): Promise { + private startFetch( + url: string, + options: ElectronBridgeTrustOptions + ): Promise { return new Promise((resolve, reject) => { let worker: Worker; try { @@ -148,7 +164,11 @@ export class EpgWorkerService { totalChannels: 0, totalPrograms: 0, }); - worker.postMessage({ type: 'FETCH_EPG', url }); + worker.postMessage({ + type: 'FETCH_EPG', + url, + options, + }); break; case 'EPG_PROGRESS': @@ -192,7 +212,10 @@ export class EpgWorkerService { url, 'error', undefined, - message.error + message.error, + undefined, + message.errorCode, + message.errorHost ); this.workers.delete(url); settle(() => { @@ -313,13 +336,12 @@ export class EpgWorkerService { // Resolve only after every interrupted fetch // worker has exited too — they may still hold the // SQLite lock the caller expects to be free. - const terminations = [ - ...this.workers.values(), - ].map((runningWorker) => - this.terminateWorker( - runningWorker, - 'fetch during clear' - ) + const terminations = [...this.workers.values()].map( + (runningWorker) => + this.terminateWorker( + runningWorker, + 'fetch during clear' + ) ); this.workers.clear(); terminations.push( 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 349b51d46..4cbe17c7e 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -109,6 +109,7 @@ describe('EpgEvents', () => { expect(worker.postMessage).toHaveBeenCalledWith({ type: 'FETCH_EPG', url: 'https://example.com/guide.xml', + options: {}, }); worker.emit('message', { diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 65781279a..41edea95c 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,6 +1,10 @@ import { eq } from 'drizzle-orm'; import { ipcMain } from 'electron'; -import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + ElectronBridgeTrustOptions, + EpgChannelMetadata, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; import { epgQueryService } from './epg-query.service'; @@ -17,9 +21,15 @@ export default class EpgEvents { * Bootstrap EPG events */ static bootstrapEpgEvents(): Electron.IpcMain { - ipcMain.handle('FETCH_EPG', async (_event, args: { url: string[] }) => { - return await this.handleFetchEpg(args.url); - }); + ipcMain.handle( + 'FETCH_EPG', + async ( + _event, + args: { url: string[]; options?: ElectronBridgeTrustOptions } + ) => { + return await this.handleFetchEpg(args.url, args.options); + } + ); ipcMain.handle( 'GET_CHANNEL_PROGRAMS', @@ -53,10 +63,21 @@ export default class EpgEvents { } ); - ipcMain.handle('EPG_FORCE_FETCH', async (_event, url: string) => { - epgWorkerService.deleteFetchedUrl(url); - return await this.handleFetchEpg([url]); - }); + ipcMain.handle( + 'EPG_FORCE_FETCH', + async ( + _event, + args: + | string + | { url: string; options?: ElectronBridgeTrustOptions } + ) => { + const url = typeof args === 'string' ? args : args.url; + const options = + typeof args === 'string' ? undefined : args.options; + epgWorkerService.deleteFetchedUrl(url); + return await this.handleFetchEpg([url], options); + } + ); ipcMain.handle('EPG_CLEAR_ALL', async () => { await this.clearEpgData(); @@ -149,7 +170,8 @@ export default class EpgEvents { * Processes URLs sequentially to avoid SQLite database locking issues */ private static async handleFetchEpg( - urls: string[] + urls: string[], + options: ElectronBridgeTrustOptions = {} ): Promise<{ success: boolean; message?: string; skipped?: string[] }> { const validUrls = urls.filter((url) => url?.trim()); @@ -198,7 +220,7 @@ export default class EpgEvents { const errors: string[] = []; for (const url of urlsToFetch) { try { - await this.fetchEpgFromUrl(url); + await this.fetchEpgFromUrl(url, options); } catch (error) { console.error( this.loggerLabel, @@ -222,8 +244,11 @@ export default class EpgEvents { return { success: true, skipped: freshUrls }; } - private static async fetchEpgFromUrl(url: string): Promise { - return epgWorkerService.fetchEpgFromUrl(url); + private static async fetchEpgFromUrl( + url: string, + options: ElectronBridgeTrustOptions = {} + ): Promise { + return epgWorkerService.fetchEpgFromUrl(url, options); } private static async handleGetChannelPrograms( diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index 907867be1..a858927cd 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -7,20 +7,43 @@ import { parse } from 'iptv-playlist-parser'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { createPlaylistAgentFactory } from '../util/secure-https'; +import { + createInvalidTlsCertificateError, + getHostnameFromErrorUrl, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +export interface PlaylistFetchOptions { + trustedInsecureTlsHosts?: readonly string[]; +} + export async function fetchPlaylistFromUrl( url: string, - title?: string + title?: string, + options: PlaylistFetchOptions = {} ): Promise { - const result = await requestWithValidatedRedirects( - url, - { - agentFactory: createPlaylistAgentFactory(), - method: 'GET', - }, - { allowPrivateNetworks: true } - ); + let result; + try { + result = await requestWithValidatedRedirects( + url, + { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, + }), + method: 'GET', + }, + { allowPrivateNetworks: true } + ); + } catch (error) { + if (isInvalidTlsCertificateError(error)) { + throw createInvalidTlsCertificateError( + getHostnameFromErrorUrl(error, url) + ); + } + throw error; + } + const parsedPlaylist = parse(result.data); const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : ''; const playlistName = diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index fc2ad44c5..1fdbf34af 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -12,6 +12,7 @@ import { PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, PLAYLIST_REFRESH_EVENT, + ElectronBridgeTrustOptions, Playlist, PlaylistRefreshEvent, PlaylistRefreshPayload, @@ -85,14 +86,24 @@ function createPlaylistRefreshError(error: { return workerError; } -ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { - try { - return await fetchPlaylistFromUrl(url, title); - } catch (error) { - console.error('Error fetching playlist:', error); - throw error; +ipcMain.handle( + 'fetch-playlist-by-url', + async ( + event, + url, + title?: string, + options?: ElectronBridgeTrustOptions + ) => { + try { + return await fetchPlaylistFromUrl(url, title, { + trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts, + }); + } catch (error) { + console.error('Error fetching playlist:', error); + throw error; + } } -}); +); ipcMain.handle( 'update-playlist-from-file-path', @@ -132,57 +143,72 @@ ipcMain.handle('open-playlist-from-file', async () => { } }); -ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { - console.log(`Auto-updating ${playlists.length} playlist(s)...`); +ipcMain.handle( + AUTO_UPDATE_PLAYLISTS, + async ( + event, + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => { + console.log(`Auto-updating ${playlists.length} playlist(s)...`); - const updatedPlaylists: Playlist[] = []; + const updatedPlaylists: Playlist[] = []; - for (const playlist of playlists) { - try { - let playlistObject; + for (const playlist of playlists) { + try { + let playlistObject; + + if (playlist.importDate && playlist.url) { + // Update from URL + console.log( + `Updating playlist "${playlist.title}" from URL: ${playlist.url}` + ); + playlistObject = await fetchPlaylistFromUrl( + playlist.url, + playlist.title, + { + trustedInsecureTlsHosts: + options?.trustedInsecureTlsHosts, + } + ); + } else if (playlist.filePath) { + // Update from file path + console.log( + `Updating playlist "${playlist.title}" from file: ${playlist.filePath}` + ); + playlistObject = await fetchPlaylistFromFile( + playlist.filePath, + playlist.title + ); + } else { + console.warn( + `Skipping playlist "${playlist.title}": no URL or file path found` + ); + continue; + } + + updatedPlaylists.push( + preserveAutoUpdatedPlaylistFields(playlistObject, playlist) + ); - if (playlist.importDate && playlist.url) { - // Update from URL console.log( - `Updating playlist "${playlist.title}" from URL: ${playlist.url}` + `Successfully updated playlist "${playlist.title}"` ); - playlistObject = await fetchPlaylistFromUrl( - playlist.url, - playlist.title + } catch (error) { + console.error( + `Failed to update playlist "${playlist.title}":`, + error ); - } else if (playlist.filePath) { - // Update from file path - console.log( - `Updating playlist "${playlist.title}" from file: ${playlist.filePath}` - ); - playlistObject = await fetchPlaylistFromFile( - playlist.filePath, - playlist.title - ); - } else { - console.warn( - `Skipping playlist "${playlist.title}": no URL or file path found` - ); - continue; + // Continue with other playlists even if one fails } - - updatedPlaylists.push( - preserveAutoUpdatedPlaylistFields(playlistObject, playlist) - ); - - console.log(`Successfully updated playlist "${playlist.title}"`); - } catch (error) { - console.error( - `Failed to update playlist "${playlist.title}":`, - error - ); - // Continue with other playlists even if one fails } - } - console.log(`Auto-update completed: ${updatedPlaylists.length} updated`); - return updatedPlaylists; -}); + console.log( + `Auto-update completed: ${updatedPlaylists.length} updated` + ); + return updatedPlaylists; + } +); ipcMain.handle( PLAYLIST_REFRESH, diff --git a/apps/electron-backend/src/app/util/secure-https.spec.ts b/apps/electron-backend/src/app/util/secure-https.spec.ts index d0b8421a8..7e777c2c6 100644 --- a/apps/electron-backend/src/app/util/secure-https.spec.ts +++ b/apps/electron-backend/src/app/util/secure-https.spec.ts @@ -30,7 +30,10 @@ describe('secure-https', () => { delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; expect(isInsecureTlsAllowed()).toBe(false); - createPlaylistAgentFactory().createHttpsAgent(lookup); + createPlaylistAgentFactory().createHttpsAgent( + lookup, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ lookup, @@ -44,7 +47,10 @@ describe('secure-https', () => { process.env.IPTVNATOR_ALLOW_INSECURE_TLS = value; expect(isInsecureTlsAllowed()).toBe(true); - createPlaylistAgentFactory().createHttpsAgent(lookup); + createPlaylistAgentFactory().createHttpsAgent( + lookup, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ lookup, @@ -62,10 +68,39 @@ describe('secure-https', () => { it('preserves TLS policy when no pinned lookup is required', () => { delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; - createPlaylistAgentFactory().createHttpsAgent(); + createPlaylistAgentFactory().createHttpsAgent( + undefined, + new URL('https://example.com/list.m3u') + ); expect(agentConstructorMock).toHaveBeenCalledWith({ rejectUnauthorized: true, }); }); + + it('allows invalid certificates only for trusted hosts', () => { + delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS; + + const factory = createPlaylistAgentFactory({ + trustedInsecureTlsHosts: ['playlist.local'], + }); + + factory.createHttpsAgent( + lookup, + new URL('https://playlist.local/list.m3u') + ); + factory.createHttpsAgent( + lookup, + new URL('https://other.local/list.m3u') + ); + + expect(agentConstructorMock).toHaveBeenNthCalledWith(1, { + lookup, + rejectUnauthorized: false, + }); + expect(agentConstructorMock).toHaveBeenNthCalledWith(2, { + lookup, + rejectUnauthorized: true, + }); + }); }); diff --git a/apps/electron-backend/src/app/util/secure-https.ts b/apps/electron-backend/src/app/util/secure-https.ts index 5ef49a432..b66aa7d8d 100644 --- a/apps/electron-backend/src/app/util/secure-https.ts +++ b/apps/electron-backend/src/app/util/secure-https.ts @@ -1,5 +1,6 @@ import { Agent } from 'node:https'; import type { LookupFunction } from 'node:net'; +import { normalizeHost } from '@iptvnator/shared/interfaces'; import type { ValidatedRequestAgentFactory } from './validated-axios'; const INSECURE_TLS_ENV = 'IPTVNATOR_ALLOW_INSECURE_TLS'; @@ -18,6 +19,20 @@ export function isInsecureTlsAllowed(): boolean { return value === '1' || value === 'true'; } +function shouldTrustInvalidCertificateForHost( + url: URL | undefined, + trustedHosts: readonly string[] +): boolean { + if (!url) { + return false; + } + + const host = normalizeHost(url.hostname); + return trustedHosts.some( + (trustedHost) => normalizeHost(trustedHost) === host + ); +} + /** * Builds the agent factory used for remote playlist fetches. * @@ -25,12 +40,22 @@ export function isInsecureTlsAllowed(): boolean { * (see {@link isInsecureTlsAllowed}). The validated request layer supplies a * DNS lookup pinned to the addresses approved for each redirect hop. */ -export function createPlaylistAgentFactory(): ValidatedRequestAgentFactory & { - createHttpsAgent(lookup?: LookupFunction): Agent; +export function createPlaylistAgentFactory( + options: { + trustedInsecureTlsHosts?: readonly string[]; + } = {} +): ValidatedRequestAgentFactory & { + createHttpsAgent(lookup?: LookupFunction, url?: URL): Agent; } { - const rejectUnauthorized = !isInsecureTlsAllowed(); + const trustedHosts = options.trustedInsecureTlsHosts ?? []; return { - createHttpsAgent: (lookup) => new Agent({ lookup, rejectUnauthorized }), + createHttpsAgent: (lookup, url) => + new Agent({ + lookup, + rejectUnauthorized: + !isInsecureTlsAllowed() && + !shouldTrustInvalidCertificateForHost(url, trustedHosts), + }), }; } diff --git a/apps/electron-backend/src/app/util/security-errors.ts b/apps/electron-backend/src/app/util/security-errors.ts new file mode 100644 index 000000000..421df1223 --- /dev/null +++ b/apps/electron-backend/src/app/util/security-errors.ts @@ -0,0 +1,93 @@ +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + SECURITY_ERROR_PREFIX, +} from '@iptvnator/shared/interfaces'; +export { + parseSecurityPolicyError, + SECURITY_ERROR_PREFIX, +} from '@iptvnator/shared/interfaces'; +export type { SerializedSecurityError } from '@iptvnator/shared/interfaces'; + +const TLS_CERTIFICATE_ERROR_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', +]); + +export class SecurityPolicyError extends Error { + constructor( + readonly code: string, + message: string, + readonly host?: string + ) { + super( + `${SECURITY_ERROR_PREFIX}${JSON.stringify({ code, host, message })}` + ); + this.name = 'SecurityPolicyError'; + } +} + +function readErrorProperty(error: unknown, property: string): unknown { + if (!error || typeof error !== 'object') { + return undefined; + } + return (error as Record)[property]; +} + +export function isInvalidTlsCertificateError(error: unknown): boolean { + const code = readErrorProperty(error, 'code'); + if (typeof code === 'string' && TLS_CERTIFICATE_ERROR_CODES.has(code)) { + return true; + } + + const cause = readErrorProperty(error, 'cause'); + const causeCode = readErrorProperty(cause, 'code'); + if ( + typeof causeCode === 'string' && + TLS_CERTIFICATE_ERROR_CODES.has(causeCode) + ) { + return true; + } + + const message = + error instanceof Error ? error.message : String(error ?? ''); + return /certificate|self[- ]signed|cert_altname|unable to verify/i.test( + message + ); +} + +export function createInvalidTlsCertificateError( + host: string | undefined, + message = 'Certificate for this playlist host is invalid.' +): SecurityPolicyError { + return new SecurityPolicyError( + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + message, + host + ); +} + +export function getHostnameFromUrl(url: string): string | undefined { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +export function getHostnameFromErrorUrl( + error: unknown, + fallbackUrl: string +): string | undefined { + const configUrl = + error && typeof error === 'object' + ? ((error as { config?: { url?: string } }).config?.url ?? + fallbackUrl) + : fallbackUrl; + + return getHostnameFromUrl(configUrl); +} diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts index 21bd3f8dd..ff4146eca 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -90,7 +90,10 @@ describe('requestWithValidatedRedirects', () => { ); }); - expect(factory.createHttpsAgent).toHaveBeenCalledWith(lookups[0]); + expect(factory.createHttpsAgent).toHaveBeenCalledWith( + lookups[0], + new URL('https://epg.example/guide.xml') + ); expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); expect(requestConfig).not.toHaveProperty('agentFactory'); expect(resolvedAddress).toEqual({ @@ -144,7 +147,10 @@ describe('requestWithValidatedRedirects', () => { ); const requestConfig = axiosMock.mock.calls[0][0]; - expect(factory.createHttpsAgent).toHaveBeenCalledWith(); + expect(factory.createHttpsAgent).toHaveBeenCalledWith( + undefined, + new URL('https://192.168.1.10/list.m3u') + ); expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent); expect(requestConfig).not.toHaveProperty('agentFactory'); }); diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index ad6922198..ab554bd8a 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -22,7 +22,7 @@ const SENSITIVE_HEADERS = new Set([ export interface ValidatedRequestAgentFactory { createHttpAgent?(lookup?: LookupFunction): HttpAgent; - createHttpsAgent?(lookup?: LookupFunction): HttpsAgent; + createHttpsAgent?(lookup?: LookupFunction, url?: URL): HttpsAgent; } export type ValidatedAxiosRequestConfig = Omit< @@ -100,7 +100,7 @@ function pinRequestToValidatedAddresses( if (url.protocol === 'https:' && agentFactory?.createHttpsAgent) { return { ...axiosConfig, - httpsAgent: agentFactory.createHttpsAgent(), + httpsAgent: agentFactory.createHttpsAgent(undefined, url), }; } if (url.protocol === 'http:' && agentFactory?.createHttpAgent) { @@ -117,7 +117,7 @@ function pinRequestToValidatedAddresses( return { ...axiosConfig, httpsAgent: - agentFactory?.createHttpsAgent?.(lookup) ?? + agentFactory?.createHttpsAgent?.(lookup, url) ?? new HttpsAgent({ lookup }), proxy: false, }; 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 f65c95df8..31f647506 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,11 +1,25 @@ import type BetterSqlite3 from 'better-sqlite3'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ElectronBridgeSecurityErrorCode, + ElectronBridgeTrustOptions, +} from '@iptvnator/shared/interfaces'; import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; import { createGunzip } from 'zlib'; import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database'; import { StreamingEpgParser } from './epg-streaming-parser'; import { shouldGunzipEpgResponse } from './epg-response-utils'; -import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety'; +import { + isPrivateNetworkUrlAccessAllowed, + UnsafeUrlError, +} from '../events/url-safety'; +import { createPlaylistAgentFactory } from '../util/secure-https'; +import { + getHostnameFromErrorUrl, + getHostnameFromUrl, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { getNativeModuleSearchPaths, @@ -46,6 +60,7 @@ const Database = loadBetterSqlite3(); interface WorkerMessage { type: 'FETCH_EPG' | 'FORCE_FETCH' | 'CLEAR_EPG'; url?: string; + options?: ElectronBridgeTrustOptions; } interface WorkerResponse { @@ -56,6 +71,8 @@ interface WorkerResponse { | 'CLEAR_COMPLETE' | 'READY'; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; url?: string; stats?: { totalChannels: number; @@ -73,7 +90,10 @@ const PROGRAM_BATCH_SIZE = 1000; * Fetches and parses EPG data from URL using streaming * Inserts directly into SQLite to avoid blocking main thread */ -async function fetchAndParseEpgStreaming(url: string): Promise { +async function fetchAndParseEpgStreaming( + url: string, + options: ElectronBridgeTrustOptions = {} +): Promise { console.log(loggerLabel, `Fetching EPG from ${url}`); // Create database connection in worker @@ -92,12 +112,17 @@ async function fetchAndParseEpgStreaming(url: string): Promise { const response = await requestWithValidatedRedirects( url.trim(), { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: options.trustedInsecureTlsHosts, + }), decompress: false, method: 'GET', responseType: 'stream', }, { - allowPrivateNetworks: isPrivateNetworkUrlAccessAllowed(), + allowPrivateNetworks: + isPrivateNetworkUrlAccessAllowed() || + isTrustedPrivateNetworkEpgSource(url, options), } ); const responseUrl = response.config.url; @@ -225,10 +250,55 @@ async function fetchAndParseEpgStreaming(url: string): Promise { }); } catch (error) { epgDb.close(); - throw error; + throw toEpgFetchError(error, url); } } +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 +): Error & { + code?: ElectronBridgeSecurityErrorCode; + host?: string; +} { + if ( + error instanceof UnsafeUrlError && + /private|local network/i.test(error.message) + ) { + return Object.assign( + new Error('EPG source points to private network and was blocked.'), + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked, + host: getHostnameFromUrl(url), + } + ); + } + + if (isInvalidTlsCertificateError(error)) { + return Object.assign( + new Error('Certificate for this source host is invalid.'), + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: getHostnameFromErrorUrl(error, url), + } + ); + } + + return error instanceof Error ? error : new Error(String(error)); +} + /** * Clears all EPG data from the database * Runs in worker thread to avoid blocking main thread @@ -267,15 +337,21 @@ if (parentPort) { message.type === 'FETCH_EPG' || message.type === 'FORCE_FETCH' ) { - await fetchAndParseEpgStreaming(message.url!); + await fetchAndParseEpgStreaming(message.url!, message.options); } else if (message.type === 'CLEAR_EPG') { clearAllEpgData(); } } catch (error) { console.error(loggerLabel, 'Worker error:', error); + const typedError = error as { + code?: ElectronBridgeSecurityErrorCode; + host?: string; + }; const errorResponse: WorkerResponse = { type: 'EPG_ERROR', error: error instanceof Error ? error.message : String(error), + errorCode: typedError.code, + errorHost: typedError.host, url: message.url, }; parentPort?.postMessage(errorResponse); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index fd78af623..8dd781fab 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -15,6 +15,11 @@ import type { PlaylistRefreshWorkerIncomingMessage, PlaylistRefreshWorkerMessage, } from './playlist-refresh.worker.types'; +import { + createInvalidTlsCertificateError, + getHostnameFromErrorUrl, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; type ActiveRefreshState = { @@ -82,16 +87,28 @@ async function fetchPlaylistFromUrl( emitEvent(payload, { status: 'started', phase: 'fetching' }); checkpoint(payload); - const result = await requestWithValidatedRedirects( - payload.url!, - { - agentFactory: createPlaylistAgentFactory(), - method: 'GET', - signal: controller.signal, - timeout: 30000, - }, - { allowPrivateNetworks: true } - ); + let result; + try { + result = await requestWithValidatedRedirects( + payload.url!, + { + agentFactory: createPlaylistAgentFactory({ + trustedInsecureTlsHosts: payload.trustedInsecureTlsHosts, + }), + method: 'GET', + signal: controller.signal, + timeout: 30000, + }, + { allowPrivateNetworks: true } + ); + } catch (error) { + if (isInvalidTlsCertificateError(error)) { + throw createInvalidTlsCertificateError( + getHostnameFromErrorUrl(error, payload.url!) + ); + } + throw error; + } checkpoint(payload); emitEvent(payload, { status: 'progress', phase: 'parsing' }); diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 610e2cb57..ad0c5015b 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -2,7 +2,14 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { PLAYLIST_PARSE_BY_URL } from '@iptvnator/shared/interfaces'; +import { of } from 'rxjs'; +import { DialogService } from '@iptvnator/ui/components'; +import { SettingsStore } from '@iptvnator/services'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + PLAYLIST_PARSE_BY_URL, + SECURITY_ERROR_PREFIX, +} from '@iptvnator/shared/interfaces'; import { ElectronService } from './electron.service'; describe('ElectronService', () => { @@ -12,6 +19,7 @@ describe('ElectronService', () => { openInMpv: jest.Mock; openInVlc: jest.Mock; }; + let snackBar: { open: jest.Mock }; let service: ElectronService; beforeEach(() => { @@ -22,6 +30,11 @@ describe('ElectronService', () => { openInMpv: jest.fn().mockResolvedValue(session), openInVlc: jest.fn().mockResolvedValue(session), }; + snackBar = { + open: jest.fn(() => ({ + onAction: () => of(undefined), + })), + }; Object.defineProperty(window, 'electron', { configurable: true, @@ -33,8 +46,25 @@ describe('ElectronService', () => { ElectronService, { provide: MatSnackBar, + useValue: snackBar, + }, + { + provide: DialogService, useValue: { - open: jest.fn(), + openConfirmDialog: jest.fn(), + }, + }, + { + provide: SettingsStore, + useValue: { + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + })), + getSettings: jest.fn(() => ({ + trustedInsecureTlsHosts: [], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), }, }, { @@ -69,6 +99,32 @@ describe('ElectronService', () => { expect(electronBridge.fetchPlaylistByUrl).not.toHaveBeenCalled(); }); + it('shows the trust-host action for Electron-wrapped security errors', async () => { + const securityPayload = { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + }; + electronBridge.fetchPlaylistByUrl.mockRejectedValue( + new Error( + `Error invoking remote method 'FETCH_PLAYLIST_BY_URL': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify( + securityPayload + )}` + ) + ); + + await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { + url: 'https://playlist.local/list.m3u', + }); + await Promise.resolve(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Certificate for this playlist host is invalid.', + 'Trust host', + { duration: 10000 } + ); + }); + it('preserves an absent MPV user-agent so backend fallback headers can apply', async () => { await service.sendIpcEvent('OPEN_MPV_PLAYER', { url: 'https://example.test/live.m3u8', diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 9b2bf8069..e65717678 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -3,11 +3,15 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DataService } from '@iptvnator/services'; +import { DialogService } from '@iptvnator/ui/components'; +import { DataService, SettingsStore } from '@iptvnator/services'; import { AUTO_UPDATE_PLAYLISTS, createDevLogger, + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, ERROR, + normalizeHost, + parseSecurityPolicyError, PlayerContentInfo, Playlist, PLAYLIST_PARSE_BY_URL, @@ -46,7 +50,9 @@ export class ElectronService extends DataService { private eventListeners: { [key: string]: () => void } = {}; private messageListeners = new Map(); private readonly snackBar = inject(MatSnackBar); + private readonly dialogService = inject(DialogService); private readonly store = inject(Store); + private readonly settingsStore = inject(SettingsStore); private readonly translateService = inject(TranslateService); private readonly debugLog = createDevLogger('ElectronService'); private readonly silentXtreamActions = new Set([ @@ -212,7 +218,10 @@ export class ElectronService extends DataService { if (type === AUTO_UPDATE_PLAYLISTS) { const data = payload as Playlist[]; - const playlists = await window.electron.autoUpdatePlaylists(data); + const playlists = await window.electron.autoUpdatePlaylists( + data, + this.settingsStore.getTrustOptions() + ); this.store.dispatch( PlaylistActions.updateManyPlaylists({ playlists, @@ -276,7 +285,11 @@ export class ElectronService extends DataService { const title = payload.title?.trim() || undefined; window.electron - .fetchPlaylistByUrl(payload.url, title) + .fetchPlaylistByUrl( + payload.url, + title, + this.settingsStore.getTrustOptions() + ) .then((result) => { this.store.dispatch( PlaylistActions.handleAddingPlaylistByUrl({ @@ -286,6 +299,14 @@ export class ElectronService extends DataService { ); }) .catch((error: unknown) => { + if ( + this.handlePlaylistSecurityError(error, () => + this.fetchM3uPlaylistFromUrl(payload) + ) + ) { + return; + } + const statusCode = this.extractHttpStatusCode(error); let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED'; if (statusCode === 403) { @@ -331,7 +352,8 @@ export class ElectronService extends DataService { if (data.url && !data.filePath) { playlistObject = await window.electron.fetchPlaylistByUrl( data.url, - data.title + data.title, + this.settingsStore.getTrustOptions() ); } else if (data.filePath && !data.url) { playlistObject = @@ -365,6 +387,14 @@ export class ElectronService extends DataService { ); } catch (error: unknown) { console.error('Playlist refresh error:', error); + if ( + data.url && + this.handlePlaylistSecurityError(error, () => { + void this.updateM3uPlaylistFromFile(data); + }) + ) { + return; + } this.snackBar.open( this.getPlaylistRefreshErrorMessage(error, data), this.translateService.instant('CLOSE'), @@ -425,6 +455,86 @@ export class ElectronService extends DataService { return translated === key ? fallback : translated; } + private handlePlaylistSecurityError( + error: unknown, + retry: () => void + ): boolean { + const securityError = parseSecurityPolicyError(error); + if ( + securityError?.code !== + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ) { + return false; + } + + const ref = this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_INVALID_TLS', + 'Certificate for this playlist host is invalid.' + ), + this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + { duration: 10000 } + ); + + ref.onAction().subscribe(() => { + this.confirmTrustPlaylistHost(securityError.host, retry); + }); + return true; + } + + private confirmTrustPlaylistHost( + host: string | undefined, + retry: () => void + ): void { + if (!host) { + this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN', + 'Could not determine the playlist host. Please retry manually.' + ), + this.translateService.instant('CLOSE'), + { duration: 5000 } + ); + return; + } + + this.dialogService.openConfirmDialog({ + title: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + width: '420px', + onConfirm: () => { + void this.trustPlaylistHost(host).then(retry); + }, + }); + } + + private async trustPlaylistHost(host: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + normalizeHost(item) + ) + ); + trustedHosts.add(normalizeHost(host)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + } + /* private getErrorMessageByStatusCode(status: number) { let message = 'Something went wrong'; switch (status) { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 4bfc30de3..1f482dfb0 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -18,7 +18,10 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { Store } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -94,6 +97,21 @@ class MockSettingsStore { getSettings = () => this._settings(); + getTrustOptions = () => ({ + trustedPrivateNetworkEpgUrls: + ( + this._settings() as typeof DEFAULT_SETTINGS & { + trustedPrivateNetworkEpgUrls?: string[]; + } + ).trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: + ( + this._settings() as typeof DEFAULT_SETTINGS & { + trustedInsecureTlsHosts?: string[]; + } + ).trustedInsecureTlsHosts ?? [], + }); + loadSettings = jest.fn().mockResolvedValue(undefined); updateSettings = jest.fn().mockResolvedValue(undefined); @@ -488,9 +506,7 @@ describe('SettingsComponent', () => { expect( component .players() - .some( - (player) => player.id === VideoPlayer.EmbeddedMpv - ) + .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(true); } ); @@ -515,9 +531,9 @@ describe('SettingsComponent', () => { partialBridgeFixture.detectChanges(); expect(partialBridgeComponent.isDesktop).toBe(true); - expect( - partialBridgeComponent.supportsManagedExternalPlayers - ).toBe(false); + expect(partialBridgeComponent.supportsManagedExternalPlayers).toBe( + false + ); expect( partialBridgeComponent.supportsExternalPlayerPathSettings ).toBe(false); @@ -611,9 +627,7 @@ describe('SettingsComponent', () => { ).toBe(false); if (!resolveSupport) { - throw new Error( - 'Expected embedded MPV support probe to start' - ); + throw new Error('Expected embedded MPV support probe to start'); } resolveSupport({ @@ -882,7 +896,10 @@ describe('SettingsComponent', () => { it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => { const url = 'http://epg-url-here/data.xml'; component.refreshEpg(url); - expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url, { + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); }); it('clears EPG data with a busy state and refreshes all sources on success', async () => { @@ -1112,12 +1129,16 @@ describe('SettingsComponent', () => { component.onSubmit(); await fixture.whenStable(); - expect(mockStore.updateSettings).toHaveBeenCalledWith( - component.settingsForm.value - ); - expect(updateSettings).toHaveBeenCalledWith( - component.settingsForm.value - ); + expect(mockStore.updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + expect(updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); }); it('clears external player paths in Electron when saved as empty', async () => { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 3b8fef08a..a348c5b3a 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -25,7 +25,10 @@ import { import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -222,9 +225,7 @@ export class SettingsComponent implements OnInit, OnDestroy { ], recordingFolder: '', coverSize: 'medium' as CoverSize, - ...(this.supportsEpg - ? { preferUploadedEpgOverXtream: false } - : {}), + ...(this.supportsEpg ? { preferUploadedEpgOverXtream: false } : {}), }); /** Form array with epg sources */ @@ -241,12 +242,10 @@ export class SettingsComponent implements OnInit, OnDestroy { readonly removeAllProgress = signal(null); private settingsStore = inject(SettingsStore); - readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems( - { - supportsEpg: this.supportsEpg, - supportsRemoteControl: this.supportsRemoteControl, - } - ); + readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({ + supportsEpg: this.supportsEpg, + supportsRemoteControl: this.supportsRemoteControl, + }); readonly playlistDeleteSummary = computed( () => { @@ -560,6 +559,10 @@ export class SettingsComponent implements OnInit, OnDestroy { value.preferUploadedEpgOverXtream ?? currentSettings.preferUploadedEpgOverXtream ?? false, + trustedPrivateNetworkEpgUrls: + currentSettings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: + currentSettings.trustedInsecureTlsHosts ?? [], }; } @@ -616,7 +619,10 @@ export class SettingsComponent implements OnInit, OnDestroy { if (!this.epgBridge.supportsDataManagement || !url) { return; } - void this.epgBridge.forceFetchEpg(url); + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); } /** @@ -629,7 +635,8 @@ export class SettingsComponent implements OnInit, OnDestroy { const urls = (this.epgUrl.value as string[]) .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)); - urls.forEach((url) => void this.epgBridge.forceFetchEpg(url)); + const options = this.settingsStore.getTrustOptions(); + urls.forEach((url) => void this.epgBridge.forceFetchEpg(url, options)); } /** diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a4fa83135..1f80c9af6 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. يرجى التحقق من الرابط والمحاولة مرة أخرى.", "ERROR_403": "تم رفض الوصول (403): رفض الخادم الطلب. قد يتطلب الرابط مصادقة أو أن قائمة التشغيل مقيدة.", "ERROR_404": "قائمة التشغيل غير موجودة (404): الرابط لا يشير إلى قائمة تشغيل صالحة. يرجى التحقق من الرابط.", - "ERROR_401": "غير مصرح (401): المصادقة مطلوبة للوصول إلى قائمة التشغيل هذه." + "ERROR_401": "غير مصرح (401): المصادقة مطلوبة للوصول إلى قائمة التشغيل هذه.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "إدراج قائمة تشغيل M3U(8) كنص", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "إجمالي البرامج", "UP_TO_DATE": "EPG محدث، لا حاجة للمزامنة", "RETRY": "إعادة المحاولة", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c1381c693..1d37aafcc 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. عفاك تحقق من الرابط وحاول مرة أخرى.", "ERROR_403": "الوصول مرفوض (403): السيرفر رفض الطلب. يمكن الرابط محتاج مصادقة ولا قائمة التشغيل مقيدة.", "ERROR_404": "قائمة التشغيل ما تلقاتش (404): الرابط ما كيوجهش لقائمة تشغيل صحيحة. عفاك تحقق من الرابط.", - "ERROR_401": "غير مصرح (401): المصادقة مطلوبة باش تدخل لهاد قائمة التشغيل." + "ERROR_401": "غير مصرح (401): المصادقة مطلوبة باش تدخل لهاد قائمة التشغيل.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "دخل قائمة تشغيل m3u(8) على شكل نص", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "مجموع البرامج", "UP_TO_DATE": "EPG محدث، ما خاصش مزامنة", "RETRY": "حاول مرة أخرى", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 1a8f0889c..5f6dbe5ad 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Не ўдалося атрымаць плэйліст. Калі ласка, праверце URL і паспрабуйце яшчэ раз.", "ERROR_403": "Доступ забаронены (403): сервер адхіліў запыт. URL можа патрабаваць аўтэнтыфікацыі або плэйліст абмежаваны.", "ERROR_404": "Плэйліст не знойдзены (404): URL не паказвае на сапраўдны плэйліст. Калі ласка, праверце URL.", - "ERROR_401": "Несанкцыянавана (401): для доступу да гэтага плэйліста патрабуецца аўтэнтыфікацыя." + "ERROR_401": "Несанкцыянавана (401): для доступу да гэтага плэйліста патрабуецца аўтэнтыфікацыя.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Устаўце плэйліст фармату m3u(8) у гэта поле", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Усяго перадач", "UP_TO_DATE": "EPG актуальны, сінхранізацыя не патрэбна", "RETRY": "Паўтарыць", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 886cdd49e..98b7922a2 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Playlist konnte nicht abgerufen werden. Bitte überprüfen Sie die URL und versuchen Sie es erneut.", "ERROR_403": "Zugriff verweigert (403): Der Server hat die Anfrage abgelehnt. Die URL erfordert möglicherweise eine Authentifizierung oder die Playlist ist eingeschränkt.", "ERROR_404": "Playlist nicht gefunden (404): Die URL verweist nicht auf eine gültige Playlist. Bitte überprüfen Sie die URL.", - "ERROR_401": "Nicht autorisiert (401): Für den Zugriff auf diese Playlist ist eine Authentifizierung erforderlich." + "ERROR_401": "Nicht autorisiert (401): Für den Zugriff auf diese Playlist ist eine Authentifizierung erforderlich.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Füge m3u(8)-Wiedergabeliste hier als Text ein", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Programme insgesamt", "UP_TO_DATE": "EPG ist aktuell, keine Synchronisation nötig", "RETRY": "Erneut versuchen", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index befbf9e13..7b9e973f3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Αποτυχία λήψης της λίστας αναπαραγωγής. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.", "ERROR_403": "Άρνηση πρόσβασης (403): Ο διακομιστής απέρριψε το αίτημα. Η διεύθυνση URL μπορεί να απαιτεί έλεγχο ταυτότητας ή η λίστα αναπαραγωγής είναι περιορισμένη.", "ERROR_404": "Η λίστα αναπαραγωγής δεν βρέθηκε (404): Η διεύθυνση URL δεν δείχνει σε έγκυρη λίστα αναπαραγωγής. Ελέγξτε τη διεύθυνση URL.", - "ERROR_401": "Μη εξουσιοδοτημένος (401): Απαιτείται έλεγχος ταυτότητας για πρόσβαση σε αυτή τη λίστα αναπαραγωγής." + "ERROR_401": "Μη εξουσιοδοτημένος (401): Απαιτείται έλεγχος ταυτότητας για πρόσβαση σε αυτή τη λίστα αναπαραγωγής.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Προσθήκη m3u(8) λίστας αναπαραγωγής ως κείμενο", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Σύνολο προγραμμάτων", "UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός", "RETRY": "Επανάληψη", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 1f5c4a7d6..f0add855e 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Failed to fetch the playlist. Please check the URL and try again.", "ERROR_403": "Access denied (403): The server refused the request. The URL may require authentication or the playlist is restricted.", "ERROR_404": "Playlist not found (404): The URL does not point to a valid playlist. Please check the URL.", - "ERROR_401": "Unauthorized (401): Authentication is required to access this playlist." + "ERROR_401": "Unauthorized (401): Authentication is required to access this playlist.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total programs", "UP_TO_DATE": "EPG is up-to-date, no sync needed", "RETRY": "Retry", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 018f429b0..0ce3be118 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "No se pudo obtener la lista de reproducción. Verifica la URL e inténtalo de nuevo.", "ERROR_403": "Acceso denegado (403): el servidor rechazó la solicitud. La URL puede requerir autenticación o la lista de reproducción está restringida.", "ERROR_404": "Lista de reproducción no encontrada (404): la URL no apunta a una lista válida. Verifica la URL.", - "ERROR_401": "No autorizado (401): se requiere autenticación para acceder a esta lista de reproducción." + "ERROR_401": "No autorizado (401): se requiere autenticación para acceder a esta lista de reproducción.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Inserte la playlist m3u(8) como texto", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total de programas", "UP_TO_DATE": "EPG actualizada, no se necesita sincronización", "RETRY": "Reintentar", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d0e2129af..50130074a 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Échec de la récupération de la liste de lecture. Veuillez vérifier l'URL et réessayer.", "ERROR_403": "Accès refusé (403) : le serveur a refusé la requête. L'URL peut nécessiter une authentification ou la liste de lecture est restreinte.", "ERROR_404": "Liste de lecture introuvable (404) : l'URL ne pointe pas vers une liste de lecture valide. Veuillez vérifier l'URL.", - "ERROR_401": "Non autorisé (401) : une authentification est requise pour accéder à cette liste de lecture." + "ERROR_401": "Non autorisé (401) : une authentification est requise pour accéder à cette liste de lecture.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Insérer la liste m3u(8) sous forme de texte", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total des programmes", "UP_TO_DATE": "L'EPG est à jour, aucune synchronisation nécessaire", "RETRY": "Réessayer", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8da29c87b..f24d32eb1 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Impossibile recuperare la playlist. Controlla l'URL e riprova.", "ERROR_403": "Accesso negato (403): il server ha rifiutato la richiesta. L'URL potrebbe richiedere autenticazione o la playlist è soggetta a restrizioni.", "ERROR_404": "Playlist non trovata (404): l'URL non punta a una playlist valida. Controlla l'URL.", - "ERROR_401": "Non autorizzato (401): è richiesta l'autenticazione per accedere a questa playlist." + "ERROR_401": "Non autorizzato (401): è richiesta l'autenticazione per accedere a questa playlist.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Inserisci playlist m3u(8) come testo", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Totale programmi", "UP_TO_DATE": "L'EPG è aggiornato, nessuna sincronizzazione necessaria", "RETRY": "Riprova", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 27889de4d..3e45ce33d 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "プレイリストを取得できませんでした。URLを確認してもう一度お試しください。", "ERROR_403": "アクセスが拒否されました(403):サーバーがリクエストを拒否しました。URLに認証が必要か、プレイリストが制限されている可能性があります。", "ERROR_404": "プレイリストが見つかりません(404):URLは有効なプレイリストを指していません。URLを確認してください。", - "ERROR_401": "未認証(401):このプレイリストにアクセスするには認証が必要です。" + "ERROR_401": "未認証(401):このプレイリストにアクセスするには認証が必要です。", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "m3u(8)プレイリストをテキストとして挿入", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "総番組数", "UP_TO_DATE": "EPGは最新です。同期は不要です", "RETRY": "再試行", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0c6e45542..82a38ca01 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "재생목록을 가져오지 못했습니다. URL을 확인하고 다시 시도해 주세요.", "ERROR_403": "접근 거부됨 (403): 서버가 요청을 거부했습니다. URL에 인증이 필요하거나 재생목록이 제한되어 있을 수 있습니다.", "ERROR_404": "재생목록을 찾을 수 없습니다 (404): URL이 유효한 재생목록을 가리키지 않습니다. URL을 확인해 주세요.", - "ERROR_401": "권한 없음 (401): 이 재생목록에 접근하려면 인증이 필요합니다." + "ERROR_401": "권한 없음 (401): 이 재생목록에 접근하려면 인증이 필요합니다.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "전체 프로그램", "UP_TO_DATE": "EPG가 최신 상태이며 동기화가 필요하지 않습니다", "RETRY": "다시 시도", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2bc801996..1c56af772 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Ophalen van afspeellijst mislukt. Controleer de URL en probeer het opnieuw.", "ERROR_403": "Toegang geweigerd (403): de server heeft het verzoek geweigerd. De URL vereist mogelijk authenticatie of de afspeellijst is beperkt.", "ERROR_404": "Afspeellijst niet gevonden (404): de URL verwijst niet naar een geldige afspeellijst. Controleer de URL.", - "ERROR_401": "Niet geautoriseerd (401): authenticatie is vereist om deze afspeellijst te openen." + "ERROR_401": "Niet geautoriseerd (401): authenticatie is vereist om deze afspeellijst te openen.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Voer een m3u(8)-afspeellijst in als tekst", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Totaal aantal programma's", "UP_TO_DATE": "EPG is up-to-date, synchronisatie niet nodig", "RETRY": "Opnieuw proberen", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e62cea92b..cc288d25e 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Nie udało się pobrać listy odtwarzania. Sprawdź URL i spróbuj ponownie.", "ERROR_403": "Brak dostępu (403): Serwer odrzucił żądanie. URL może wymagać uwierzytelnienia lub lista jest ograniczona.", "ERROR_404": "Nie znaleziono listy odtwarzania (404): URL nie wskazuje prawidłowej listy. Sprawdź URL.", - "ERROR_401": "Brak autoryzacji (401): Dostęp do tej listy odtwarzania wymaga uwierzytelnienia." + "ERROR_401": "Brak autoryzacji (401): Dostęp do tej listy odtwarzania wymaga uwierzytelnienia.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Wklej listę odtwarzania m3u(8) jako tekst", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Wszystkie programy", "UP_TO_DATE": "EPG jest aktualne, synchronizacja nie jest potrzebna", "RETRY": "Spróbuj ponownie", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 90bfab72e..ad485a7b7 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Falha ao buscar a playlist. Verifique a URL e tente novamente.", "ERROR_403": "Acesso negado (403): O servidor recusou a solicitação. A URL pode exigir autenticação ou a playlist está restrita.", "ERROR_404": "Playlist não encontrada (404): A URL não aponta para uma playlist válida. Verifique a URL.", - "ERROR_401": "Não autorizado (401): É necessária autenticação para acessar esta playlist." + "ERROR_401": "Não autorizado (401): É necessária autenticação para acessar esta playlist.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Inserir playlist m3u(8) como texto", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Total de programas", "UP_TO_DATE": "EPG está atualizado, sincronização não é necessária", "RETRY": "Tentar novamente", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f690bcea7..d99dcab30 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Не удалось получить плейлист. Проверьте URL и попробуйте снова.", "ERROR_403": "Доступ запрещён (403): сервер отклонил запрос. Возможно, URL требует аутентификации или плейлист ограничен.", "ERROR_404": "Плейлист не найден (404): URL не указывает на действительный плейлист. Проверьте URL.", - "ERROR_401": "Не авторизовано (401): для доступа к этому плейлисту требуется аутентификация." + "ERROR_401": "Не авторизовано (401): для доступа к этому плейлисту требуется аутентификация.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "Вставьте плейлист формата m3u(8) в данное поле", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Всего программ", "UP_TO_DATE": "EPG актуален, синхронизация не требуется", "RETRY": "Повторить", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8a44cc88a..58ff1e31c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "Oynatma listesi alınamadı. Lütfen URL'yi kontrol edip tekrar deneyin.", "ERROR_403": "Erişim reddedildi (403): Sunucu isteği reddetti. URL kimlik doğrulama gerektiriyor olabilir veya oynatma listesi kısıtlanmış olabilir.", "ERROR_404": "Oynatma listesi bulunamadı (404): URL geçerli bir oynatma listesine işaret etmiyor. Lütfen URL'yi kontrol edin.", - "ERROR_401": "Yetkisiz (401): Bu oynatma listesine erişmek için kimlik doğrulama gerekiyor." + "ERROR_401": "Yetkisiz (401): Bu oynatma listesine erişmek için kimlik doğrulama gerekiyor.", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "m3u(8) oynatma listesini metin olarak girin", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "Toplam program", "UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok", "RETRY": "Tekrar Dene", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index bbb4a10fc..6dedcf2dd 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "获取播放列表失败。请检查 URL 后重试。", "ERROR_403": "拒绝访问 (403):服务器拒绝了请求。该 URL 可能需要身份验证,或播放列表受到限制。", "ERROR_404": "未找到播放列表 (404):该 URL 不指向有效的播放列表。请检查 URL。", - "ERROR_401": "未授权 (401):访问此播放列表需要进行身份验证。" + "ERROR_401": "未授权 (401):访问此播放列表需要进行身份验证。", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "从文本导入播放列表(m3u, m3u8)", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "节目总数", "UP_TO_DATE": "EPG 已为最新,无需同步", "RETRY": "重试", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 524ecf1ea..310488386 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -155,7 +155,12 @@ "ERROR_FETCH_FAILED": "無法擷取播放清單。請檢查網址後重試。", "ERROR_403": "存取被拒(403):伺服器拒絕了此請求。該網址可能需要驗證,或播放清單存取受限。", "ERROR_404": "找不到播放清單(404):該網址未指向有效的播放清單。請檢查網址。", - "ERROR_401": "未授權(401):存取此播放清單需要驗證。" + "ERROR_401": "未授權(401):存取此播放清單需要驗證。", + "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", + "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates." }, "TEXT_IMPORT": { "LABEL": "將 m3u(8)播放清單以純文字的方式插入", @@ -569,6 +574,12 @@ "TOTAL_PROGRAMS": "節目總數", "UP_TO_DATE": "EPG 已是最新,無需同步", "RETRY": "重試", + "ALLOW_PRIVATE_SOURCE": "Allow source", + "ALLOW_PRIVATE_SOURCE_TITLE": "Allow private-network EPG source?", + "ALLOW_PRIVATE_SOURCE_WARNING": "Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.", + "TRUST_TLS_HOST": "Trust host", + "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", + "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 3163c1bdf..6f4d581fe 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -124,14 +124,20 @@ private Node `Agent.options` state. Cross-origin redirects must not forward `Aut `Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies. EPG URLs are strict by default because an M3U playlist can supply them through -`url-tvg`. Operators who intentionally use a LAN-hosted EPG source can opt in -for that run with `IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1`. Directly configured -Xtream, Stalker, and playlist providers retain private-network support, but -still require HTTP(S), reject embedded credentials, and validate redirects. +`url-tvg`. Operators who intentionally use a LAN-hosted EPG source should prefer +the renderer's source-scoped “Allow source” action, which persists the exact EPG +URL in settings and retries that source only. The +`IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1` environment flag remains an +emergency/development process-wide override for strict EPG fetches. Directly +configured Xtream, Stalker, and playlist providers retain private-network +support, but still require HTTP(S), reject embedded credentials, and validate +redirects. Remote playlist TLS certificates are validated by default. The -`IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch is only for explicitly trusted -providers with invalid or self-signed certificates. +renderer can persist a host-scoped invalid-certificate trust decision for a +playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch +is only for explicitly trusted providers with invalid or self-signed +certificates when the host-scoped UI path is not available. ## Filesystem Capabilities diff --git a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts index 19c2b0f5a..434194761 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts @@ -3,10 +3,16 @@ import { EpgImportProgress, EpgRuntimeBridgeService, } from './epg-runtime-bridge.service'; +import { SettingsStore } from '@iptvnator/services'; import { EpgProgressService } from './epg-progress.service'; describe('EpgProgressService', () => { let epgBridge: Partial; + let settingsStore: { + getSettings: jest.Mock; + getTrustOptions: jest.Mock; + updateSettings: jest.Mock; + }; beforeEach(() => { epgBridge = { @@ -15,6 +21,17 @@ describe('EpgProgressService', () => { supportsDataManagement: false, supportsProgress: false, }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; }); afterEach(() => { @@ -30,6 +47,10 @@ describe('EpgProgressService', () => { provide: EpgRuntimeBridgeService, useValue: epgBridge, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, ], }); @@ -57,7 +78,31 @@ describe('EpgProgressService', () => { service.retry('https://example.com/epg.xml'); expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( - 'https://example.com/epg.xml' + 'https://example.com/epg.xml', + { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + } + ); + }); + + it('trusts a private-network source and retries it', async () => { + epgBridge.supportsDataManagement = true; + const service = configureService(); + + await service.trustPrivateNetworkSourceAndRetry( + 'http://192.168.1.30/guide.xml' + ); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + trustedPrivateNetworkEpgUrls: [ + 'http://192.168.1.20/guide.xml', + 'http://192.168.1.30/guide.xml', + ], + }); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( + 'http://192.168.1.30/guide.xml', + expect.any(Object) ); }); diff --git a/libs/epg/data-access/src/lib/epg-progress.service.ts b/libs/epg/data-access/src/lib/epg-progress.service.ts index 173344415..77292f314 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.ts @@ -1,4 +1,9 @@ import { Injectable, computed, inject, signal } from '@angular/core'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + normalizeHost, +} from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; import { EpgImportProgress, EpgRuntimeBridgeService, @@ -7,6 +12,7 @@ import { @Injectable({ providedIn: 'root' }) export class EpgProgressService { private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); private readonly importsMap = signal>( new Map() ); @@ -22,9 +28,7 @@ export class EpgProgressService { readonly queuedImports = computed(() => this.imports() .filter((item) => item.status === 'queued') - .sort( - (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) - ) + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)) ); readonly queuedCount = computed(() => this.queuedImports().length); readonly isVisible = computed(() => this.imports().length > 0); @@ -48,7 +52,46 @@ export class EpgProgressService { if (!this.epgBridge.supportsDataManagement) { return; } - void this.epgBridge.forceFetchEpg(url); + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); + } + + async trustPrivateNetworkSourceAndRetry(url: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedUrls = new Set( + settings.trustedPrivateNetworkEpgUrls ?? [] + ); + trustedUrls.add(url.trim()); + + await this.settingsStore.updateSettings({ + trustedPrivateNetworkEpgUrls: Array.from(trustedUrls), + }); + this.retry(url); + } + + async trustInsecureTlsHostAndRetry( + url: string, + host?: string + ): Promise { + const trustedHost = host ?? this.getHostname(url); + if (!trustedHost) { + return; + } + + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + normalizeHost(item) + ) + ); + trustedHosts.add(normalizeHost(trustedHost)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + this.retry(url); } private initializeListener(): void { @@ -71,11 +114,31 @@ export class EpgProgressService { return updated; }); - if (progress.status === 'complete' || progress.status === 'error') { + if ( + progress.status === 'complete' || + (progress.status === 'error' && !this.isActionableError(progress)) + ) { setTimeout(() => this.removeImport(progress.url), 5000); } } + private isActionableError(progress: EpgImportProgress): boolean { + return ( + progress.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked || + progress.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ); + } + + private getHostname(url: string): string | undefined { + try { + return new URL(url).hostname; + } catch { + return undefined; + } + } + private removeImport(url: string): void { this.importsMap.update((current) => { const updated = new Map(current); diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts index f73c40f68..c9f9f6f05 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts @@ -85,9 +85,13 @@ describe('EpgRuntimeBridgeService', () => { success: true, }); - expect(fetchEpg).toHaveBeenCalledWith(['https://example.com/epg.xml']); + expect(fetchEpg).toHaveBeenCalledWith( + ['https://example.com/epg.xml'], + undefined + ); expect(forceFetchEpg).toHaveBeenCalledWith( - 'https://example.com/epg.xml' + 'https://example.com/epg.xml', + undefined ); expect(clearEpgData).toHaveBeenCalledTimes(1); }); diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 339f209e2..4082b5d73 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -9,6 +9,7 @@ import { ElectronBridgeEpgFreshnessResult, ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeResult, + ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; @@ -76,20 +77,28 @@ export class EpgRuntimeBridgeService { return this.runtime.supportsEpgProgramSearch; } - fetchEpg(urls: string[]): Promise { + fetchEpg( + urls: string[], + options?: ElectronBridgeTrustOptions + ): Promise { if (!this.supportsImport) { return Promise.resolve(null); } - return this.bridge?.fetchEpg?.(urls) ?? Promise.resolve(null); + return this.bridge?.fetchEpg?.(urls, options) ?? Promise.resolve(null); } - forceFetchEpg(url: string): Promise { + forceFetchEpg( + url: string, + options?: ElectronBridgeTrustOptions + ): Promise { if (!this.supportsDataManagement) { return Promise.resolve(null); } - return this.bridge?.forceFetchEpg?.(url) ?? Promise.resolve(null); + return ( + this.bridge?.forceFetchEpg?.(url, options) ?? Promise.resolve(null) + ); } clearEpgData(): Promise { diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 44331afd0..136779e92 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; +import { SettingsStore } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { EpgService } from './epg.service'; @@ -9,6 +10,7 @@ describe('EpgService', () => { let service: EpgService; let epgBridge: Partial; let snackBar: { open: jest.Mock }; + let settingsStore: { getSettings: jest.Mock; getTrustOptions: jest.Mock }; beforeEach(() => { epgBridge = { @@ -22,6 +24,16 @@ describe('EpgService', () => { snackBar = { open: jest.fn(), }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + })), + }; TestBed.configureTestingModule({ providers: [ @@ -40,6 +52,10 @@ describe('EpgService', () => { instant: (key: string) => key, }, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, ], }); @@ -61,10 +77,13 @@ describe('EpgService', () => { 'https://example.com/other.xml', ]); - expect(epgBridge.fetchEpg).toHaveBeenCalledWith([ - 'https://example.com/epg.xml', - 'https://example.com/other.xml', - ]); + expect(epgBridge.fetchEpg).toHaveBeenCalledWith( + ['https://example.com/epg.xml', 'https://example.com/other.xml'], + { + trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], + trustedInsecureTlsHosts: ['playlist.local'], + } + ); }); it('does not show a fetch error when the bridge returns no result', async () => { diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 4a07c337c..9bc9b84f7 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -8,6 +8,7 @@ import { EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { normalizeEpgPrograms } from './epg-program-normalization.util'; @@ -25,6 +26,7 @@ export class EpgService { private snackBar = inject(MatSnackBar); private translate = inject(TranslateService); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); private epgAvailable = new BehaviorSubject(false); private currentEpgPrograms = new BehaviorSubject([]); @@ -46,7 +48,12 @@ export class EpgService { const validUrls = urls.filter((url) => url?.trim()); if (validUrls.length === 0) return; - from(this.epgBridge.fetchEpg(validUrls)) + from( + this.epgBridge.fetchEpg( + validUrls, + this.settingsStore.getTrustOptions() + ) + ) .pipe( tap((result) => { if (result === null) return; @@ -272,7 +279,9 @@ export class EpgService { return of(new Map()); } - return from(this.epgBridge.getChannelMetadata(normalizedChannelIds)).pipe( + return from( + this.epgBridge.getChannelMetadata(normalizedChannelIds) + ).pipe( map((metadataByChannelId) => { return new Map( normalizedChannelIds.map((channelId) => [ diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 02584545e..17912fcd0 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { DialogService } from '@iptvnator/ui/components'; import { DataService, @@ -12,12 +13,15 @@ import { PlaybackPositionService, PlaylistRefreshService, RuntimeCapabilitiesService, + SettingsStore, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, PLAYLIST_UPDATE, Playlist, PlaylistMeta, + SECURITY_ERROR_PREFIX, } from '@iptvnator/shared/interfaces'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistRefreshActionService } from './playlist-refresh-action.service'; @@ -84,6 +88,11 @@ describe('PlaylistRefreshActionService', () => { let playbackPositionService: { getAllPlaybackPositions: jest.Mock; }; + let settingsStore: { + getSettings: jest.Mock; + getTrustOptions: jest.Mock; + updateSettings: jest.Mock; + }; let runtime: { supportsPlaylistRefresh: boolean; supportsXtreamSqliteDataSource: boolean; @@ -131,7 +140,9 @@ describe('PlaylistRefreshActionService', () => { navigate: jest.fn().mockResolvedValue(true), }; snackBar = { - open: jest.fn(), + open: jest.fn(() => ({ + onAction: () => of(undefined), + })), }; store = { dispatch: jest.fn(), @@ -142,6 +153,16 @@ describe('PlaylistRefreshActionService', () => { playbackPositionService = { getAllPlaybackPositions: jest.fn().mockResolvedValue([]), }; + settingsStore = { + getSettings: jest.fn(() => ({ + trustedInsecureTlsHosts: ['playlist.local'], + })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: ['playlist.local'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; runtime = { supportsPlaylistRefresh: true, supportsXtreamSqliteDataSource: true, @@ -194,6 +215,10 @@ describe('PlaylistRefreshActionService', () => { provide: RuntimeCapabilitiesService, useValue: runtime, }, + { + provide: SettingsStore, + useValue: settingsStore, + }, { provide: PlaylistContextFacade, useValue: { @@ -270,17 +295,80 @@ describe('PlaylistRefreshActionService', () => { service.refresh(playlist); - expect(dataService.sendIpcEvent).toHaveBeenCalledWith( - PLAYLIST_UPDATE, - { - id: 'playlist-url', - title: 'URL playlist', - url: 'https://example.com/playlist.m3u', - } - ); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, { + id: 'playlist-url', + title: 'URL playlist', + url: 'https://example.com/playlist.m3u', + }); expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); }); + it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => { + const playlist = createPlaylistMeta({ + _id: 'playlist-url', + title: 'URL playlist', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://playlist.local/list.m3u', + }); + playlistRefreshService.refreshPlaylist.mockResolvedValue({ + _id: playlist._id, + playlist: { items: [] }, + } as Playlist); + + service.refresh(playlist); + await Promise.resolve(); + + expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://playlist.local/list.m3u', + trustedInsecureTlsHosts: ['playlist.local'], + }) + ); + }); + + it('clears active M3U loading before showing a trust-host prompt', async () => { + routeProvider.set('playlists'); + resolvedPlaylistId.set('playlist-url'); + const playlist = createPlaylistMeta({ + _id: 'playlist-url', + title: 'URL playlist', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://playlist.local/list.m3u', + }); + playlistRefreshService.refreshPlaylist.mockRejectedValue( + new Error( + `Error invoking remote method 'PLAYLIST_REFRESH': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify( + { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: 'playlist.local', + message: + 'Certificate for this playlist host is invalid.', + } + )}` + ) + ); + + service.refresh(playlist); + await Promise.resolve(); + await Promise.resolve(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Certificate for this playlist host is invalid.', + 'Trust host', + { duration: 10000 } + ); + expect(store.dispatch).toHaveBeenCalledWith( + ChannelActions.setChannelsLoading({ loading: true }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + ChannelActions.setChannelsLoading({ loading: false }) + ); + }); + it('treats Xtream playlists as refreshable only when the SQLite data source is available', () => { runtime.supportsXtreamSqliteDataSource = true; diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 03c800bd9..e74d7b26f 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -12,10 +12,17 @@ import { PlaybackPositionService, PlaylistRefreshService, RuntimeCapabilitiesService, + SettingsStore, XtreamPendingRestoreService, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; -import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + normalizeHost, + parseSecurityPolicyError, + PLAYLIST_UPDATE, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; export interface XtreamRefreshPreparationState { @@ -38,6 +45,7 @@ export class PlaylistRefreshActionService { private readonly playbackPositionService = inject(PlaybackPositionService); private readonly playlistRefreshService = inject(PlaylistRefreshService); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsStore = inject(SettingsStore); private readonly playlistContext = inject(PlaylistContextFacade); private readonly pendingRestoreService = inject( XtreamPendingRestoreService @@ -62,7 +70,9 @@ export class PlaylistRefreshActionService { return true; } - return this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath); + return ( + this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath) + ); } refresh(playlist: PlaylistMeta): void { @@ -205,6 +215,9 @@ export class PlaylistRefreshActionService { title: item.title, url: item.url, filePath: item.filePath, + trustedInsecureTlsHosts: + this.settingsStore.getTrustOptions() + .trustedInsecureTlsHosts, }); this.store.dispatch( @@ -227,19 +240,26 @@ export class PlaylistRefreshActionService { } catch (error) { if (!isDbAbortError(error)) { console.error('Error refreshing playlist:', error); + if ( + item.url && + this.handlePlaylistSecurityError(error, () => + this.refresh(item) + ) + ) { + return; + } this.snackBar.open( this.getRefreshErrorMessage(error, item), this.translate.instant('CLOSE'), { duration: 5000 } ); } - + } finally { if (isActiveM3uRoute) { this.store.dispatch( ChannelActions.setChannelsLoading({ loading: false }) ); } - } finally { this.isRefreshing.set(false); } } @@ -258,6 +278,90 @@ export class PlaylistRefreshActionService { return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); } + private handlePlaylistSecurityError( + error: unknown, + retry: () => void + ): boolean { + const securityError = parseSecurityPolicyError(error); + if ( + securityError?.code !== + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ) { + return false; + } + + const ref = this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_INVALID_TLS', + 'Certificate for this playlist host is invalid.' + ), + this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + { duration: 10000 } + ); + ref.onAction().subscribe(() => { + this.confirmTrustPlaylistHost(securityError.host, retry); + }); + return true; + } + + private confirmTrustPlaylistHost( + host: string | undefined, + retry: () => void + ): void { + if (!host) { + this.snackBar.open( + this.translateWithFallback( + 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN', + 'Could not determine the playlist host. Please retry manually.' + ), + this.translate.instant('CLOSE'), + { duration: 5000 } + ); + return; + } + + this.dialogService.openConfirmDialog({ + title: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + 'Trust host' + ), + width: '420px', + onConfirm: () => { + void this.trustPlaylistHost(host).then(retry); + }, + }); + } + + private async trustPlaylistHost(host: string): Promise { + const settings = this.settingsStore.getSettings(); + const trustedHosts = new Set( + (settings.trustedInsecureTlsHosts ?? []).map((item) => + normalizeHost(item) + ) + ); + trustedHosts.add(normalizeHost(host)); + + await this.settingsStore.updateSettings({ + trustedInsecureTlsHosts: Array.from(trustedHosts), + }); + } + + private translateWithFallback(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return translated === key ? fallback : translated; + } + private updateRefreshPreparationFromEvent( playlistId: string, operationId: string, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index f6042734e..94e48daef 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -9,6 +9,7 @@ import { import { StorageMap } from '@ngx-pwa/local-storage'; import { firstValueFrom } from 'rxjs'; import { + ElectronBridgeTrustOptions, Language, Settings, StartupBehavior, @@ -41,6 +42,8 @@ const DEFAULT_SETTINGS: Settings = { recordingFolder: '', coverSize: 'medium', preferUploadedEpgOverXtream: false, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], }; let embeddedMpvPrepareScheduled = false; @@ -157,6 +160,12 @@ export const SettingsStore = signalStore( preferUploadedEpgOverXtream: store.preferUploadedEpgOverXtream?.() ?? DEFAULT_SETTINGS.preferUploadedEpgOverXtream, + trustedPrivateNetworkEpgUrls: + store.trustedPrivateNetworkEpgUrls?.() ?? + DEFAULT_SETTINGS.trustedPrivateNetworkEpgUrls, + trustedInsecureTlsHosts: + store.trustedInsecureTlsHosts?.() ?? + DEFAULT_SETTINGS.trustedInsecureTlsHosts, }; }, @@ -170,6 +179,15 @@ export const SettingsStore = signalStore( ); }, + getTrustOptions(): ElectronBridgeTrustOptions { + const settings = this.getSettings(); + return { + trustedPrivateNetworkEpgUrls: + settings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], + }; + }, + getPlayer() { return store.player(); }, diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 038682309..26b132d62 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -25,6 +25,7 @@ export * from './lib/playlist.interface'; export * from './lib/portal-activity-item.interface'; export * from './lib/portal-debug.interface'; export * from './lib/portal-playback.interface'; +export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; export * from './lib/stalker-portal-actions.enum'; export * from './lib/store-keys.enum'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index ac21d369a..538f84f1f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -67,6 +67,14 @@ export const ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES = { export type ElectronBridgeEpgProgressStatus = (typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES)[keyof typeof ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES]; +export const ELECTRON_BRIDGE_SECURITY_ERROR_CODES = { + EpgPrivateNetworkBlocked: 'epg-private-network-blocked', + InvalidTlsCertificate: 'invalid-tls-certificate', +} as const; + +export type ElectronBridgeSecurityErrorCode = + (typeof ELECTRON_BRIDGE_SECURITY_ERROR_CODES)[keyof typeof ELECTRON_BRIDGE_SECURITY_ERROR_CODES]; + export const ELECTRON_BRIDGE_DB_OPERATION_STATUSES = { Cancelled: 'cancelled', Completed: 'completed', @@ -182,6 +190,11 @@ export interface ElectronBridgeEpgFetchResult extends ElectronBridgeResult { skipped?: string[]; } +export interface ElectronBridgeTrustOptions { + trustedPrivateNetworkEpgUrls?: string[]; + trustedInsecureTlsHosts?: string[]; +} + export interface ElectronBridgeEpgFreshnessResult { staleUrls: string[]; freshUrls: string[]; @@ -197,6 +210,8 @@ export interface ElectronBridgeEpgProgress { status: ElectronBridgeEpgProgressStatus; stats?: ElectronBridgeEpgProgressStats; error?: string; + errorCode?: ElectronBridgeSecurityErrorCode; + errorHost?: string; queuePosition?: number; } @@ -437,7 +452,11 @@ export interface ElectronBridgeApi { onWindowStateChange: ( callback: (state: ElectronBridgeWindowState) => void ) => () => void; - fetchPlaylistByUrl: (url: string, title?: string) => Promise; + fetchPlaylistByUrl: ( + url: string, + title?: string, + options?: ElectronBridgeTrustOptions + ) => Promise; updatePlaylistFromFilePath: ( filePath: string, title: string @@ -479,8 +498,14 @@ export interface ElectronBridgeApi { startTime?: number, headers?: Record ) => Promise; - autoUpdatePlaylists: (playlists: Playlist[]) => Promise; - fetchEpg: (urls: string[]) => Promise; + autoUpdatePlaylists: ( + playlists: Playlist[], + options?: ElectronBridgeTrustOptions + ) => Promise; + fetchEpg: ( + urls: string[], + options?: ElectronBridgeTrustOptions + ) => Promise; getChannelPrograms: (channelId: string) => Promise; getCurrentProgramsBatch: ( channelIds: string[] @@ -493,7 +518,10 @@ export interface ElectronBridgeApi { skip: number, limit: number ) => Promise; - forceFetchEpg: (url: string) => Promise; + forceFetchEpg: ( + url: string, + options?: ElectronBridgeTrustOptions + ) => Promise; clearEpgData: () => Promise; checkEpgFreshness: ( urls: string[], diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts index 87ad10681..b02ee028f 100644 --- a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -25,4 +25,5 @@ export interface PlaylistRefreshPayload { title: string; filePath?: string; url?: string; + trustedInsecureTlsHosts?: string[]; } diff --git a/libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts b/libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts new file mode 100644 index 000000000..b6e331ea4 --- /dev/null +++ b/libs/shared/interfaces/src/lib/security-policy-error.utils.spec.ts @@ -0,0 +1,56 @@ +import { + normalizeHost, + parseSecurityPolicyError, + SECURITY_ERROR_PREFIX, +} from './security-policy-error.utils'; + +describe('security-policy-error utils', () => { + it('parses plain serialized security errors', () => { + expect( + parseSecurityPolicyError( + `${SECURITY_ERROR_PREFIX}${JSON.stringify({ + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + })}` + ) + ).toEqual({ + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + }); + }); + + it('parses security errors wrapped by Electron ipcRenderer.invoke', () => { + expect( + parseSecurityPolicyError( + new Error( + `Error invoking remote method 'FETCH_PLAYLIST_BY_URL': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify( + { + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: + 'Certificate for this playlist host is invalid.', + } + )}` + ) + ) + ).toEqual({ + code: 'INVALID_TLS_CERTIFICATE', + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + }); + }); + + it('ignores malformed security payloads', () => { + expect( + parseSecurityPolicyError( + `${SECURITY_ERROR_PREFIX}{"code":"INVALID_TLS_CERTIFICATE"}` + ) + ).toBeNull(); + }); + + it('normalizes host values for trust comparisons', () => { + expect(normalizeHost(' [EXAMPLE.Local] ')).toBe('example.local'); + }); +}); diff --git a/libs/shared/interfaces/src/lib/security-policy-error.utils.ts b/libs/shared/interfaces/src/lib/security-policy-error.utils.ts new file mode 100644 index 000000000..275d6b820 --- /dev/null +++ b/libs/shared/interfaces/src/lib/security-policy-error.utils.ts @@ -0,0 +1,76 @@ +export const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; + +export interface SerializedSecurityError { + readonly code: string; + readonly host?: string; + readonly message: string; +} + +function readErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object') { + const message = (error as { readonly message?: unknown }).message; + if (typeof message === 'string') { + return message; + } + } + + return undefined; +} + +function parseSecurityPayload(payload: string): SerializedSecurityError | null { + try { + const parsed = JSON.parse(payload); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.code === 'string' && + typeof parsed.message === 'string' + ) { + return { + code: parsed.code, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + message: parsed.message, + }; + } + } catch { + return null; + } + + return null; +} + +export function parseSecurityPolicyError( + error: unknown +): SerializedSecurityError | null { + const message = readErrorMessage(error); + const prefixIndex = message?.indexOf(SECURITY_ERROR_PREFIX) ?? -1; + if (!message || prefixIndex < 0) { + return null; + } + + const payload = message.slice(prefixIndex + SECURITY_ERROR_PREFIX.length); + const parsed = parseSecurityPayload(payload); + if (parsed) { + return parsed; + } + + const payloadEnd = payload.lastIndexOf('}'); + return payloadEnd >= 0 + ? parseSecurityPayload(payload.slice(0, payloadEnd + 1)) + : null; +} + +export function normalizeHost(host: string): string { + return host + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, '$1'); +} diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 6f8e44a20..ebf0d0398 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -66,4 +66,16 @@ export interface Settings { * Only meaningful for Xtream playlists in Electron. */ preferUploadedEpgOverXtream?: boolean; + /** + * Exact EPG source URLs the user has allowed to resolve to private/LAN + * network addresses. Kept source-scoped instead of disabling SSRF + * protection globally. + */ + trustedPrivateNetworkEpgUrls?: string[]; + /** + * Lowercase hostnames whose invalid TLS certificates the user has chosen + * to trust. This is host-scoped and does not disable TLS validation for + * unrelated playlist or EPG hosts. + */ + trustedInsecureTlsHosts?: string[]; } diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html index cc22a7629..a767ffc1d 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.html @@ -22,9 +22,7 @@ mat-icon-button class="minimize-btn" (click)="toggleMinimize()" - [attr.aria-label]=" - minimized() ? 'Expand' : 'Minimize' - " + [attr.aria-label]="minimized() ? 'Expand' : 'Minimize'" > {{ minimized() ? 'unfold_more' : 'unfold_less' }} @@ -50,9 +48,7 @@ >
{{ getStatusIcon(item.status) }} @@ -60,6 +56,16 @@ {{ getDisplayUrl(item.url) }} @if (item.status === 'error') { + @if (isActionableSecurityError(item)) { + + }
@@ -137,19 +139,14 @@ {{ i + 1 }} - + {{ getDisplayUrl(item.url) }} diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss index c032d61f8..f3d46d3c2 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss @@ -192,6 +192,17 @@ ); } } + + .trust-btn { + flex: 0 0 auto; + min-width: 0; + height: 26px; + padding: 0 8px; + border-radius: 6px; + font-size: 0.72rem; + line-height: 24px; + white-space: nowrap; + } } &:hover .dismiss-btn { diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts index 24b9141f1..ce8cb2a8d 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts @@ -1,19 +1,54 @@ import { Component, computed, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, +} from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EpgImportProgress, EpgProgressService, } from '@iptvnator/epg/data-access'; +import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces'; + +interface EpgTrustConfirmDialogData { + confirmLabel: string; + message: string; + title: string; +} + +@Component({ + selector: 'app-epg-trust-confirm-dialog', + imports: [MatButtonModule, MatDialogModule, TranslatePipe], + template: ` +

{{ data.title }}

+ + {{ data.message }} + + + + + + `, +}) +class EpgTrustConfirmDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); +} @Component({ selector: 'app-epg-progress-panel', standalone: true, imports: [ MatButtonModule, + MatDialogModule, MatIconModule, MatTooltip, MatProgressBar, @@ -24,6 +59,8 @@ import { }) export class EpgProgressPanelComponent { private readonly epgProgress = inject(EpgProgressService); + private readonly dialog = inject(MatDialog); + private readonly translate = inject(TranslateService); readonly imports = this.epgProgress.imports; readonly isVisible = this.epgProgress.isVisible; @@ -46,9 +83,7 @@ export class EpgProgressPanelComponent { get queuedImports() { return this.imports() .filter((item) => item.status === 'queued') - .sort( - (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) - ); + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); } getStatusIcon(status: EpgImportProgress['status']): string { @@ -88,4 +123,102 @@ export class EpgProgressPanelComponent { retry(url: string): void { this.epgProgress.retry(url); } + + isActionableSecurityError(item: EpgImportProgress): boolean { + return ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked || + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate + ); + } + + getActionLabel(item: EpgImportProgress): string { + if ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked + ) { + return this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE', + 'Allow source' + ); + } + + return this.translateWithFallback('EPG.TRUST_TLS_HOST', 'Trust host'); + } + + confirmTrust(item: EpgImportProgress): void { + if ( + item.errorCode === + ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked + ) { + this.openTrustDialog( + { + title: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE_TITLE', + 'Allow private-network EPG source?' + ), + message: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE_WARNING', + 'Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.' + ), + confirmLabel: this.translateWithFallback( + 'EPG.ALLOW_PRIVATE_SOURCE', + 'Allow source' + ), + }, + () => { + void this.epgProgress.trustPrivateNetworkSourceAndRetry( + item.url + ); + } + ); + return; + } + + this.openTrustDialog( + { + title: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST_TITLE', + 'Trust invalid certificate?' + ), + message: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST_WARNING', + 'Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.' + ), + confirmLabel: this.translateWithFallback( + 'EPG.TRUST_TLS_HOST', + 'Trust host' + ), + }, + () => { + void this.epgProgress.trustInsecureTlsHostAndRetry( + item.url, + item.errorHost + ); + } + ); + } + + private openTrustDialog( + data: EpgTrustConfirmDialogData, + onConfirm: () => void + ): void { + this.dialog + .open( + EpgTrustConfirmDialogComponent, + { data, width: '420px' } + ) + .afterClosed() + .subscribe((confirmed) => { + if (confirmed) { + onConfirm(); + } + }); + } + + private translateWithFallback(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return translated === key ? fallback : translated; + } }