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

+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(