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
This commit is contained in:
4gray authored and GitHub committed 2026-05-16 00:10:03 +02:00
1 parent 5e5a89a300
commit 32cc0b74e8
26 files changed
+1653 -92

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'web-backend',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/web-backend',
};
+49
View File
@@ -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"
}
}
}
}
@@ -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<string, string>;
readonly params?: Record<string, string>;
readonly responseData: unknown;
readonly responseStatus?: number;
readonly url: string;
}
class StubHttpClient implements WebBackendHttpClient {
readonly requests: Omit<HttpRequest, 'responseData' | 'responseStatus'>[] =
[];
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<T>(
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<string> {
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<T>(
app: ReturnType<typeof createWebBackendApp>,
callback: (baseUrl: string) => Promise<T>
): Promise<T> {
const server = await new Promise<Server>((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<void>((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<Record<string, unknown>> };
};
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, '<script>alert(1)</script>');
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: '<script>alert(1)</script>',
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([]);
}
);
});
});
+574
View File
@@ -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<string, string>;
readonly params?: Record<string, string>;
readonly responseType?: 'arraybuffer';
}
export interface WebBackendHttpClient {
get<T>(
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 string[]>;
readonly runtimeBackendUrl?: string;
}
interface ProviderUrlPolicy {
readonly allowPrivateNetworkTargets: boolean;
readonly resolveHostname: (hostname: string) => Promise<readonly string[]>;
}
interface ProviderUrlError {
readonly message: string;
readonly status: number;
}
type ProviderTargetRegistry = Map<string, URL>;
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<URL | ProviderUrlError> {
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<readonly string[]> {
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<string, string> {
const excluded = new Set(excludedKeys);
const params: Record<string, string> = {};
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<Record<string, unknown> | PlaylistParseError> {
try {
// Provider URLs are validated by /provider-targets before playlist parsing.
// codeql[js/request-forgery]
const response = await options.httpClient.get<string>(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<unknown> {
const href = url.href;
// Provider URLs are validated by /provider-targets before XMLTV parsing.
// codeql[js/request-forgery]
const response = await httpClient.get<ArrayBuffer | string>(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<string, unknown> | PlaylistParseError
): result is PlaylistParseError {
return (
typeof (result as PlaylistParseError).status === 'number' &&
typeof (result as PlaylistParseError).message === 'string'
);
}
function parsePlaylist(playlist: string): {
items: Array<Record<string, unknown>>;
} {
return parser.parse(playlist) as unknown as {
items: Array<Record<string, unknown>>;
};
}
function createPlaylistObject(options: {
readonly guid: () => string;
readonly now: () => Date;
readonly playlist: { items: Array<Record<string, unknown>> };
readonly title: string;
readonly url: string;
}): Record<string, unknown> {
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;
}
+8
View File
@@ -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}`);
});
+7
View File
@@ -0,0 +1,7 @@
declare module 'epg-parser' {
const parser: {
parse(xml: string): unknown;
};
export default parser;
}
+10
View File
@@ -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"]
}
+14
View File
@@ -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"]
}
+15
View File
@@ -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"
]
}
+6
View File
@@ -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: [
{
+80
View File
@@ -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<Map<string, string>> {
const providerTargets = new Map<string, string>();
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, string>
): 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<ProviderTargetPayload>).url === 'string'
) {
return (value as ProviderTargetPayload).url;
}
return null;
}
+106
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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 });
});
+20 -3
View File
@@ -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<void> {
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 }) => {
+29 -10
View File
@@ -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<void> {
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(
+5 -2
View File
@@ -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
}
}
+2 -1
View File
@@ -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(
+96 -41
View File
@@ -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<string, Promise<string>>();
private readonly silentXtreamActions = new Set<string>([
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<string, string>;
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<PwaXtreamResponse>(
`${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<string, string>;
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<string> {
const cachedTargetId = this.providerTargetIds.get(url);
if (cachedTargetId) {
return cachedTargetId;
}
const targetIdRequest = firstValueFrom(
this.http.post<ProviderTargetRegistration>(
`${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 {
@@ -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);
});
});
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
window.__IPTVNATOR_CONFIG__ = window.__IPTVNATOR_CONFIG__ || {};
+2 -5
View File
@@ -20,6 +20,7 @@
<link rel="manifest" href="manifest.webmanifest" />
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png" />
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico" />
<script src="assets/app-config.js" defer></script>
<style>
/* Inline splash — paints immediately while the JS bundle and the
styles.css (~300KB) are still downloading and Angular is
@@ -68,11 +69,7 @@
</style>
</head>
<body class="mat-app-background">
<div
id="initial-splash"
role="status"
aria-label="Loading IPTVnator"
>
<div id="initial-splash" role="status" aria-label="Loading IPTVnator">
<span class="splash-mark">IPTVnator</span>
<span class="splash-spinner" aria-hidden="true"></span>
</div>
+6 -2
View File
@@ -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
You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend
Vendored
+3
View File
@@ -37,6 +37,9 @@ declare global {
}
interface Window {
__IPTVNATOR_CONFIG__?: {
BACKEND_URL?: string;
};
electron: {
onPlaylistRefreshEvent?: (
callback: (data: PlaylistRefreshEvent) => void
+29 -28
View File
@@ -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)"
]
}
}
]
}
+1
View File
@@ -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",
+18
View File
@@ -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: {}