mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
[codex] Add scoped EPG security trust controls (#1054)
* Add scoped EPG security trust controls * fix: address scoped trust review feedback --------- Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
1 parent
9043116ebd
commit
e8aa7c3a34
56 files changed
+1683
-243
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<EmbeddedMpvSession | null> =>
|
||||
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 }),
|
||||
|
||||
@@ -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<void> {
|
||||
async fetchEpgFromUrl(
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions = {}
|
||||
): Promise<void> {
|
||||
// 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<void> {
|
||||
private startFetch(
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -109,6 +109,7 @@ describe('EpgEvents', () => {
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||
type: 'FETCH_EPG',
|
||||
url: 'https://example.com/guide.xml',
|
||||
options: {},
|
||||
});
|
||||
|
||||
worker.emit('message', {
|
||||
|
||||
@@ -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<void> {
|
||||
return epgWorkerService.fetchEpgFromUrl(url);
|
||||
private static async fetchEpgFromUrl(
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions = {}
|
||||
): Promise<void> {
|
||||
return epgWorkerService.fetchEpgFromUrl(url, options);
|
||||
}
|
||||
|
||||
private static async handleGetChannelPrograms(
|
||||
|
||||
@@ -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<Playlist> {
|
||||
const result = await requestWithValidatedRedirects<string>(
|
||||
url,
|
||||
{
|
||||
agentFactory: createPlaylistAgentFactory(),
|
||||
method: 'GET',
|
||||
},
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
result = await requestWithValidatedRedirects<string>(
|
||||
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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>)[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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
async function fetchAndParseEpgStreaming(
|
||||
url: string,
|
||||
options: ElectronBridgeTrustOptions = {}
|
||||
): Promise<void> {
|
||||
console.log(loggerLabel, `Fetching EPG from ${url}`);
|
||||
|
||||
// Create database connection in worker
|
||||
@@ -92,12 +112,17 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
|
||||
const response = await requestWithValidatedRedirects<Readable>(
|
||||
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<void> {
|
||||
});
|
||||
} 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);
|
||||
|
||||
@@ -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<string>(
|
||||
payload.url!,
|
||||
{
|
||||
agentFactory: createPlaylistAgentFactory(),
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
timeout: 30000,
|
||||
},
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
result = await requestWithValidatedRedirects<string>(
|
||||
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' });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, EventListener>();
|
||||
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<string>([
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<DbOperationEvent | null>(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<SettingsPlaylistDeleteSummary>(
|
||||
() => {
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<EpgRuntimeBridgeService>;
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Map<string, EpgImportProgress>>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<EpgFetchResult | null> {
|
||||
fetchEpg(
|
||||
urls: string[],
|
||||
options?: ElectronBridgeTrustOptions
|
||||
): Promise<EpgFetchResult | null> {
|
||||
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<EpgFetchResult | null> {
|
||||
forceFetchEpg(
|
||||
url: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
): Promise<EpgFetchResult | null> {
|
||||
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<EpgClearResult | null> {
|
||||
|
||||
@@ -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<EpgRuntimeBridgeService>;
|
||||
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 () => {
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
|
||||
@@ -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<string, EpgChannelMetadata | null>(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Playlist>;
|
||||
fetchPlaylistByUrl: (
|
||||
url: string,
|
||||
title?: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<Playlist>;
|
||||
updatePlaylistFromFilePath: (
|
||||
filePath: string,
|
||||
title: string
|
||||
@@ -479,8 +498,14 @@ export interface ElectronBridgeApi {
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) => Promise<ExternalPlayerSession>;
|
||||
autoUpdatePlaylists: (playlists: Playlist[]) => Promise<Playlist[]>;
|
||||
fetchEpg: (urls: string[]) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
autoUpdatePlaylists: (
|
||||
playlists: Playlist[],
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<Playlist[]>;
|
||||
fetchEpg: (
|
||||
urls: string[],
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
|
||||
getCurrentProgramsBatch: (
|
||||
channelIds: string[]
|
||||
@@ -493,7 +518,10 @@ export interface ElectronBridgeApi {
|
||||
skip: number,
|
||||
limit: number
|
||||
) => Promise<ElectronBridgeEpgChannelWithPrograms[]>;
|
||||
forceFetchEpg: (url: string) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
forceFetchEpg: (
|
||||
url: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
clearEpgData: () => Promise<ElectronBridgeResult>;
|
||||
checkEpgFreshness: (
|
||||
urls: string[],
|
||||
|
||||
@@ -25,4 +25,5 @@ export interface PlaylistRefreshPayload {
|
||||
title: string;
|
||||
filePath?: string;
|
||||
url?: string;
|
||||
trustedInsecureTlsHosts?: string[];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -22,9 +22,7 @@
|
||||
mat-icon-button
|
||||
class="minimize-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[attr.aria-label]="
|
||||
minimized() ? 'Expand' : 'Minimize'
|
||||
"
|
||||
[attr.aria-label]="minimized() ? 'Expand' : 'Minimize'"
|
||||
>
|
||||
<mat-icon>
|
||||
{{ minimized() ? 'unfold_more' : 'unfold_less' }}
|
||||
@@ -50,9 +48,7 @@
|
||||
>
|
||||
<div class="item-header">
|
||||
<mat-icon
|
||||
[class.spinning]="
|
||||
item.status === 'loading'
|
||||
"
|
||||
[class.spinning]="item.status === 'loading'"
|
||||
>
|
||||
{{ getStatusIcon(item.status) }}
|
||||
</mat-icon>
|
||||
@@ -60,6 +56,16 @@
|
||||
{{ getDisplayUrl(item.url) }}
|
||||
</span>
|
||||
@if (item.status === 'error') {
|
||||
@if (isActionableSecurityError(item)) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="trust-btn"
|
||||
type="button"
|
||||
(click)="confirmTrust(item)"
|
||||
>
|
||||
{{ getActionLabel(item) }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="retry-btn"
|
||||
@@ -105,13 +111,9 @@
|
||||
{{ item.stats.totalPrograms }}
|
||||
</span>
|
||||
} @else if (item.error) {
|
||||
<span class="error-text">{{
|
||||
item.error
|
||||
}}</span>
|
||||
<span class="error-text">{{ item.error }}</span>
|
||||
} @else if (item.status === 'loading') {
|
||||
<span
|
||||
>{{ 'EPG.LOADING' | translate }}...</span
|
||||
>
|
||||
<span>{{ 'EPG.LOADING' | translate }}...</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,19 +139,14 @@
|
||||
<span class="queue-position">{{
|
||||
i + 1
|
||||
}}</span>
|
||||
<span
|
||||
class="queue-url"
|
||||
[title]="item.url"
|
||||
>
|
||||
<span class="queue-url" [title]="item.url">
|
||||
{{ getDisplayUrl(item.url) }}
|
||||
</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="dismiss-btn"
|
||||
(click)="dismiss(item.url)"
|
||||
[attr.aria-label]="
|
||||
'CLOSE' | translate
|
||||
"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
{{ data.message }}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [mat-dialog-close]="true">
|
||||
{{ data.confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
class EpgTrustConfirmDialogComponent {
|
||||
readonly data = inject<EpgTrustConfirmDialogData>(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, EpgTrustConfirmDialogData>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user