From 32cc0b74e8edc42f3a3a44b3453139b0b1af5eaa Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 16 May 2026 00:10:03 +0200 Subject: [PATCH] feat: add self-hosted PWA backend runtime (#944) * feat(web-backend): add self-hosted proxy app * feat(web): enable runtime PWA configuration * test(web-e2e): cover self-hosted backend proxy * test(web-e2e): clean self-hosted lint warnings * fix(web-backend): validate proxied provider URLs * fix(web-backend): proxy through registered provider targets * fix(web-backend): document CodeQL SSRF validation boundary * test(web-e2e): mock provider target registration * fix(web): address self-hosted review feedback --- apps/web-backend/jest.config.ts | 13 + apps/web-backend/project.json | 49 ++ .../src/app/web-backend-app.spec.ts | 493 +++++++++++++++ apps/web-backend/src/app/web-backend-app.ts | 574 ++++++++++++++++++ apps/web-backend/src/main.ts | 8 + apps/web-backend/src/types/epg-parser.d.ts | 7 + apps/web-backend/tsconfig.app.json | 10 + apps/web-backend/tsconfig.json | 14 + apps/web-backend/tsconfig.spec.json | 15 + apps/web-e2e/playwright.config.ts | 6 + apps/web-e2e/src/provider-target-route.ts | 80 +++ apps/web-e2e/src/self-hosted.e2e.ts | 106 ++++ apps/web-e2e/src/stalker.e2e.ts | 23 +- apps/web-e2e/src/xtream.e2e.ts | 39 +- apps/web/project.json | 7 +- apps/web/src/app/app.config.ts | 3 +- apps/web/src/app/services/pwa.service.ts | 137 +++-- .../src/app/services/runtime-config.spec.ts | 39 ++ apps/web/src/app/services/runtime-config.ts | 27 + apps/web/src/assets/app-config.js | 1 + apps/web/src/index.html | 7 +- docker/README.md | 8 +- global.d.ts | 3 + ngsw-config.json | 57 +- package.json | 1 + pnpm-lock.yaml | 18 + 26 files changed, 1653 insertions(+), 92 deletions(-) create mode 100644 apps/web-backend/jest.config.ts create mode 100644 apps/web-backend/project.json create mode 100644 apps/web-backend/src/app/web-backend-app.spec.ts create mode 100644 apps/web-backend/src/app/web-backend-app.ts create mode 100644 apps/web-backend/src/main.ts create mode 100644 apps/web-backend/src/types/epg-parser.d.ts create mode 100644 apps/web-backend/tsconfig.app.json create mode 100644 apps/web-backend/tsconfig.json create mode 100644 apps/web-backend/tsconfig.spec.json create mode 100644 apps/web-e2e/src/provider-target-route.ts create mode 100644 apps/web-e2e/src/self-hosted.e2e.ts create mode 100644 apps/web/src/app/services/runtime-config.spec.ts create mode 100644 apps/web/src/app/services/runtime-config.ts create mode 100644 apps/web/src/assets/app-config.js diff --git a/apps/web-backend/jest.config.ts b/apps/web-backend/jest.config.ts new file mode 100644 index 000000000..dcbcf8fc3 --- /dev/null +++ b/apps/web-backend/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'web-backend', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/web-backend', +}; diff --git a/apps/web-backend/project.json b/apps/web-backend/project.json new file mode 100644 index 000000000..88fa476fb --- /dev/null +++ b/apps/web-backend/project.json @@ -0,0 +1,49 @@ +{ + "name": "web-backend", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/web-backend/src", + "tags": ["scope:app", "domain:web", "type:app"], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/web-backend", + "main": "apps/web-backend/src/main.ts", + "tsConfig": "apps/web-backend/tsconfig.app.json", + "format": ["cjs"], + "platform": "node", + "target": "node22", + "bundle": true, + "thirdParty": true, + "generatePackageJson": true + } + }, + "serve": { + "continuous": true, + "executor": "nx:run-commands", + "options": { + "command": "pnpm tsx apps/web-backend/src/main.ts", + "cwd": "{workspaceRoot}", + "color": true, + "env": { + "PORT": "3333", + "CLIENT_URL": "http://localhost:4200", + "BACKEND_URL": "/api", + "IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1" + } + } + }, + "lint": { + "command": "eslint apps/web-backend/**/*.ts" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/web-backend/jest.config.ts" + } + } + } +} diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts new file mode 100644 index 000000000..13fdca6b3 --- /dev/null +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -0,0 +1,493 @@ +import { AddressInfo } from 'node:net'; +import { Server } from 'node:http'; +import { + createWebBackendApp, + WebBackendHttpClient, + WebBackendHttpGetOptions, +} from './web-backend-app'; + +interface HttpRequest { + readonly headers?: Record; + readonly params?: Record; + readonly responseData: unknown; + readonly responseStatus?: number; + readonly url: string; +} + +class StubHttpClient implements WebBackendHttpClient { + readonly requests: Omit[] = + []; + private readonly queuedResponses: Array<{ + readonly data: unknown; + readonly error?: Error; + readonly status?: number; + readonly statusText?: string; + }> = []; + + queueResponse(data: unknown): void { + this.queuedResponses.push({ data }); + } + + queueFailure(status: number, statusText = 'Provider failure'): void { + this.queuedResponses.push({ data: null, status, statusText }); + } + + queueNetworkFailure(message = 'connect ECONNREFUSED'): void { + this.queuedResponses.push({ data: null, error: new Error(message) }); + } + + async get( + url: string, + options: WebBackendHttpGetOptions = {} + ): Promise<{ data: T }> { + this.requests.push({ + headers: options.headers, + params: options.params, + url, + }); + + const response = this.queuedResponses.shift(); + if (!response) { + throw new Error(`No queued response for ${url}`); + } + + if (response.error) { + throw response.error; + } + + if (response.status) { + const error = new Error(response.statusText) as Error & { + response: { status: number; statusText: string }; + }; + error.response = { + status: response.status, + statusText: response.statusText ?? 'Provider failure', + }; + throw error; + } + + return { data: response.data as T }; + } +} + +const resolvePublicHost = async () => ['93.184.216.34']; + +async function registerProviderTarget( + baseUrl: string, + url: string +): Promise { + const response = await fetch(`${baseUrl}/provider-targets`, { + body: JSON.stringify({ url }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }); + const body = (await response.json()) as { targetId: string }; + return body.targetId; +} + +async function withServer( + app: ReturnType, + callback: (baseUrl: string) => Promise +): Promise { + const server = await new Promise((resolve) => { + const started = app.listen(0, '127.0.0.1', () => resolve(started)); + }); + + try { + const address = server.address() as AddressInfo; + return await callback(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +describe('web backend app', () => { + it('exposes a health endpoint', async () => { + await withServer(createWebBackendApp(), async (baseUrl) => { + const response = await fetch(`${baseUrl}/health`); + + await expect(response.json()).resolves.toEqual({ + status: 'ok', + service: 'iptvnator-web-backend', + }); + }); + }); + + it('serves runtime config as executable JavaScript', async () => { + await withServer( + createWebBackendApp({ + runtimeBackendUrl: '/api', + }), + async (baseUrl) => { + const response = await fetch(`${baseUrl}/config.js`); + const body = await response.text(); + + expect(response.headers.get('content-type')).toContain( + 'application/javascript' + ); + expect(body).toContain('window.__IPTVNATOR_CONFIG__'); + expect(body).toContain('"BACKEND_URL":"/api"'); + } + ); + }); + + it('parses remote M3U playlists into the PWA playlist shape', async () => { + const httpClient = new StubHttpClient(); + let idCounter = 0; + httpClient.queueResponse(`#EXTM3U +#EXTINF:-1 tvg-id="news" group-title="News",News Channel +https://stream.example/news.m3u8`); + + await withServer( + createWebBackendApp({ + clientOrigins: ['http://localhost:4200'], + guid: () => `fixed-id-${++idCounter}`, + httpClient, + now: () => new Date('2026-05-15T08:00:00.000Z'), + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'https://provider.example/list.m3u' + ); + const response = await fetch( + `${baseUrl}/parse?targetId=${targetId}`, + { headers: { Origin: 'http://localhost:4200' } } + ); + const body = (await response.json()) as { + playlist: { items: Array> }; + }; + + expect(response.status).toBe(200); + expect( + response.headers.get('access-control-allow-origin') + ).toBe('http://localhost:4200'); + expect(body).toMatchObject({ + _id: 'fixed-id-1', + autoRefresh: false, + count: 1, + favorites: [], + filename: 'list.m3u', + id: 'fixed-id-1', + importDate: '2026-05-15T08:00:00.000Z', + lastUsage: '2026-05-15T08:00:00.000Z', + title: 'list.m3u', + url: 'https://provider.example/list.m3u', + }); + expect(body.playlist.items).toHaveLength(1); + expect(body.playlist.items[0]).toMatchObject({ + id: 'fixed-id-2', + name: 'News Channel', + url: 'https://stream.example/news.m3u8', + }); + expect(httpClient.requests).toEqual([ + { + headers: undefined, + params: undefined, + url: 'https://provider.example/list.m3u', + }, + ]); + } + ); + }); + + it('allows browser preflight checks for provider target registration', async () => { + await withServer( + createWebBackendApp({ + clientOrigins: ['http://localhost:4200'], + }), + async (baseUrl) => { + const response = await fetch(`${baseUrl}/provider-targets`, { + headers: { + 'Access-Control-Request-Headers': 'content-type', + 'Access-Control-Request-Method': 'POST', + Origin: 'http://localhost:4200', + }, + method: 'OPTIONS', + }); + + expect(response.status).toBe(200); + expect( + response.headers.get('access-control-allow-origin') + ).toBe('http://localhost:4200'); + } + ); + }); + + it('proxies Xtream requests through the provider player API endpoint', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ user_info: { username: 'demo' } }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://xtream.example' + ); + const response = await fetch( + `${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info` + ); + + await expect(response.json()).resolves.toEqual({ + action: 'get_account_info', + payload: { user_info: { username: 'demo' } }, + }); + expect(httpClient.requests).toEqual([ + { + headers: undefined, + params: { + action: 'get_account_info', + password: 'secret', + username: 'demo', + }, + url: 'http://xtream.example/player_api.php', + }, + ]); + } + ); + }); + + it('proxies Stalker requests with MAC cookie and bearer token', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ js: [{ id: '2001', title: 'Action' }] }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php' + ); + const response = await fetch( + `${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod` + ); + + await expect(response.json()).resolves.toEqual({ + action: 'get_categories', + payload: { js: [{ id: '2001', title: 'Action' }] }, + }); + expect(httpClient.requests).toEqual([ + { + headers: { + Authorization: 'Bearer abc123', + Cookie: 'mac=00:1A:79:00:00:01', + }, + params: { + action: 'get_categories', + macAddress: '00:1A:79:00:00:01', + token: 'abc123', + type: 'vod', + }, + url: 'http://stalker.example/portal.php', + }, + ]); + } + ); + }); + + it('normalizes provider errors for portal proxy calls', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueFailure(403, 'Forbidden'); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://xtream.example' + ); + const response = await fetch( + `${baseUrl}/xtream?targetId=${targetId}&action=get_account_info` + ); + + await expect(response.json()).resolves.toEqual({ + message: 'Forbidden', + status: 403, + }); + } + ); + }); + + it('normalizes non-HTTP upstream failures as bad gateway', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueNetworkFailure(); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://xtream.example' + ); + const response = await fetch( + `${baseUrl}/xtream?targetId=${targetId}&action=get_account_info` + ); + + await expect(response.json()).resolves.toEqual({ + message: 'Bad Gateway', + status: 502, + }); + } + ); + }); + + it('returns provider parse errors as JSON instead of executable text', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueFailure(502, ''); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'https://provider.example/list.m3u' + ); + const response = await fetch( + `${baseUrl}/parse?targetId=${targetId}` + ); + + expect(response.status).toBe(502); + expect(response.headers.get('content-type')).toContain( + 'application/json' + ); + await expect(response.json()).resolves.toEqual({ + message: '', + status: 502, + }); + } + ); + }); + + it('rejects unsupported target URL schemes before proxying', async () => { + const httpClient = new StubHttpClient(); + + await withServer( + createWebBackendApp({ httpClient }), + async (baseUrl) => { + const response = await fetch(`${baseUrl}/provider-targets`, { + body: JSON.stringify({ url: 'file:///etc/passwd' }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + message: 'Only http and https provider URLs are supported', + status: 400, + }); + expect(httpClient.requests).toEqual([]); + } + ); + }); + + it('rejects loopback target URLs by default', async () => { + const httpClient = new StubHttpClient(); + + await withServer( + createWebBackendApp({ httpClient }), + async (baseUrl) => { + const response = await fetch(`${baseUrl}/provider-targets`, { + body: JSON.stringify({ + url: 'http://127.0.0.1:3211', + }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + message: + 'Provider URL points to a private or local network address', + status: 400, + }); + expect(httpClient.requests).toEqual([]); + } + ); + }); + + it('allows private target URLs when explicitly enabled for local self-hosted testing', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ user_info: { username: 'demo' } }); + + await withServer( + createWebBackendApp({ + allowPrivateNetworkTargets: true, + httpClient, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://127.0.0.1:3211' + ); + const response = await fetch( + `${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info` + ); + + await expect(response.json()).resolves.toEqual({ + action: 'get_account_info', + payload: { user_info: { username: 'demo' } }, + }); + expect(httpClient.requests[0]?.url).toBe( + 'http://127.0.0.1:3211/player_api.php' + ); + } + ); + }); + + it('requires portal proxy callers to use registered provider targets', async () => { + const httpClient = new StubHttpClient(); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const missingTargetResponse = await fetch( + `${baseUrl}/xtream?action=get_account_info` + ); + const unknownTargetResponse = await fetch( + `${baseUrl}/xtream?targetId=missing&action=get_account_info` + ); + + expect(missingTargetResponse.status).toBe(400); + await expect(missingTargetResponse.json()).resolves.toEqual({ + message: 'Missing targetId', + status: 400, + }); + + expect(unknownTargetResponse.status).toBe(404); + await expect(unknownTargetResponse.json()).resolves.toEqual({ + message: 'Provider target not found', + status: 404, + }); + expect(httpClient.requests).toEqual([]); + } + ); + }); +}); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts new file mode 100644 index 000000000..8ae5d854f --- /dev/null +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -0,0 +1,574 @@ +import cors from 'cors'; +import express, { Express, Request, Response } from 'express'; +import { createHash } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import zlib from 'node:zlib'; +import axios from 'axios'; +import epgParser from 'epg-parser'; +import parser from 'iptv-playlist-parser'; + +export interface WebBackendHttpGetOptions { + readonly headers?: Record; + readonly params?: Record; + readonly responseType?: 'arraybuffer'; +} + +export interface WebBackendHttpClient { + get( + url: string, + options?: WebBackendHttpGetOptions + ): Promise<{ data: T }>; +} + +interface ProviderError extends Error { + readonly response?: { + readonly status?: number; + readonly statusText?: string; + }; +} + +interface PlaylistParseError { + readonly message: string; + readonly status: number; +} + +export interface WebBackendAppOptions { + readonly allowPrivateNetworkTargets?: boolean; + readonly clientOrigins?: string[]; + readonly guid?: () => string; + readonly httpClient?: WebBackendHttpClient; + readonly now?: () => Date; + readonly resolveHostname?: (hostname: string) => Promise; + readonly runtimeBackendUrl?: string; +} + +interface ProviderUrlPolicy { + readonly allowPrivateNetworkTargets: boolean; + readonly resolveHostname: (hostname: string) => Promise; +} + +interface ProviderUrlError { + readonly message: string; + readonly status: number; +} + +type ProviderTargetRegistry = Map; + +export function createWebBackendApp( + options: WebBackendAppOptions = {} +): Express { + const app = express(); + const httpClient = (options.httpClient ?? axios) as WebBackendHttpClient; + const guid = options.guid ?? createGuid; + const now = options.now ?? (() => new Date()); + const clientOrigins = options.clientOrigins ?? getClientOrigins(); + const runtimeBackendUrl = + options.runtimeBackendUrl ?? process.env['BACKEND_URL'] ?? '/api'; + const providerUrlPolicy: ProviderUrlPolicy = { + allowPrivateNetworkTargets: + options.allowPrivateNetworkTargets ?? + isPrivateNetworkProxyAllowed(), + resolveHostname: options.resolveHostname ?? resolveHostname, + }; + const providerTargets: ProviderTargetRegistry = new Map(); + + const corsMiddleware = cors({ + origin(origin, callback) { + if ( + !origin || + clientOrigins.includes('*') || + clientOrigins.includes(origin) + ) { + callback(null, true); + return; + } + callback(null, false); + }, + optionsSuccessStatus: 200, + }); + + app.get('/', (_req, res) => res.send('IPTVnator web backend')); + app.get('/health', (_req, res) => + res.json({ status: 'ok', service: 'iptvnator-web-backend' }) + ); + + app.get('/config.js', corsMiddleware, (_req, res) => { + const config = JSON.stringify({ BACKEND_URL: runtimeBackendUrl }); + res.type('application/javascript').send( + `window.__IPTVNATOR_CONFIG__ = Object.assign({}, window.__IPTVNATOR_CONFIG__, ${config});\n` + ); + }); + + app.options('/provider-targets', corsMiddleware); + app.post( + '/provider-targets', + corsMiddleware, + express.json({ limit: '16kb' }), + async (req, res) => { + const rawUrl = + req.body && + typeof req.body === 'object' && + 'url' in req.body && + typeof req.body.url === 'string' + ? req.body.url + : undefined; + + if (!rawUrl) { + res.status(400).json({ message: 'Missing url', status: 400 }); + return; + } + + const result = await validateProviderUrl(rawUrl, providerUrlPolicy); + if ('message' in result) { + res.status(result.status).json(result); + return; + } + + const targetId = createProviderTargetId(result); + providerTargets.set(targetId, result); + res.json({ targetId }); + } + ); + + app.get('/parse', corsMiddleware, async (req, res) => { + const url = getRegisteredProviderUrl(req, res, providerTargets); + if (!url) { + return; + } + + const result = await handlePlaylistParse({ + guid, + httpClient, + now, + url: url.href, + }); + + if (isPlaylistParseError(result)) { + res.status(result.status).json(result); + return; + } + + res.json(result); + }); + + app.get('/parse-xml', corsMiddleware, async (req, res) => { + const url = getRegisteredProviderUrl(req, res, providerTargets); + if (!url) { + return; + } + + try { + const result = await fetchEpgDataFromUrl(httpClient, url); + if (!result) { + res.status(500).json({ + message: 'Error, something went wrong', + status: 500, + }); + return; + } + + res.json(result); + } catch (error) { + const providerError = normalizeProviderError(error); + res.status(providerError.status).json(providerError); + } + }); + + app.get('/xtream', corsMiddleware, async (req, res) => { + const url = getRegisteredProviderUrl(req, res, providerTargets); + if (!url) { + return; + } + + try { + // Provider URLs are validated by /provider-targets before they enter the registry. + // codeql[js/request-forgery] + const response = await httpClient.get( + appendPathSegment(url, 'player_api.php'), + { + params: getProxyParams(req, ['targetId']), + } + ); + + res.json({ + action: getQueryString(req, 'action'), + payload: response.data, + }); + } catch (error) { + res.json(normalizeProviderError(error)); + } + }); + + app.get('/stalker', corsMiddleware, async (req, res) => { + const url = getRegisteredProviderUrl(req, res, providerTargets); + const macAddress = getQueryString(req, 'macAddress'); + const token = getQueryString(req, 'token'); + if (!url) { + return; + } + + try { + // Provider URLs are validated by /provider-targets before they enter the registry. + // codeql[js/request-forgery] + const response = await httpClient.get(url.href, { + params: getProxyParams(req, ['targetId']), + headers: { + ...(macAddress ? { Cookie: `mac=${macAddress}` } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + res.json({ + action: getQueryString(req, 'action'), + payload: response.data, + }); + } catch (error) { + res.json(normalizeProviderError(error)); + } + }); + + return app; +} + +function getRegisteredProviderUrl( + req: Request, + res: Response, + providerTargets: ProviderTargetRegistry +): URL | null { + const targetId = getQueryString(req, 'targetId'); + if (!targetId) { + res.status(400).json({ message: 'Missing targetId', status: 400 }); + return null; + } + + const targetUrl = providerTargets.get(targetId); + if (!targetUrl) { + res.status(404).json({ + message: 'Provider target not found', + status: 404, + }); + return null; + } + + return targetUrl; +} + +async function validateProviderUrl( + rawUrl: string, + policy: ProviderUrlPolicy +): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { message: 'Provider URL is not a valid URL', status: 400 }; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { + message: 'Only http and https provider URLs are supported', + status: 400, + }; + } + + if (url.username || url.password) { + return { + message: 'Provider URL credentials are not supported', + status: 400, + }; + } + + if (policy.allowPrivateNetworkTargets) { + return url; + } + + const hostname = normalizeHostname(url.hostname); + if (isLocalHostname(hostname) || isPrivateOrReservedIp(hostname)) { + return { + message: + 'Provider URL points to a private or local network address', + status: 400, + }; + } + + if (isIP(hostname) === 0) { + let addresses: readonly string[]; + try { + addresses = await policy.resolveHostname(hostname); + } catch { + return { + message: 'Provider URL host could not be resolved', + status: 400, + }; + } + + if ( + addresses.length === 0 || + addresses.some((address) => + isPrivateOrReservedIp(normalizeHostname(address)) + ) + ) { + return { + message: + 'Provider URL points to a private or local network address', + status: 400, + }; + } + } + + return url; +} + +async function resolveHostname(hostname: string): Promise { + const records = await lookup(hostname, { all: true, verbatim: true }); + return records.map((record) => record.address); +} + +function createProviderTargetId(url: URL): string { + return createHash('sha256').update(url.href).digest('hex'); +} + +function isPrivateNetworkProxyAllowed(): boolean { + const value = process.env['IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS']; + return value === '1' || value === 'true'; +} + +function getClientOrigins(): string[] { + const configured = process.env['CLIENT_URL'] + ?.split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + + if (configured?.length) { + return configured; + } + + return process.env['NODE_ENV'] === 'development' || + process.env['NODE_ENV'] === 'dev' + ? ['http://localhost:4200'] + : ['https://iptvnator.vercel.app']; +} + +function getQueryString(req: Request, key: string): string | undefined { + const value = req.query[key]; + if (Array.isArray(value)) { + return normalizeQueryValue(value[0]); + } + return normalizeQueryValue(value); +} + +function normalizeQueryValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function getProxyParams( + req: Request, + excludedKeys: string[] +): Record { + const excluded = new Set(excludedKeys); + const params: Record = {}; + for (const [key, value] of Object.entries(req.query)) { + if (excluded.has(key)) { + continue; + } + const normalized = Array.isArray(value) + ? normalizeQueryValue(value[0]) + : normalizeQueryValue(value); + if (normalized) { + params[key] = normalized; + } + } + return params; +} + +function appendPathSegment(url: URL, segment: string): string { + const nextUrl = new URL(url.href); + nextUrl.pathname = `${nextUrl.pathname.replace(/\/+$/, '')}/${segment}`; + nextUrl.search = ''; + nextUrl.hash = ''; + return nextUrl.href; +} + +async function handlePlaylistParse(options: { + readonly guid: () => string; + readonly httpClient: WebBackendHttpClient; + readonly now: () => Date; + readonly url: string; +}): Promise | PlaylistParseError> { + try { + // Provider URLs are validated by /provider-targets before playlist parsing. + // codeql[js/request-forgery] + const response = await options.httpClient.get(options.url); + const parsedPlaylist = parsePlaylist(response.data); + const title = getLastUrlSegment(options.url); + return createPlaylistObject({ + guid: options.guid, + now: options.now, + playlist: parsedPlaylist, + title, + url: options.url, + }); + } catch (error) { + const providerError = error as ProviderError; + return { + status: providerError.response?.status ?? 500, + message: + providerError.response?.statusText ?? + 'Error, something went wrong', + }; + } +} + +async function fetchEpgDataFromUrl( + httpClient: WebBackendHttpClient, + url: URL +): Promise { + const href = url.href; + // Provider URLs are validated by /provider-targets before XMLTV parsing. + // codeql[js/request-forgery] + const response = await httpClient.get(href, { + ...(url.pathname.endsWith('.gz') + ? { responseType: 'arraybuffer' } + : {}), + }); + const xml = url.pathname.endsWith('.gz') + ? zlib.gunzipSync(Buffer.from(response.data as ArrayBuffer)).toString() + : response.data.toString(); + return epgParser.parse(xml); +} + +function isPlaylistParseError( + result: Record | PlaylistParseError +): result is PlaylistParseError { + return ( + typeof (result as PlaylistParseError).status === 'number' && + typeof (result as PlaylistParseError).message === 'string' + ); +} + +function parsePlaylist(playlist: string): { + items: Array>; +} { + return parser.parse(playlist) as unknown as { + items: Array>; + }; +} + +function createPlaylistObject(options: { + readonly guid: () => string; + readonly now: () => Date; + readonly playlist: { items: Array> }; + readonly title: string; + readonly url: string; +}): Record { + const timestamp = options.now().toISOString(); + const id = options.guid(); + return { + id, + _id: id, + filename: options.title, + title: options.title, + count: options.playlist.items.length, + playlist: { + ...options.playlist, + items: options.playlist.items.map((item) => ({ + id: options.guid(), + ...item, + })), + }, + importDate: timestamp, + lastUsage: timestamp, + favorites: [], + autoRefresh: false, + url: options.url, + }; +} + +function getLastUrlSegment(value: string): string { + const segment = value.slice(value.lastIndexOf('/') + 1).trim(); + return segment.length > 0 ? segment : 'Playlist without title'; +} + +function normalizeProviderError(error: unknown): { + readonly message: string; + readonly status: number; +} { + const providerError = error as ProviderError; + return { + message: providerError.response?.statusText ?? 'Bad Gateway', + status: providerError.response?.status ?? 502, + }; +} + +function createGuid(): string { + return Math.random().toString(36).slice(2); +} + +function normalizeHostname(hostname: string): string { + return hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); +} + +function isLocalHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname.endsWith('.localhost'); +} + +function isPrivateOrReservedIp(address: string): boolean { + const version = isIP(address); + if (version === 4) { + return isPrivateOrReservedIpv4(address); + } + + if (version === 6) { + return isPrivateOrReservedIpv6(address); + } + + return false; +} + +function isPrivateOrReservedIpv4(address: string): boolean { + const parts = address.split('.').map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return true; + } + + const [first, second, third] = parts; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 192 && second === 0) || + (first === 192 && second === 0 && third === 2) || + (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224 + ); +} + +function isPrivateOrReservedIpv6(address: string): boolean { + const normalized = address.toLowerCase(); + if ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe80:') + ) { + return true; + } + + const mappedIpv4 = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + return mappedIpv4 ? isPrivateOrReservedIpv4(mappedIpv4) : false; +} diff --git a/apps/web-backend/src/main.ts b/apps/web-backend/src/main.ts new file mode 100644 index 000000000..3fcb96289 --- /dev/null +++ b/apps/web-backend/src/main.ts @@ -0,0 +1,8 @@ +import { createWebBackendApp } from './app/web-backend-app'; + +const port = Number(process.env['PORT'] ?? 3000); +const app = createWebBackendApp(); + +app.listen(port, () => { + console.log(`IPTVnator web backend listening on http://localhost:${port}`); +}); diff --git a/apps/web-backend/src/types/epg-parser.d.ts b/apps/web-backend/src/types/epg-parser.d.ts new file mode 100644 index 000000000..6307817b3 --- /dev/null +++ b/apps/web-backend/src/types/epg-parser.d.ts @@ -0,0 +1,7 @@ +declare module 'epg-parser' { + const parser: { + parse(xml: string): unknown; + }; + + export default parser; +} diff --git a/apps/web-backend/tsconfig.app.json b/apps/web-backend/tsconfig.app.json new file mode 100644 index 000000000..bc2cf401b --- /dev/null +++ b/apps/web-backend/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/apps/web-backend/tsconfig.json b/apps/web-backend/tsconfig.json new file mode 100644 index 000000000..357916b0d --- /dev/null +++ b/apps/web-backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "moduleResolution": "nodenext", + "module": "NodeNext", + "target": "ES2022", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web-backend/tsconfig.spec.json b/apps/web-backend/tsconfig.spec.json new file mode 100644 index 000000000..09849f85f --- /dev/null +++ b/apps/web-backend/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index c1045404c..bcf94dc43 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -46,6 +46,12 @@ export default defineConfig({ reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, + { + command: 'pnpm nx run web-backend:serve', + url: 'http://localhost:3333/health', + reuseExistingServer: !process.env['CI'], + cwd: workspaceRoot, + }, ], projects: [ { diff --git a/apps/web-e2e/src/provider-target-route.ts b/apps/web-e2e/src/provider-target-route.ts new file mode 100644 index 000000000..50ebd11ef --- /dev/null +++ b/apps/web-e2e/src/provider-target-route.ts @@ -0,0 +1,80 @@ +import { type Page } from '@playwright/test'; + +interface ProviderTargetPayload { + readonly url: string; +} + +const BACKEND_ORIGIN = 'http://localhost:3000'; +const CORS_HEADERS = { + 'access-control-allow-headers': 'content-type', + 'access-control-allow-methods': 'POST, OPTIONS', + 'access-control-allow-origin': 'http://localhost:4200', +} as const; + +export async function interceptProviderTargetRegistration( + page: Page +): Promise> { + const providerTargets = new Map(); + + await page.route(`${BACKEND_ORIGIN}/provider-targets**`, async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + body: '', + headers: CORS_HEADERS, + status: 200, + }); + return; + } + + const providerUrl = getProviderUrl(route.request().postDataJSON()); + if (!providerUrl) { + await route.fulfill({ + body: JSON.stringify({ + message: 'Missing url', + status: 400, + }), + contentType: 'application/json', + headers: CORS_HEADERS, + status: 400, + }); + return; + } + + const targetId = Buffer.from(providerUrl).toString('base64url'); + providerTargets.set(targetId, providerUrl); + + await route.fulfill({ + body: JSON.stringify({ targetId }), + contentType: 'application/json', + headers: CORS_HEADERS, + status: 200, + }); + }); + + return providerTargets; +} + +export function getRegisteredProviderUrl( + url: URL, + providerTargets: ReadonlyMap +): string | null { + const targetId = url.searchParams.get('targetId'); + if (!targetId) { + return url.searchParams.get('url'); + } + + return providerTargets.get(targetId) ?? null; +} + +function getProviderUrl(value: unknown): string | null { + if ( + value && + typeof value === 'object' && + 'url' in value && + typeof (value as Partial).url === 'string' + ) { + return (value as ProviderTargetPayload).url; + } + + return null; +} diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts new file mode 100644 index 000000000..0529f4baa --- /dev/null +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -0,0 +1,106 @@ +import { expect, type Locator, type Page, test } from '@playwright/test'; + +const WEB_BACKEND_URL = 'http://localhost:3333'; +const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211'; +const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; +const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; +const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`; +const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`; +const DEFAULT_MAC = '00:1A:79:00:00:01'; + +async function installRuntimeConfig(page: Page): Promise { + await page.route('**/assets/app-config.js', async (route) => { + await route.fulfill({ + contentType: 'application/javascript', + body: `window.__IPTVNATOR_CONFIG__ = { BACKEND_URL: ${JSON.stringify(WEB_BACKEND_URL)} };\n`, + }); + }); +} + +async function setInputValue(input: Locator, value: string): Promise { + await input.fill(''); + await input.fill(value); + + if ((await input.inputValue()) === value) { + return; + } + + await input.click(); + await input.press('ControlOrMeta+A'); + await input.press('Backspace'); + await input.type(value); + await expect(input).toHaveValue(value); +} + +async function addXtreamPortal(page: Page): Promise { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click(); + + await dialog.locator('#title').fill('Self-hosted Xtream'); + await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER); + await dialog.locator('#username').fill('user1'); + await dialog.locator('#password').fill('pass1'); + + await dialog.getByRole('button', { name: 'Add', exact: true }).click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/xtreams.*vod/); +} + +async function addStalkerPortal(page: Page): Promise { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click(); + + await setInputValue(dialog.locator('input#title'), 'Self-hosted Stalker'); + await setInputValue(dialog.locator('input#portalUrl'), STALKER_PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), DEFAULT_MAC); + + const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/stalker.*vod/); +} + +test.beforeEach(async ({ page, request }) => { + await request.post(`${XTREAM_MOCK_SERVER}/reset`); + await request.post(`${STALKER_MOCK_SERVER}/reset`); + await installRuntimeConfig(page); + await page.goto('/'); +}); + +test('@self-hosted runtime config points PWA calls at the monorepo backend', async ({ + page, + request, +}) => { + await expect + .poll(() => + page.evaluate(() => window.__IPTVNATOR_CONFIG__?.BACKEND_URL) + ) + .toBe(WEB_BACKEND_URL); + + const response = await request.get(`${WEB_BACKEND_URL}/health`); + expect(response.ok()).toBeTruthy(); + await expect(response).toBeOK(); +}); + +test('@self-hosted Xtream portal loads through web-backend proxy', async ({ + page, +}) => { + await addXtreamPortal(page); + + const categoryItems = page.locator('.category-item'); + await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); +}); + +test('@self-hosted Stalker portal loads through web-backend proxy', async ({ + page, +}) => { + await addStalkerPortal(page); + + const categoryItems = page.locator('.category-item'); + await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); +}); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 799b4cbe8..aa8e8bc5e 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -5,6 +5,10 @@ import { type Page, test, } from '@playwright/test'; +import { + getRegisteredProviderUrl, + interceptProviderTargetRegistration, +} from './provider-target-route'; /** * Stalker Portal E2E Tests @@ -42,11 +46,25 @@ const MINIMAL_MAC = '00:1A:79:00:00:03'; * any app environment configuration. */ async function interceptStalkerRequests(page: Page): Promise { + const providerTargets = await interceptProviderTargetRegistration(page); + await page.route('**/localhost:3000/stalker**', async (route) => { const originalUrl = new URL(route.request().url()); const mockUrl = new URL(BACKEND_PROXY); - // Forward all query params unchanged + const providerUrl = getRegisteredProviderUrl( + originalUrl, + providerTargets + ); + + if (providerUrl) { + mockUrl.searchParams.set('url', providerUrl); + } + originalUrl.searchParams.forEach((value, key) => { + if (key === 'targetId') { + return; + } + mockUrl.searchParams.set(key, value); }); await route.continue({ url: mockUrl.toString() }); @@ -183,9 +201,8 @@ test('@stalker VOD — content list loads after selecting a category', async ({ const contentItems = page.locator( '.content-card, [data-test-id="channel-item"], mat-card' ); + await expect(contentItems).not.toHaveCount(0, { timeout: 10_000 }); await expect(contentItems.first()).toBeVisible({ timeout: 10_000 }); - const itemCount = await contentItems.count(); - expect(itemCount).toBeGreaterThan(0); }); test('@stalker minimal scenario — correct item counts', async ({ page }) => { diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 71df92d8c..f1c54b529 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -1,4 +1,8 @@ import { APIRequestContext, test, expect, Page } from '@playwright/test'; +import { + getRegisteredProviderUrl, + interceptProviderTargetRegistration, +} from './provider-target-route'; /** * Xtream Codes E2E Tests @@ -33,10 +37,25 @@ const EPG_PASSWORD = 'epg'; * to the mock server. This avoids any real backend requirement. */ async function interceptXtreamRequests(page: Page): Promise { + const providerTargets = await interceptProviderTargetRegistration(page); + await page.route('**/localhost:3000/xtream**', async (route) => { const originalUrl = new URL(route.request().url()); const mockUrl = new URL(`${MOCK_SERVER}/xtream`); + const providerUrl = getRegisteredProviderUrl( + originalUrl, + providerTargets + ); + + if (providerUrl) { + mockUrl.searchParams.set('url', providerUrl); + } + originalUrl.searchParams.forEach((value, key) => { + if (key === 'targetId') { + return; + } + mockUrl.searchParams.set(key, value); }); await route.continue({ url: mockUrl.toString() }); @@ -289,21 +308,19 @@ test('@xtream epg fixture — short epg starts at the current program, respects expect(decodeXtreamText(nextListing.title)).toBe('Market Wrap'); const now = Math.floor(Date.now() / 1000); - expect(Number.parseInt(currentListing.start_timestamp, 10)).toBeLessThanOrEqual( - now - ); - expect(Number.parseInt(currentListing.stop_timestamp, 10)).toBeGreaterThanOrEqual( - now - ); + expect( + Number.parseInt(currentListing.start_timestamp, 10) + ).toBeLessThanOrEqual(now); + expect( + Number.parseInt(currentListing.stop_timestamp, 10) + ).toBeGreaterThanOrEqual(now); expect(currentListing.start).not.toBe( formatXtreamDateTime( Number.parseInt(currentListing.start_timestamp, 10) ) ); expect(currentListing.end).not.toBe( - formatXtreamDateTime( - Number.parseInt(currentListing.stop_timestamp, 10) - ) + formatXtreamDateTime(Number.parseInt(currentListing.stop_timestamp, 10)) ); }); @@ -344,7 +361,9 @@ test('@xtream epg fixture — full epg and legacy alias return the same ordered decodeXtreamText(listing.title) === 'Late Edition' ); if (!boundaryListing) { - throw new Error('Expected the full EPG fixture to include Late Edition.'); + throw new Error( + 'Expected the full EPG fixture to include Late Edition.' + ); } const boundaryStart = new Date( diff --git a/apps/web/project.json b/apps/web/project.json index 6e4871dcd..548533c67 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -22,6 +22,7 @@ "polyfills": ["zone.js"], "tsConfig": "apps/web/tsconfig.app.json", "inlineStyleLanguage": "scss", + "serviceWorker": "ngsw-config.json", "assets": [ "apps/web/src/favicon.ico", "apps/web/src/assets", @@ -99,12 +100,14 @@ ] }, "development": { + "serviceWorker": false, "optimization": false, "extractLicenses": false, "sourceMap": true }, "electron-e2e": { "baseHref": "./", + "serviceWorker": false, "optimization": false, "extractLicenses": false, "sourceMap": true @@ -166,9 +169,9 @@ "continuous": true, "executor": "@nx/web:file-server", "options": { - "buildTarget": "web:build", + "buildTarget": "web:build:pwa", "port": 4200, - "staticFilePath": "dist/apps/web/browser", + "staticFilePath": "dist/apps/web", "spa": true } } diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index eafb9250f..70bb84b3f 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -40,6 +40,7 @@ import { } from './services/portal-navigation-actions.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; +import { shouldEnableServiceWorker } from './services/runtime-config'; import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service'; // AoT requires an exported function for factories @@ -113,7 +114,7 @@ export const appConfig: ApplicationConfig = { provideRouterStore(), ...(AppConfig.production ? [] : [provideStoreDevtools({ maxAge: 25 })]), provideServiceWorker('ngsw-worker.js', { - enabled: AppConfig.production && !!window.electron, + enabled: shouldEnableServiceWorker(), registrationStrategy: 'registerWhenStable:30000', }), importProvidersFrom( diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index e231cdba6..25e2af2d5 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -5,7 +5,7 @@ import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { catchError, firstValueFrom, throwError } from 'rxjs'; +import { catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs'; import { DataService } from '@iptvnator/services'; import { ERROR, @@ -25,6 +25,7 @@ import { logPortalDebugEvent, logPortalDebugRequest, } from '@iptvnator/portal/shared/util'; +import { getRuntimeBackendUrl } from './runtime-config'; interface PwaXtreamResponse { readonly payload?: unknown; @@ -48,6 +49,10 @@ interface ErrorStatus { readonly status?: number; } +interface ProviderTargetRegistration { + readonly targetId: string; +} + @Injectable({ providedIn: 'root', }) @@ -58,6 +63,7 @@ export class PwaService extends DataService { private readonly store = inject(Store); private readonly swUpdate = inject(SwUpdate); private readonly translateService = inject(TranslateService); + private readonly providerTargetIds = new Map>(); private readonly silentXtreamActions = new Set([ XtreamCodeActions.GetAccountInfo, XtreamCodeActions.GetLiveCategories, @@ -69,7 +75,7 @@ export class PwaService extends DataService { ]); /** Proxy URL to avoid CORS issues */ - corsProxyUrl = AppConfig.BACKEND_URL; + corsProxyUrl = getRuntimeBackendUrl(); constructor() { super(); @@ -123,6 +129,7 @@ export class PwaService extends DataService { url: string; macAddress: string; params: Record; + token?: string; } ) as T; } @@ -269,39 +276,49 @@ export class PwaService extends DataService { }, } : {}; - const requestPayload = { - method: 'GET', - url: `${this.corsProxyUrl}/xtream`, - params: { - url: payload.url, - ...payload.params, - }, - ...(payload.macAddress - ? { - headers: { - Cookie: `mac=${payload.macAddress}`, - }, - } - : {}), - }; - const context = createPortalDebugRequestContext({ + let context = createPortalDebugRequestContext({ provider: 'xtream', operation: payload.params?.action ?? 'unknown', transport: 'pwa-http', - request: requestPayload, + request: { + method: 'GET', + params: payload.params, + url: `${this.corsProxyUrl}/xtream`, + }, }); - logPortalDebugRequest(context); try { + const targetId = await this.getProviderTargetId(payload.url); + const requestParams = { + targetId, + ...payload.params, + }; + const requestPayload = { + method: 'GET', + params: requestParams, + url: `${this.corsProxyUrl}/xtream`, + ...(payload.macAddress + ? { + headers: { + Cookie: `mac=${payload.macAddress}`, + }, + } + : {}), + }; + context = createPortalDebugRequestContext({ + provider: 'xtream', + operation: payload.params?.action ?? 'unknown', + transport: 'pwa-http', + request: requestPayload, + }); + logPortalDebugRequest(context); + let result: PwaErrorResult | PwaXtreamResult; const response = (await firstValueFrom( this.http.get( `${this.corsProxyUrl}/xtream`, { - params: { - url: payload.url, - ...payload.params, - }, + params: requestParams, ...headers, } ) @@ -440,30 +457,42 @@ export class PwaService extends DataService { url: string; params: Record; macAddress: string; + token?: string; }) { - const params = new URLSearchParams({ - url: payload.url, - ...payload.params, - macAddress: payload.macAddress, - }); - const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`; - const context = createPortalDebugRequestContext({ + let context = createPortalDebugRequestContext({ provider: 'stalker', operation: payload.params?.action ?? 'unknown', transport: 'pwa-http', request: { method: 'GET', - url: requestUrl, - params: { - url: payload.url, - ...payload.params, - macAddress: payload.macAddress, - }, + params: payload.params, + url: `${this.corsProxyUrl}/stalker`, }, }); - logPortalDebugRequest(context); try { + const targetId = await this.getProviderTargetId(payload.url); + const token = payload.token ?? payload.params.token; + const requestParams = { + targetId, + ...payload.params, + macAddress: payload.macAddress, + ...(token ? { token } : {}), + }; + const params = new URLSearchParams(requestParams); + const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`; + context = createPortalDebugRequestContext({ + provider: 'stalker', + operation: payload.params?.action ?? 'unknown', + transport: 'pwa-http', + request: { + method: 'GET', + params: requestParams, + url: requestUrl, + }, + }); + logPortalDebugRequest(context); + // Make the fetch request const response = await fetch(requestUrl); @@ -496,9 +525,35 @@ export class PwaService extends DataService { } getPlaylistFromUrl(url: string) { - return this.http.get(`${this.corsProxyUrl}/parse`, { - params: { url }, - }); + return from(this.getProviderTargetId(url)).pipe( + switchMap((targetId) => + this.http.get(`${this.corsProxyUrl}/parse`, { + params: { targetId }, + }) + ) + ); + } + + private getProviderTargetId(url: string): Promise { + const cachedTargetId = this.providerTargetIds.get(url); + if (cachedTargetId) { + return cachedTargetId; + } + + const targetIdRequest = firstValueFrom( + this.http.post( + `${this.corsProxyUrl}/provider-targets`, + { url } + ) + ) + .then((response) => response.targetId) + .catch((error) => { + this.providerTargetIds.delete(url); + throw error; + }); + + this.providerTargetIds.set(url, targetIdRequest); + return targetIdRequest; } removeAllListeners(type: string): void { diff --git a/apps/web/src/app/services/runtime-config.spec.ts b/apps/web/src/app/services/runtime-config.spec.ts new file mode 100644 index 000000000..3efffcf35 --- /dev/null +++ b/apps/web/src/app/services/runtime-config.spec.ts @@ -0,0 +1,39 @@ +import { resolveBackendUrl, shouldEnableServiceWorker } from './runtime-config'; + +describe('runtime config helpers', () => { + it('uses runtime BACKEND_URL when provided', () => { + expect( + resolveBackendUrl( + { BACKEND_URL: ' http://self-hosted.local/api ' }, + 'https://fallback.example' + ) + ).toBe('http://self-hosted.local/api'); + }); + + it('falls back to the build-time backend URL when runtime config is missing or blank', () => { + expect(resolveBackendUrl(undefined, 'https://fallback.example')).toBe( + 'https://fallback.example' + ); + expect(resolveBackendUrl({}, 'https://fallback.example')).toBe( + 'https://fallback.example' + ); + expect( + resolveBackendUrl( + { BACKEND_URL: ' ' }, + 'https://fallback.example' + ) + ).toBe('https://fallback.example'); + }); + + it('enables service worker only for production builds with browser support', () => { + expect( + shouldEnableServiceWorker(true, { + serviceWorker: {}, + } as Navigator) + ).toBe(true); + expect( + shouldEnableServiceWorker(false, { serviceWorker: {} } as Navigator) + ).toBe(false); + expect(shouldEnableServiceWorker(true, {} as Navigator)).toBe(false); + }); +}); diff --git a/apps/web/src/app/services/runtime-config.ts b/apps/web/src/app/services/runtime-config.ts new file mode 100644 index 000000000..2a542a9b0 --- /dev/null +++ b/apps/web/src/app/services/runtime-config.ts @@ -0,0 +1,27 @@ +import { AppConfig } from '../../environments/environment'; + +export interface IptvnatorRuntimeConfig { + readonly BACKEND_URL?: string; +} + +export function resolveBackendUrl( + runtimeConfig: IptvnatorRuntimeConfig | undefined, + fallbackUrl: string +): string { + const runtimeUrl = runtimeConfig?.BACKEND_URL?.trim(); + return runtimeUrl || fallbackUrl; +} + +export function getRuntimeBackendUrl(): string { + return resolveBackendUrl( + globalThis.window?.__IPTVNATOR_CONFIG__, + AppConfig.BACKEND_URL + ); +} + +export function shouldEnableServiceWorker( + production = AppConfig.production, + navigatorRef: Navigator | undefined = globalThis.navigator +): boolean { + return production && !!navigatorRef && 'serviceWorker' in navigatorRef; +} diff --git a/apps/web/src/assets/app-config.js b/apps/web/src/assets/app-config.js new file mode 100644 index 000000000..bc466029f --- /dev/null +++ b/apps/web/src/assets/app-config.js @@ -0,0 +1 @@ +window.__IPTVNATOR_CONFIG__ = window.__IPTVNATOR_CONFIG__ || {}; diff --git a/apps/web/src/index.html b/apps/web/src/index.html index ef7b41fe9..967aa47ed 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -20,6 +20,7 @@ + -
+
IPTVnator
diff --git a/docker/README.md b/docker/README.md index 7c895db54..120944a0f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -7,10 +7,14 @@ You can deploy and run the PWA version of IPTVnator on your own machine with `do This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file. -## Build frontend +The web backend proxy accepts only `http` and `https` provider URLs. The PWA first registers provider URLs through `/provider-targets`, then uses the returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users. + +For providers that use private certificate authorities, keep TLS validation enabled and pass the CA bundle to Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`. + +## Build frontend $ docker build -t 4gray/iptvnator -f docker/Dockerfile . ## Build backend -You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend \ No newline at end of file +You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend diff --git a/global.d.ts b/global.d.ts index 7e40923f2..e56f47274 100644 --- a/global.d.ts +++ b/global.d.ts @@ -37,6 +37,9 @@ declare global { } interface Window { + __IPTVNATOR_CONFIG__?: { + BACKEND_URL?: string; + }; electron: { onPlaylistRefreshEvent?: ( callback: (data: PlaylistRefreshEvent) => void diff --git a/ngsw-config.json b/ngsw-config.json index f8bf2102e..e997fdbf5 100644 --- a/ngsw-config.json +++ b/ngsw-config.json @@ -1,30 +1,31 @@ { - "$schema": "./node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" - ] - } - } - ] + "$schema": "./node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.html", + "/manifest.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/**", + "!/assets/app-config.js", + "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" + ] + } + } + ] } diff --git a/package.json b/package.json index 340fd32b3..e6f082a75 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "drizzle-orm": "0.45.2", "electron-conf": "1.3.0", "electron-dl": "4.0.0", + "epg-parser": "^0.1.6", "fix-path": "5.0.0", "hls.js": "1.6.13", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f80140247..60af43347 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: electron-dl: specifier: 4.0.0 version: 4.0.0 + epg-parser: + specifier: ^0.1.6 + version: 0.1.6 fix-path: specifier: 5.0.0 version: 5.0.0 @@ -6577,6 +6580,9 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + epg-parser@0.1.6: + resolution: {integrity: sha512-g6AxKOvs0E4bTGPdIUh8/FDKdrVjbf4DVK0jIFuChDt7wBRJmMVyqbLeS8NApf6M2wpCRLBpIenXOCS88w0Rqw==} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -12023,6 +12029,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -19160,6 +19170,10 @@ snapshots: environment@1.1.0: {} + epg-parser@0.1.6: + dependencies: + xml-js: 1.6.11 + err-code@2.0.3: {} errno@0.1.8: @@ -25950,6 +25964,10 @@ snapshots: dependencies: is-wsl: 3.1.0 + xml-js@1.6.11: + dependencies: + sax: 1.6.0 + xml-name-validator@5.0.0: {} xmlbuilder@15.1.1: {}