From 6c771a2a955e4076750ea9e99814ab322b54e258 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 12 Jun 2026 19:56:28 +0200 Subject: [PATCH] fix: address scoped trust review feedback --- .../src/app/events/playlist-source.ts | 18 +--- .../src/app/util/secure-https.ts | 8 +- .../src/app/util/security-errors.ts | 73 ++++++--------- .../src/app/workers/epg-parser.worker.ts | 29 ++---- .../app/workers/playlist-refresh.worker.ts | 18 +--- .../src/app/services/electron.service.spec.ts | 60 +++++++++++- apps/web/src/app/services/electron.service.ts | 91 ++++--------------- .../app/settings/settings.component.spec.ts | 15 +++ .../src/app/settings/settings.component.ts | 17 +--- 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 | 1 + 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 ++- .../src/lib/epg-progress.service.spec.ts | 5 + .../src/lib/epg-progress.service.ts | 27 ++---- .../data-access/src/lib/epg.service.spec.ts | 6 +- libs/epg/data-access/src/lib/epg.service.ts | 17 ++-- .../playlist-refresh-action.service.spec.ts | 53 ++++++++++- .../lib/playlist-refresh-action.service.ts | 78 ++++------------ .../src/lib/settings-store.service.ts | 10 ++ libs/shared/interfaces/src/index.ts | 1 + .../lib/security-policy-error.utils.spec.ts | 56 ++++++++++++ .../src/lib/security-policy-error.utils.ts | 76 ++++++++++++++++ 37 files changed, 576 insertions(+), 304 deletions(-) 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/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index 90a52a456..a858927cd 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -9,6 +9,7 @@ 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'; @@ -58,23 +59,6 @@ export async function fetchPlaylistFromUrl( ); } -function getHostnameFromErrorUrl( - error: unknown, - fallbackUrl: string -): string | undefined { - const configUrl = - error && typeof error === 'object' - ? ((error as { config?: { url?: string } }).config?.url ?? - fallbackUrl) - : fallbackUrl; - - try { - return new URL(configUrl).hostname.toLowerCase(); - } catch { - return undefined; - } -} - export async function fetchPlaylistFromFile( filePath: string, title: string diff --git a/apps/electron-backend/src/app/util/secure-https.ts b/apps/electron-backend/src/app/util/secure-https.ts index 3e4864eb4..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,13 +19,6 @@ export function isInsecureTlsAllowed(): boolean { return value === '1' || value === 'true'; } -function normalizeHost(host: string): string { - return host - .trim() - .toLowerCase() - .replace(/^\[(.*)\]$/, '$1'); -} - function shouldTrustInvalidCertificateForHost( url: URL | undefined, trustedHosts: readonly string[] diff --git a/apps/electron-backend/src/app/util/security-errors.ts b/apps/electron-backend/src/app/util/security-errors.ts index c898a738e..421df1223 100644 --- a/apps/electron-backend/src/app/util/security-errors.ts +++ b/apps/electron-backend/src/app/util/security-errors.ts @@ -1,12 +1,12 @@ -import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces'; - -export const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; - -export interface SerializedSecurityError { - code: string; - host?: string; - message: string; -} +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', @@ -60,40 +60,6 @@ export function isInvalidTlsCertificateError(error: unknown): boolean { ); } -export function parseSecurityPolicyError( - error: unknown -): SerializedSecurityError | null { - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : undefined; - if (!message?.startsWith(SECURITY_ERROR_PREFIX)) { - return null; - } - - try { - const parsed = JSON.parse(message.slice(SECURITY_ERROR_PREFIX.length)); - 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 createInvalidTlsCertificateError( host: string | undefined, message = 'Certificate for this playlist host is invalid.' @@ -104,3 +70,24 @@ export function createInvalidTlsCertificateError( 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/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index 381bebe13..31f647506 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -15,7 +15,11 @@ import { UnsafeUrlError, } from '../events/url-safety'; import { createPlaylistAgentFactory } from '../util/secure-https'; -import { isInvalidTlsCertificateError } from '../util/security-errors'; +import { + getHostnameFromErrorUrl, + getHostnameFromUrl, + isInvalidTlsCertificateError, +} from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { getNativeModuleSearchPaths, @@ -262,27 +266,6 @@ function isTrustedPrivateNetworkEpgSource( ); } -function getHostname(url: string): string | undefined { - try { - return new URL(url).hostname.toLowerCase(); - } catch { - return undefined; - } -} - -function getHostnameFromErrorUrl( - error: unknown, - fallbackUrl: string -): string | undefined { - const configUrl = - error && typeof error === 'object' - ? ((error as { config?: { url?: string } }).config?.url ?? - fallbackUrl) - : fallbackUrl; - - return getHostname(configUrl); -} - function toEpgFetchError( error: unknown, url: string @@ -298,7 +281,7 @@ function toEpgFetchError( new Error('EPG source points to private network and was blocked.'), { code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked, - host: getHostname(url), + host: getHostnameFromUrl(url), } ); } 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 d55194950..8dd781fab 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -17,6 +17,7 @@ import type { } from './playlist-refresh.worker.types'; import { createInvalidTlsCertificateError, + getHostnameFromErrorUrl, isInvalidTlsCertificateError, } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; @@ -131,23 +132,6 @@ async function fetchPlaylistFromUrl( ); } -function getHostnameFromErrorUrl( - error: unknown, - fallbackUrl: string -): string | undefined { - const configUrl = - error && typeof error === 'object' - ? ((error as { config?: { url?: string } }).config?.url ?? - fallbackUrl) - : fallbackUrl; - - try { - return new URL(configUrl).hostname.toLowerCase(); - } catch { - return undefined; - } -} - async function fetchPlaylistFromFile( payload: PlaylistRefreshPayload ): Promise { 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 8e337f952..e65717678 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -9,8 +9,9 @@ import { AUTO_UPDATE_PLAYLISTS, createDevLogger, ELECTRON_BRIDGE_SECURITY_ERROR_CODES, - ElectronBridgeTrustOptions, ERROR, + normalizeHost, + parseSecurityPolicyError, PlayerContentInfo, Playlist, PLAYLIST_PARSE_BY_URL, @@ -42,14 +43,6 @@ interface ErrorStatus { readonly status?: number; } -interface ParsedSecurityError { - readonly code: string; - readonly host?: string; - readonly message: string; -} - -const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; - @Injectable({ providedIn: 'root', }) @@ -227,7 +220,7 @@ export class ElectronService extends DataService { const data = payload as Playlist[]; const playlists = await window.electron.autoUpdatePlaylists( data, - this.getTrustOptions() + this.settingsStore.getTrustOptions() ); this.store.dispatch( PlaylistActions.updateManyPlaylists({ @@ -292,7 +285,11 @@ export class ElectronService extends DataService { const title = payload.title?.trim() || undefined; window.electron - .fetchPlaylistByUrl(payload.url, title, this.getTrustOptions()) + .fetchPlaylistByUrl( + payload.url, + title, + this.settingsStore.getTrustOptions() + ) .then((result) => { this.store.dispatch( PlaylistActions.handleAddingPlaylistByUrl({ @@ -356,7 +353,7 @@ export class ElectronService extends DataService { playlistObject = await window.electron.fetchPlaylistByUrl( data.url, data.title, - this.getTrustOptions() + this.settingsStore.getTrustOptions() ); } else if (data.filePath && !data.url) { playlistObject = @@ -458,20 +455,11 @@ export class ElectronService extends DataService { return translated === key ? fallback : translated; } - private getTrustOptions(): ElectronBridgeTrustOptions { - const settings = this.settingsStore.getSettings(); - return { - trustedPrivateNetworkEpgUrls: - settings.trustedPrivateNetworkEpgUrls ?? [], - trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], - }; - } - private handlePlaylistSecurityError( error: unknown, retry: () => void ): boolean { - const securityError = this.parseSecurityPolicyError(error); + const securityError = parseSecurityPolicyError(error); if ( securityError?.code !== ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate @@ -502,6 +490,14 @@ export class ElectronService extends DataService { 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; } @@ -529,63 +525,16 @@ export class ElectronService extends DataService { const settings = this.settingsStore.getSettings(); const trustedHosts = new Set( (settings.trustedInsecureTlsHosts ?? []).map((item) => - this.normalizeHost(item) + normalizeHost(item) ) ); - trustedHosts.add(this.normalizeHost(host)); + trustedHosts.add(normalizeHost(host)); await this.settingsStore.updateSettings({ trustedInsecureTlsHosts: Array.from(trustedHosts), }); } - private parseSecurityPolicyError( - error: unknown - ): ParsedSecurityError | null { - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : this.getErrorDetails(error)?.message; - - if (!message?.startsWith(SECURITY_ERROR_PREFIX)) { - return null; - } - - try { - const parsed = JSON.parse( - message.slice(SECURITY_ERROR_PREFIX.length) - ); - 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; - } - - private normalizeHost(host: string): string { - return host - .trim() - .toLowerCase() - .replace(/^\[(.*)\]$/, '$1'); - } - /* 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 0ad660b96..1f482dfb0 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -97,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); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 2c9c40635..a348c5b3a 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -51,7 +51,6 @@ import { import { EmbeddedMpvSupport, CoverSize, - ElectronBridgeTrustOptions, Language, normalizeExternalPlayerArguments, Settings, @@ -620,7 +619,10 @@ export class SettingsComponent implements OnInit, OnDestroy { if (!this.epgBridge.supportsDataManagement || !url) { return; } - void this.epgBridge.forceFetchEpg(url, this.getEpgTrustOptions()); + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); } /** @@ -633,19 +635,10 @@ export class SettingsComponent implements OnInit, OnDestroy { const urls = (this.epgUrl.value as string[]) .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)); - const options = this.getEpgTrustOptions(); + const options = this.settingsStore.getTrustOptions(); urls.forEach((url) => void this.epgBridge.forceFetchEpg(url, options)); } - private getEpgTrustOptions(): ElectronBridgeTrustOptions { - const settings = this.settingsStore.getSettings(); - return { - trustedPrivateNetworkEpgUrls: - settings.trustedPrivateNetworkEpgUrls ?? [], - trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], - }; - } - /** * Initializes new entry in form array for EPG URL */ 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 eefed2b4c..f0add855e 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -157,6 +157,7 @@ "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_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." 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/libs/epg/data-access/src/lib/epg-progress.service.spec.ts b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts index 6e16bddc7..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 @@ -10,6 +10,7 @@ describe('EpgProgressService', () => { let epgBridge: Partial; let settingsStore: { getSettings: jest.Mock; + getTrustOptions: jest.Mock; updateSettings: jest.Mock; }; @@ -25,6 +26,10 @@ describe('EpgProgressService', () => { 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), }; }); 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 6901ac080..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,7 +1,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES, - ElectronBridgeTrustOptions, + normalizeHost, } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { @@ -52,7 +52,10 @@ export class EpgProgressService { if (!this.epgBridge.supportsDataManagement) { return; } - void this.epgBridge.forceFetchEpg(url, this.getTrustOptions()); + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); } async trustPrivateNetworkSourceAndRetry(url: string): Promise { @@ -80,10 +83,10 @@ export class EpgProgressService { const settings = this.settingsStore.getSettings(); const trustedHosts = new Set( (settings.trustedInsecureTlsHosts ?? []).map((item) => - this.normalizeHost(item) + normalizeHost(item) ) ); - trustedHosts.add(this.normalizeHost(trustedHost)); + trustedHosts.add(normalizeHost(trustedHost)); await this.settingsStore.updateSettings({ trustedInsecureTlsHosts: Array.from(trustedHosts), @@ -119,15 +122,6 @@ export class EpgProgressService { } } - private getTrustOptions(): ElectronBridgeTrustOptions { - const settings = this.settingsStore.getSettings(); - return { - trustedPrivateNetworkEpgUrls: - settings.trustedPrivateNetworkEpgUrls ?? [], - trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], - }; - } - private isActionableError(progress: EpgImportProgress): boolean { return ( progress.errorCode === @@ -145,13 +139,6 @@ export class EpgProgressService { } } - private normalizeHost(host: string): string { - return host - .trim() - .toLowerCase() - .replace(/^\[(.*)\]$/, '$1'); - } - private removeImport(url: string): void { this.importsMap.update((current) => { const updated = new Map(current); 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 cc730e2fa..136779e92 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -10,7 +10,7 @@ describe('EpgService', () => { let service: EpgService; let epgBridge: Partial; let snackBar: { open: jest.Mock }; - let settingsStore: { getSettings: jest.Mock }; + let settingsStore: { getSettings: jest.Mock; getTrustOptions: jest.Mock }; beforeEach(() => { epgBridge = { @@ -29,6 +29,10 @@ describe('EpgService', () => { 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({ diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 842c078aa..9bc9b84f7 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -5,7 +5,6 @@ import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; import { catchError, map, tap, timeout } from 'rxjs/operators'; import { createDevLogger, - ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; @@ -49,7 +48,12 @@ export class EpgService { const validUrls = urls.filter((url) => url?.trim()); if (validUrls.length === 0) return; - from(this.epgBridge.fetchEpg(validUrls, this.getTrustOptions())) + from( + this.epgBridge.fetchEpg( + validUrls, + this.settingsStore.getTrustOptions() + ) + ) .pipe( tap((result) => { if (result === null) return; @@ -107,15 +111,6 @@ export class EpgService { }); } - private getTrustOptions(): ElectronBridgeTrustOptions { - const settings = this.settingsStore.getSettings(); - return { - trustedPrivateNetworkEpgUrls: - settings.trustedPrivateNetworkEpgUrls ?? [], - trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [], - }; - } - /** * Gets the current EPG program for a specific channel (with caching) * @param channelId Channel ID (tvg-id or channel name) 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 e547554e3..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, @@ -16,9 +17,11 @@ import { } 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'; @@ -87,6 +90,7 @@ describe('PlaylistRefreshActionService', () => { }; let settingsStore: { getSettings: jest.Mock; + getTrustOptions: jest.Mock; updateSettings: jest.Mock; }; let runtime: { @@ -136,7 +140,9 @@ describe('PlaylistRefreshActionService', () => { navigate: jest.fn().mockResolvedValue(true), }; snackBar = { - open: jest.fn(), + open: jest.fn(() => ({ + onAction: () => of(undefined), + })), }; store = { dispatch: jest.fn(), @@ -151,6 +157,10 @@ describe('PlaylistRefreshActionService', () => { getSettings: jest.fn(() => ({ trustedInsecureTlsHosts: ['playlist.local'], })), + getTrustOptions: jest.fn(() => ({ + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: ['playlist.local'], + })), updateSettings: jest.fn().mockResolvedValue(undefined), }; runtime = { @@ -318,6 +328,47 @@ describe('PlaylistRefreshActionService', () => { ); }); + 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 c755d7400..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 @@ -18,6 +18,8 @@ import { import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + normalizeHost, + parseSecurityPolicyError, PLAYLIST_UPDATE, PlaylistMeta, } from '@iptvnator/shared/interfaces'; @@ -31,14 +33,6 @@ export interface XtreamRefreshPreparationState { total?: number; } -interface ParsedSecurityError { - readonly code: string; - readonly host?: string; - readonly message: string; -} - -const SECURITY_ERROR_PREFIX = 'IPTVNATOR_SECURITY_ERROR:'; - @Injectable({ providedIn: 'root' }) export class PlaylistRefreshActionService { private readonly router = inject(Router); @@ -222,8 +216,8 @@ export class PlaylistRefreshActionService { url: item.url, filePath: item.filePath, trustedInsecureTlsHosts: - this.settingsStore.getSettings() - .trustedInsecureTlsHosts ?? [], + this.settingsStore.getTrustOptions() + .trustedInsecureTlsHosts, }); this.store.dispatch( @@ -260,13 +254,12 @@ export class PlaylistRefreshActionService { { duration: 5000 } ); } - + } finally { if (isActiveM3uRoute) { this.store.dispatch( ChannelActions.setChannelsLoading({ loading: false }) ); } - } finally { this.isRefreshing.set(false); } } @@ -289,7 +282,7 @@ export class PlaylistRefreshActionService { error: unknown, retry: () => void ): boolean { - const securityError = this.parseSecurityPolicyError(error); + const securityError = parseSecurityPolicyError(error); if ( securityError?.code !== ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate @@ -319,6 +312,14 @@ export class PlaylistRefreshActionService { 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; } @@ -346,68 +347,21 @@ export class PlaylistRefreshActionService { const settings = this.settingsStore.getSettings(); const trustedHosts = new Set( (settings.trustedInsecureTlsHosts ?? []).map((item) => - this.normalizeHost(item) + normalizeHost(item) ) ); - trustedHosts.add(this.normalizeHost(host)); + trustedHosts.add(normalizeHost(host)); await this.settingsStore.updateSettings({ trustedInsecureTlsHosts: Array.from(trustedHosts), }); } - private parseSecurityPolicyError( - error: unknown - ): ParsedSecurityError | null { - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : undefined; - - if (!message?.startsWith(SECURITY_ERROR_PREFIX)) { - return null; - } - - try { - const parsed = JSON.parse( - message.slice(SECURITY_ERROR_PREFIX.length) - ); - 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; - } - private translateWithFallback(key: string, fallback: string): string { const translated = this.translate.instant(key); return translated === key ? fallback : translated; } - private normalizeHost(host: string): string { - return host - .trim() - .toLowerCase() - .replace(/^\[(.*)\]$/, '$1'); - } - 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 1347386e1..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, @@ -178,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/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'); +}