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..5170b73a3 --- /dev/null +++ b/apps/web-backend/project.json @@ -0,0 +1,48 @@ +{ + "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" + } + } + }, + "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..acafb4f8c --- /dev/null +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -0,0 +1,249 @@ +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 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 }); + } + + 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.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 }; + } +} + +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(); + 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', + httpClient, + now: () => new Date('2026-05-15T08:00:00.000Z'), + }), + async (baseUrl) => { + const response = await fetch( + `${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}`, + { 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', + autoRefresh: false, + count: 1, + favorites: [], + filename: 'list.m3u', + id: 'fixed-id', + 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', + name: 'News Channel', + url: 'https://stream.example/news.m3u8', + }); + expect(httpClient.requests).toEqual([ + { + headers: undefined, + params: undefined, + url: 'https://provider.example/list.m3u', + }, + ]); + } + ); + }); + + 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 }), + async (baseUrl) => { + const response = await fetch( + `${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&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 }), + async (baseUrl) => { + const response = await fetch( + `${baseUrl}/stalker?url=${encodeURIComponent('http://stalker.example/portal.php')}&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 }), + async (baseUrl) => { + const response = await fetch( + `${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&action=get_account_info` + ); + + await expect(response.json()).resolves.toEqual({ + message: 'Forbidden', + status: 403, + }); + } + ); + }); +}); 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..304eacf68 --- /dev/null +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -0,0 +1,340 @@ +import cors from 'cors'; +import express, { Express, Request } from 'express'; +import https from 'node:https'; +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 httpsAgent?: unknown; + 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 clientOrigins?: string[]; + readonly guid?: () => string; + readonly httpClient?: WebBackendHttpClient; + readonly now?: () => Date; + readonly runtimeBackendUrl?: string; +} + +const defaultHttpsAgent = new https.Agent({ + rejectUnauthorized: false, +}); + +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 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.get('/parse', corsMiddleware, async (req, res) => { + const url = getQueryString(req, 'url'); + if (!url) { + res.status(400).send('Missing url'); + return; + } + + const result = await handlePlaylistParse({ + guid, + httpClient, + now, + url, + }); + + if (isPlaylistParseError(result)) { + res.status(result.status).send(result.message); + return; + } + + res.json(result); + }); + + app.get('/parse-xml', corsMiddleware, async (req, res) => { + const url = getQueryString(req, 'url'); + if (!url) { + res.status(400).send('Missing url'); + return; + } + + const result = await fetchEpgDataFromUrl(httpClient, url); + if (!result) { + res.status(500).send('Error, something went wrong'); + return; + } + + res.json(result); + }); + + app.get('/xtream', corsMiddleware, async (req, res) => { + const url = getQueryString(req, 'url'); + if (!url) { + res.status(400).json({ message: 'Missing url', status: 400 }); + return; + } + + try { + const response = await httpClient.get( + `${trimTrailingSlash(url)}/player_api.php`, + { + params: getProxyParams(req, ['url']), + } + ); + + res.json({ + action: getQueryString(req, 'action'), + payload: response.data, + }); + } catch (error) { + res.json(normalizeProviderError(error)); + } + }); + + app.get('/stalker', corsMiddleware, async (req, res) => { + const url = getQueryString(req, 'url'); + const macAddress = getQueryString(req, 'macAddress'); + const token = getQueryString(req, 'token'); + if (!url) { + res.status(400).json({ message: 'Missing url', status: 400 }); + return; + } + + try { + const response = await httpClient.get(url, { + params: getProxyParams(req, ['url']), + 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 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 trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} + +async function handlePlaylistParse(options: { + readonly guid: () => string; + readonly httpClient: WebBackendHttpClient; + readonly now: () => Date; + readonly url: string; +}): Promise | PlaylistParseError> { + try { + const response = await options.httpClient.get(options.url, { + httpsAgent: defaultHttpsAgent, + }); + 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: string +): Promise { + const response = await httpClient.get(url.trim(), { + ...(url.endsWith('.gz') ? { responseType: 'arraybuffer' } : {}), + }); + const xml = url.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(); + return { + id: options.guid(), + _id: options.guid(), + 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 ?? 'Error: not found', + status: providerError.response?.status ?? 404, + }; +} + +function createGuid(): string { + return Math.random().toString(36).slice(2); +} 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/package.json b/package.json index ab604d64b..7535fbce6 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,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: {}