From 65f81b71103b1244fab947f05809a00d346f192d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:51:36 +0200 Subject: [PATCH] fix(pwa): bring the Stalker transport to parity with Electron (#1348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pwa): bring the Stalker transport to parity with Electron The self-hosted PWA's /stalker proxy now derives its portal requests from the same shared identity and URL builders as the Electron main process: MAG User-Agent/X-User-Agent, full STB cookie (mac + stb_lang + timezone + serial-derived __cfduid), SN header, JsHttpRequest=1-xml defaulting, and the sn-only-on-get_profile rule. macAddress/token/serialNumber are control params consumed into headers and never echoed into the portal's query string (handshake keeps its candidate token — protocol content). The stalker-mock-server /stalker route mirrors the new contract through the same shared builder. Co-Authored-By: Claude Fable 5 * test(stalker): forward the full identity header set in the mock /stalker mirror Greptile review: the synthetic portal request kept only the cookie and Authorization from the generated identity, so mock handlers could never validate the SN/MAG-UA/Accept/Language/Connection headers the real proxy sends. Forward the complete set, lowercased the way Express normalizes incoming headers. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/pwa-stalker-transport-parity.md | 11 ++ .../src/app/events/stalker.events.ts | 4 +- .../stalker-playback-context.service.ts | 3 +- apps/stalker-mock-server/src/main.ts | 52 ++++-- .../src/app/web-backend-app.spec.ts | 154 +++++++++++++++--- apps/web-backend/src/app/web-backend-app.ts | 70 ++++---- apps/web/src/app/services/pwa.service.spec.ts | 43 +++++ apps/web/src/app/services/pwa.service.ts | 9 + docs/architecture/stalker-portal.md | 45 +++-- .../src/lib/stalker-live-playback.utils.ts | 4 +- libs/shared/interfaces/src/index.ts | 2 + .../lib/stalker-request-identity.util.spec.ts | 16 +- .../src/lib/stalker-request-identity.util.ts | 49 ++++-- .../src/lib/stalker-request-url.util.spec.ts | 2 +- .../src/lib/stalker-request-url.util.ts | 6 +- 15 files changed, 361 insertions(+), 109 deletions(-) create mode 100644 .changes/pwa-stalker-transport-parity.md rename apps/electron-backend/src/app/events/stalker-identity.spec.ts => libs/shared/interfaces/src/lib/stalker-request-identity.util.spec.ts (85%) rename apps/electron-backend/src/app/events/stalker-identity.ts => libs/shared/interfaces/src/lib/stalker-request-identity.util.ts (60%) rename apps/electron-backend/src/app/events/stalker-request-url.spec.ts => libs/shared/interfaces/src/lib/stalker-request-url.util.spec.ts (98%) rename apps/electron-backend/src/app/events/stalker-request-url.ts => libs/shared/interfaces/src/lib/stalker-request-url.util.ts (86%) diff --git a/.changes/pwa-stalker-transport-parity.md b/.changes/pwa-stalker-transport-parity.md new file mode 100644 index 000000000..db031f76b --- /dev/null +++ b/.changes/pwa-stalker-transport-parity.md @@ -0,0 +1,11 @@ +--- +type: fix +area: pwa +--- + +The self-hosted web app now talks to Stalker portals exactly like the desktop +app: MAG User-Agent, full STB cookie, serial-number header, and the +`JsHttpRequest` marker every real client sends — so portals that worked only in +the desktop app now work in the PWA too. Portal credentials (MAC and session +token) no longer travel in the portal request URL, keeping them out of server +logs. diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index ccd31ae31..2dc383567 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -8,12 +8,12 @@ import { ipcMain } from 'electron'; import { PortalDebugEvent, STALKER_REQUEST, + buildStalkerIdentityRequestContext, + buildStalkerRequestUrl, } from '@iptvnator/shared/interfaces'; import { redactSensitiveData } from '@iptvnator/shared/logging'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; -import { buildStalkerIdentityRequestContext } from './stalker-identity'; -import { buildStalkerRequestUrl } from './stalker-request-url'; import { assertRemoteUrlAllowed } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts index 3a343661b..74b38359e 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts @@ -1,11 +1,10 @@ import { + STALKER_MAG_USER_AGENT, buildStalkerSerialCfduid, isStalkerStreamCredentialSafe, normalizeStalkerSerialNumber, } from '@iptvnator/shared/interfaces'; -const STALKER_MAG_USER_AGENT = - 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; const STALKER_STREAM_USER_AGENT = 'KSPlayer'; const STALKER_STREAM_RANGE_HEADER = 'bytes=0-'; diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 2a26453e0..27ddea6ea 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -2,6 +2,7 @@ import http from 'http'; import { join } from 'node:path'; import express, { Request, Response } from 'express'; import cors from 'cors'; +import { buildStalkerIdentityRequestContext } from '@iptvnator/shared/interfaces'; import portalRouter, { createPortalRouter } from './app/routes/portal.route.js'; import dispatchPortalAction from './app/routes/dispatch.js'; import { @@ -118,14 +119,11 @@ function isFullPortalUrlShape(url: string): boolean { app.get('/stalker', (req: Request, res: Response) => { const { macAddress, + token, + serialNumber, url: portalUrl, ...rest } = req.query as Record; - // `token` deliberately stays in `rest`: the real backend proxy forwards - // every param except `targetId` to the portal *and* sets the Authorization - // header, and `handshake` reads the presented token from the query. Strip - // it here and the idempotent-handshake path becomes untestable. - const token = rest['token']; // A repeated query key arrives as an array, so every value used below must // be narrowed to a string before it reaches a string API. @@ -133,19 +131,49 @@ app.get('/stalker', (req: Request, res: Response) => { typeof value === 'string' ? value : undefined; const mac = asString(macAddress) ?? '00:1a:79:00:00:01'; - - // The real backend proxy turns the token query param into a Bearer header - // before calling the portal; mirror that so token handling is exercised. - const headers: Record = { cookie: `mac=${mac}` }; const bearer = asString(token); - if (bearer) { - headers['authorization'] = `Bearer ${bearer}`; + + // Mirror of the real backend proxy: `macAddress`, `token` and + // `serialNumber` are control params turned into the portal-facing + // Cookie / Authorization / SN headers and STRIPPED from the query the + // portal sees — with one protocol exception, `handshake`, which presents + // its candidate token as a query param (that keeps the + // idempotent-handshake path testable). The shared identity builder is the + // same code the real proxy and the Electron transport run, so this mirror + // cannot drift from them. + const query: Record = { ...rest }; + if (query['action'] === 'handshake' && bearer) { + query['token'] = bearer; } + const identity = buildStalkerIdentityRequestContext({ + macAddress: mac, + params: query as Record, + ...(bearer ? { token: bearer } : {}), + ...(asString(serialNumber) + ? { serialNumber: asString(serialNumber) } + : {}), + }); + // The real proxy appends this while building the portal URL; mirror it so + // `query_keys_received` diagnostics match what a real portal would log. + if (!identity.requestParams['JsHttpRequest']) { + identity.requestParams['JsHttpRequest'] = '1-xml'; + } + + // Forward the COMPLETE identity header set (Cookie, MAG UA pair, SN, + // Authorization, Accept/Language/Connection), lowercased the way Express + // normalizes incoming headers, so portal handlers can validate any header + // the real proxy sends — not just the cookie and token. + const headers: Record = Object.fromEntries( + Object.entries(identity.headers).map(([key, value]) => [ + key.toLowerCase(), + value, + ]) + ); // Build a lightweight synthetic request. We need a fresh object with mutable // `query` and a Cookie header containing the MAC for the handler helpers. const syntheticReq = { - query: rest, + query: identity.requestParams, headers, params: {}, } as unknown as Request; diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index b1448144f..ae87b0794 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -1,11 +1,21 @@ import { AddressInfo } from 'node:net'; import { Server } from 'node:http'; +import { STALKER_MAG_USER_AGENT } from '@iptvnator/shared/interfaces'; import { createWebBackendApp, WebBackendHttpClient, WebBackendHttpGetOptions, } from './web-backend-app'; +/** The transport-identity headers every portal-facing Stalker request carries. */ +const STALKER_IDENTITY_HEADERS = { + 'User-Agent': STALKER_MAG_USER_AGENT, + 'X-User-Agent': STALKER_MAG_USER_AGENT, + Accept: '*/*', + Connection: 'keep-alive', + 'Accept-Language': 'en-US,en;q=0.9', +}; + interface HttpRequest { readonly headers?: Record; readonly params?: Record; @@ -349,7 +359,7 @@ https://stream.example/live.m3u8`); ); }); - it('proxies Stalker requests with MAC cookie and bearer token', async () => { + it('proxies Stalker requests with the full STB identity and no credentials in the portal query', async () => { const httpClient = new StubHttpClient(); httpClient.queueResponse({ js: [{ id: '2001', title: 'Action' }] }); @@ -371,25 +381,131 @@ https://stream.example/live.m3u8`); action: 'get_categories', payload: { js: [{ id: '2001', title: 'Action' }] }, }); + // MAC and token travel ONLY as Cookie/Authorization — the + // portal query carries protocol params plus the JsHttpRequest + // marker every real client sends. expect(httpClient.requests).toEqual([ { headers: { + ...STALKER_IDENTITY_HEADERS, Authorization: 'Bearer abc123', - Cookie: 'mac=00:1A:79:00:00:01', + Cookie: 'mac=00:1A:79:00:00:01; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin', }, - params: { - action: 'get_categories', - macAddress: '00:1A:79:00:00:01', - token: 'abc123', - type: 'vod', - }, - url: 'http://stalker.example/portal.php', + params: undefined, + url: 'http://stalker.example/portal.php?action=get_categories&type=vod&JsHttpRequest=1-xml', }, ]); } ); }); + it('sends the SN header, cfduid cookie, and get_profile sn param for a stored serial', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ js: { status: 0 } }); + httpClient.queueResponse({ js: [] }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php' + ); + await fetch( + `${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=abc123&serialNumber=SN1234&type=stb&action=get_profile` + ); + // A non-profile action must not carry the serial as a query + // param even when the renderer sends a stale `sn`. + await fetch( + `${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=abc123&serialNumber=SN1234&type=itv&action=get_ordered_list&sn=SN1234` + ); + + const [profileRequest, listRequest] = httpClient.requests; + expect(profileRequest.headers).toMatchObject({ + SN: 'SN1234', + }); + expect(profileRequest.headers?.['Cookie']).toContain( + '__cfduid=' + ); + expect(profileRequest.url).toBe( + 'http://stalker.example/portal.php?type=stb&action=get_profile&sn=SN1234&JsHttpRequest=1-xml' + ); + + expect(listRequest.headers).toMatchObject({ SN: 'SN1234' }); + expect(listRequest.url).toBe( + 'http://stalker.example/portal.php?type=itv&action=get_ordered_list&JsHttpRequest=1-xml' + ); + for (const request of httpClient.requests) { + expect(request.url).not.toContain('serialNumber'); + expect(request.url).not.toContain('macAddress'); + expect(request.url).not.toContain('token='); + } + } + ); + }); + + it('keeps the presented handshake token in the portal query', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ js: { token: 'FRESH' } }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php' + ); + // Handshake is the one action whose token is protocol + // content: the portal reads the candidate from the query and + // returns it unchanged when it is still valid. + await fetch( + `${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=CACHEDTOKEN123&type=stb&action=handshake` + ); + + expect(httpClient.requests[0].url).toBe( + 'http://stalker.example/portal.php?type=stb&action=handshake&token=CACHEDTOKEN123&JsHttpRequest=1-xml' + ); + expect(httpClient.requests[0].headers).toMatchObject({ + Authorization: 'Bearer CACHEDTOKEN123', + }); + } + ); + }); + + it('omits the session cookie when no MAC is supplied', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ js: [] }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php' + ); + await fetch( + `${baseUrl}/stalker?targetId=${targetId}&action=get_categories` + ); + + expect( + httpClient.requests[0].headers + ).not.toHaveProperty('Cookie'); + expect(httpClient.requests[0].headers).toMatchObject( + STALKER_IDENTITY_HEADERS + ); + } + ); + }); + it('forwards Stalker cmd in the reference wire format instead of axios encoding', async () => { const httpClient = new StubHttpClient(); httpClient.queueResponse({ js: { cmd: 'http://cdn/stream.m3u8' } }); @@ -420,20 +536,20 @@ https://stream.example/live.m3u8`); // Slashes stay raw and pre-encoded sequences pass through // untouched (no %25 double-encoding); '&' inside cmd cannot - // append query parameters. cmd is no longer in axios params. + // append query parameters. The whole query is built by the + // shared URL builder, so nothing rides in axios params. expect(httpClient.requests).toEqual([ { headers: { - Cookie: 'mac=00:1A:79:00:00:01', - }, - params: { - action: 'create_link', - macAddress: '00:1A:79:00:00:01', - type: 'itv', + ...STALKER_IDENTITY_HEADERS, + Cookie: 'mac=00:1A:79:00:00:01; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin', }, + params: undefined, url: 'http://stalker.example/portal.php' + - '?cmd=ffrt3%20http://host/ch/123?token=a%3Ab%20c%26x=1', + '?action=create_link&type=itv' + + '&cmd=ffrt3%20http://host/ch/123?token=a%3Ab%20c%26x=1' + + '&JsHttpRequest=1-xml', }, ]); } @@ -471,8 +587,8 @@ https://stream.example/live.m3u8`); ); expect(httpClient.requests.map((request) => request.url)).toEqual([ - 'http://stalker.example/portal.php?cmd=/media/1.mpg', - 'http://stalker.example/load.php?cmd=/media/2.mpg', + 'http://stalker.example/portal.php?action=create_link&cmd=/media/1.mpg&JsHttpRequest=1-xml', + 'http://stalker.example/load.php?action=create_link&cmd=/media/2.mpg&JsHttpRequest=1-xml', ]); } ); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 3acaf836e..bb8ce34bb 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -8,7 +8,8 @@ import axios from 'axios'; import epgParser from 'epg-parser'; import parser from 'iptv-playlist-parser'; import { - encodeStalkerCmdValue, + buildStalkerIdentityRequestContext, + buildStalkerRequestUrl, normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils'; @@ -223,45 +224,50 @@ export function createWebBackendApp( const url = getRegisteredProviderUrl(req, res, providerTargets); const macAddress = getQueryString(req, 'macAddress'); const token = getQueryString(req, 'token'); + const serialNumber = getQueryString(req, 'serialNumber'); if (!url) { return; } try { - // `cmd` must reach the portal in the reference wire format (raw - // slashes, pre-encoded sequences untouched) — axios' default - // serializer would fully percent-encode it, diverging from what a - // real STB (and the Electron transport) sends. Append it to the - // URL with the shared encoder and let axios serialize the rest. - // The fragment is dropped first: a registered URL carrying `#...` - // would otherwise swallow the appended cmd, and a trailing bare - // `?` must not become `??cmd=`. - const { cmd, ...proxyParams } = getProxyParams(req, ['targetId']); - const portalUrl = new URL(url.href); - portalUrl.hash = ''; - const registeredQuery = portalUrl.search.replace(/^\?/, ''); - portalUrl.search = ''; - const query = [ - registeredQuery, - cmd ? `cmd=${encodeStalkerCmdValue(cmd)}` : '', - ] - .filter(Boolean) - .join('&'); - // cmd is appended strictly behind the literal `?`, so it can only - // ever form query content — never host or path. - const requestUrl = query - ? `${portalUrl.href}?${query}` - : portalUrl.href; + // `macAddress`, `token` and `serialNumber` are portal credentials, + // not protocol content: they reach the portal only as the same + // Cookie / Authorization / SN headers the Electron transport + // sends, never in the portal's query string (which lands in + // portal and intermediary access logs). The one protocol + // exception is `handshake`, which presents its candidate token as + // a query param and is answered without authentication. + const params: Record = getProxyParams( + req, + ['targetId', 'macAddress', 'token', 'serialNumber'] + ); + if (params['action'] === 'handshake' && token) { + params['token'] = token; + } + + // Shared with the Electron transport: full STB cookie, MAG + // User-Agent pair, `sn` only on get_profile, `JsHttpRequest` + // defaulting, and the reference `cmd` encoding (raw slashes, + // pre-encoded sequences untouched, `&`/`#` still escaped). + const identity = buildStalkerIdentityRequestContext({ + macAddress: macAddress ?? '', + params, + ...(token ? { token } : {}), + ...(serialNumber ? { serialNumber } : {}), + }); + const headers = { ...identity.headers }; + if (!macAddress) { + // Tolerate credential-less calls the way the route always + // has: no MAC means no session cookie, not an empty `mac=`. + delete headers['Cookie']; + } // Provider URLs are validated by /provider-targets before they enter the registry. // codeql[js/request-forgery] - const response = await httpClient.get(requestUrl, { - params: proxyParams, - headers: { - ...(macAddress ? { Cookie: `mac=${macAddress}` } : {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); + const response = await httpClient.get( + buildStalkerRequestUrl(url.href, identity.requestParams), + { headers } + ); res.json({ action: getQueryString(req, 'action'), diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index 305656995..af92ca677 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -77,4 +77,47 @@ describe('PwaService', () => { expect(http.match(() => true)).toHaveLength(0); }); + + it('sends Stalker credentials as /stalker control params, including the serial', async () => { + // JSDOM ships no fetch; install one for the proxy call. + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ payload: { js: [] } }), + } as unknown as Response); + const globalWithFetch = globalThis as { fetch?: typeof fetch }; + globalWithFetch.fetch = fetchMock as unknown as typeof fetch; + + try { + const request = service.forwardStalkerRequest({ + url: 'http://portal.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + token: 'TOKEN123', + serialNumber: 'SN1234', + params: { type: 'itv', action: 'get_ordered_list' }, + }); + + http.expectOne((req) => + req.url.endsWith('/provider-targets') + ).flush({ targetId: 'target-1' }); + + await expect(request).resolves.toEqual({ js: [] }); + + const requestUrl = new URL(String(fetchMock.mock.calls[0][0])); + // The proxy turns these into the portal-facing + // Cookie/Authorization/SN headers; they must all reach it so the + // PWA transport can match the Electron one. + expect(requestUrl.searchParams.get('macAddress')).toBe( + '00:1A:79:AA:BB:CC' + ); + expect(requestUrl.searchParams.get('token')).toBe('TOKEN123'); + expect(requestUrl.searchParams.get('serialNumber')).toBe( + 'SN1234' + ); + expect(requestUrl.searchParams.get('action')).toBe( + 'get_ordered_list' + ); + } finally { + delete globalWithFetch.fetch; + } + }); }); diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 3204b3262..16158549b 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -138,6 +138,7 @@ export class PwaService extends DataService { macAddress: string; params: Record; token?: string; + serialNumber?: string; } ) as T; } @@ -479,6 +480,7 @@ export class PwaService extends DataService { params: Record; macAddress: string; token?: string; + serialNumber?: string; }) { let context = createPortalDebugRequestContext({ provider: 'stalker', @@ -494,11 +496,18 @@ export class PwaService extends DataService { try { const targetId = await this.getProviderTargetId(payload.url); const token = payload.token ?? payload.params.token; + // `macAddress`, `token` and `serialNumber` are control params for + // the /stalker proxy: it turns them into the portal-facing + // Cookie / Authorization / SN headers and strips them from the + // query it forwards to the portal. const requestParams = { targetId, ...payload.params, macAddress: payload.macAddress, ...(token ? { token } : {}), + ...(payload.serialNumber + ? { serialNumber: payload.serialNumber } + : {}), }; const params = new URLSearchParams(requestParams); const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`; diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 22deb7d9d..ba5228c29 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -160,17 +160,42 @@ on both transports with the shared `encodeStalkerCmdValue()` decode back to the original byte server-side, so the portal-visible value is unaffected. -Consumers of the encoder: +Both transports assemble the portal request from the same two shared builders +in `@iptvnator/shared/interfaces`, so their wire format cannot drift apart: -- Electron: `buildStalkerRequestUrl()` - (`apps/electron-backend/src/app/events/stalker-request-url.ts`) assembles the - portal query for `STALKER_REQUEST`; `cmd` uses the reference encoding, every - other param stays fully `encodeURIComponent`-encoded, and `JsHttpRequest=1-xml` - is appended when missing. -- PWA: the web-backend `/stalker` proxy appends `cmd` to the portal URL with - the same encoder instead of letting axios' serializer turn slashes into - `%2F`. The renderer→proxy leg uses `URLSearchParams`, which Express decodes - losslessly, so the stored `cmd` string reaches the proxy intact. +- `buildStalkerRequestUrl()` + (`libs/shared/interfaces/src/lib/stalker-request-url.util.ts`) builds the + full portal URL: `cmd` uses the reference encoding, every other param stays + fully `encodeURIComponent`-encoded, `JsHttpRequest=1-xml` is appended when + missing, and any query carried by the portal URL itself is dropped. +- `buildStalkerIdentityRequestContext()` + (`libs/shared/interfaces/src/lib/stalker-request-identity.util.ts`) builds + the STB identity: the `mac`/`stb_lang`/`timezone` cookie (plus a + serial-derived `__cfduid`), the MAG `User-Agent`/`X-User-Agent` pair + (`STALKER_MAG_USER_AGENT`), `Accept`/`Accept-Language`/`Connection`, the + `SN` header and `Authorization: Bearer` when present, and the serial + parameter rule: `sn` travels only on `get_profile` (injected there, stripped + everywhere else, mirrored into the `metrics` JSON). + +Consumers: + +- Electron: the `STALKER_REQUEST` handler + (`apps/electron-backend/src/app/events/stalker.events.ts`) feeds both + builders directly. +- PWA: the renderer (`PwaService.forwardStalkerRequest`) sends `macAddress`, + `token`, and `serialNumber` as **control params** on the renderer→proxy leg + (`URLSearchParams`, which Express decodes losslessly). The web-backend + `/stalker` proxy consumes them into the identity headers via the same shared + builders and **never forwards them in the portal's query string** — portal + credentials must not land in portal or intermediary access logs. The one + protocol exception is `handshake`, whose candidate token is genuine query + content (the portal reads it for the idempotent-handshake path) and is + re-injected there. +- Mock: the stalker-mock-server's `/stalker` route + (`apps/stalker-mock-server/src/main.ts`) mirrors the proxy with the same + shared identity builder, so PWA E2E runs exercise the real contract + (including `query_keys_received` diagnostics matching what a real portal + would log). The mock portal's `create_link` response carries mock-only `cmd_received` and `query_keys_received` diagnostics so E2E can pin this contract diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts index 2cf25631e..c085dbef8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts @@ -1,5 +1,6 @@ import { PlaylistMeta, + STALKER_MAG_USER_AGENT, isStalkerStreamCredentialSafe, } from '@iptvnator/shared/interfaces'; import { @@ -7,8 +8,7 @@ import { normalizeStalkerSerialNumber, } from './stalker-identity.utils'; -export const STALKER_MAG_USER_AGENT = - 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; +export { STALKER_MAG_USER_AGENT }; export const STALKER_STREAM_USER_AGENT = 'KSPlayer'; export function getStalkerPortalOrigin( diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index b3efd3bb2..f9fbbf598 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -68,6 +68,8 @@ export * from './lib/xtream-vod-stream.interface'; // Stalker interfaces export * from './lib/stalker-item.normalizer'; export * from './lib/stalker-identity.utils'; +export * from './lib/stalker-request-identity.util'; +export * from './lib/stalker-request-url.util'; export * from './lib/stalker-portal-item.interface'; export * from './lib/stalker-stream-profile.util'; export * from './lib/stalker-serial-details.interface'; diff --git a/apps/electron-backend/src/app/events/stalker-identity.spec.ts b/libs/shared/interfaces/src/lib/stalker-request-identity.util.spec.ts similarity index 85% rename from apps/electron-backend/src/app/events/stalker-identity.spec.ts rename to libs/shared/interfaces/src/lib/stalker-request-identity.util.spec.ts index 53c5ea3fc..24e8a7661 100644 --- a/apps/electron-backend/src/app/events/stalker-identity.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-request-identity.util.spec.ts @@ -1,7 +1,5 @@ -import { - LEGACY_DEFAULT_STALKER_SERIAL, - buildStalkerIdentityRequestContext, -} from './stalker-identity'; +import { LEGACY_DEFAULT_STALKER_SERIAL } from './stalker-identity.utils'; +import { buildStalkerIdentityRequestContext } from './stalker-request-identity.util'; describe('buildStalkerIdentityRequestContext', () => { const macAddress = '00:1A:79:AA:BB:CC'; @@ -25,7 +23,7 @@ describe('buildStalkerIdentityRequestContext', () => { expect(context.cookieString).not.toContain('__cfduid='); expect(context.requestParams).not.toHaveProperty('sn'); expect( - JSON.parse(String(context.requestParams.metrics)) + JSON.parse(String(context.requestParams['metrics'])) ).not.toHaveProperty('sn'); }); @@ -43,13 +41,13 @@ describe('buildStalkerIdentityRequestContext', () => { }); expect(context.effectiveSerialNumber).toBe('CustomSn123'); - expect(context.headers.SN).toBe('CustomSn123'); + expect(context.headers['SN']).toBe('CustomSn123'); expect(context.cookieString).toContain('__cfduid='); expect( context.cookieString.match(/__cfduid=([^;]+)/)?.[1] ).toHaveLength(32); - expect(context.requestParams.sn).toBe('CustomSn123'); - expect(JSON.parse(String(context.requestParams.metrics))).toEqual( + expect(context.requestParams['sn']).toBe('CustomSn123'); + expect(JSON.parse(String(context.requestParams['metrics']))).toEqual( expect.objectContaining({ sn: 'CustomSn123', }) @@ -76,7 +74,7 @@ describe('buildStalkerIdentityRequestContext', () => { expect(context.cookieString).not.toContain('__cfduid='); expect(context.requestParams).not.toHaveProperty('sn'); expect( - JSON.parse(String(context.requestParams.metrics)) + JSON.parse(String(context.requestParams['metrics'])) ).not.toHaveProperty('sn'); }); diff --git a/apps/electron-backend/src/app/events/stalker-identity.ts b/libs/shared/interfaces/src/lib/stalker-request-identity.util.ts similarity index 60% rename from apps/electron-backend/src/app/events/stalker-identity.ts rename to libs/shared/interfaces/src/lib/stalker-request-identity.util.ts index 4583f5b9f..835844dc5 100644 --- a/apps/electron-backend/src/app/events/stalker-identity.ts +++ b/libs/shared/interfaces/src/lib/stalker-request-identity.util.ts @@ -1,25 +1,41 @@ import { buildStalkerSerialCfduid, - LEGACY_DEFAULT_STALKER_SERIAL, normalizeStalkerSerialNumber, -} from '@iptvnator/shared/interfaces'; +} from './stalker-identity.utils'; -export { LEGACY_DEFAULT_STALKER_SERIAL }; +/** + * User-Agent of a MAG250 set-top box (matches the stalker-to-m3u reference + * implementation). Sent as both `User-Agent` and `X-User-Agent` on every + * portal API request — some panels reject requests without it. + */ +export const STALKER_MAG_USER_AGENT = + 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; -interface StalkerIdentityRequestInput { +export interface StalkerIdentityRequestInput { macAddress: string; params: Record; token?: string; serialNumber?: string; } -interface StalkerIdentityRequestContext { +export interface StalkerIdentityRequestContext { requestParams: Record; headers: Record; cookieString: string; effectiveSerialNumber?: string; } +/** + * Builds the portal-facing identity of a Stalker request: the cookie a real + * STB sends (`mac` + `stb_lang` + `timezone`, plus a serial-derived + * `__cfduid`), the MAG User-Agent pair, the `SN`/`Authorization` headers, and + * the serial-number parameter rules (`sn` travels only on `get_profile`). + * + * This is the single source of truth for BOTH transports — the Electron main + * process and the self-hosted PWA's web-backend `/stalker` proxy — so the two + * cannot drift apart again. The stock server reads the timezone cookie with + * `new DateTimeZone()`, so it must always be a valid PHP timezone. + */ export function buildStalkerIdentityRequestContext({ macAddress, params, @@ -31,11 +47,8 @@ export function buildStalkerIdentityRequestContext({ const cookieString = buildCookieString(macAddress, effectiveSerialNumber); const headers: Record = { Cookie: cookieString, - // Use MAG250 User-Agent matching stalker-to-m3u implementation - 'User-Agent': - 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250', - 'X-User-Agent': - 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250', + 'User-Agent': STALKER_MAG_USER_AGENT, + 'X-User-Agent': STALKER_MAG_USER_AGENT, Accept: '*/*', Connection: 'keep-alive', 'Accept-Language': 'en-US,en;q=0.9', @@ -63,23 +76,23 @@ function buildRequestParams( ): Record { const requestParams = { ...params }; const isProfileRequest = - requestParams.type === 'stb' && - requestParams.action === 'get_profile'; + requestParams['type'] === 'stb' && + requestParams['action'] === 'get_profile'; if (!isProfileRequest) { - delete requestParams.sn; + delete requestParams['sn']; return requestParams; } if (effectiveSerialNumber) { - requestParams.sn = effectiveSerialNumber; + requestParams['sn'] = effectiveSerialNumber; } else { - delete requestParams.sn; + delete requestParams['sn']; } - if (typeof requestParams.metrics === 'string') { + if (typeof requestParams['metrics'] === 'string') { try { - const parsedMetrics = JSON.parse(requestParams.metrics); + const parsedMetrics = JSON.parse(requestParams['metrics']); const nextMetrics = { ...(parsedMetrics ?? {}) }; if (effectiveSerialNumber) { @@ -88,7 +101,7 @@ function buildRequestParams( delete nextMetrics.sn; } - requestParams.metrics = JSON.stringify(nextMetrics); + requestParams['metrics'] = JSON.stringify(nextMetrics); } catch { // Keep original metrics payload when malformed. } diff --git a/apps/electron-backend/src/app/events/stalker-request-url.spec.ts b/libs/shared/interfaces/src/lib/stalker-request-url.util.spec.ts similarity index 98% rename from apps/electron-backend/src/app/events/stalker-request-url.spec.ts rename to libs/shared/interfaces/src/lib/stalker-request-url.util.spec.ts index bb5292cc9..a24bafb40 100644 --- a/apps/electron-backend/src/app/events/stalker-request-url.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-request-url.util.spec.ts @@ -1,4 +1,4 @@ -import { buildStalkerRequestUrl } from './stalker-request-url'; +import { buildStalkerRequestUrl } from './stalker-request-url.util'; const PORTAL = 'http://portal.example/stalker_portal/server/load.php'; diff --git a/apps/electron-backend/src/app/events/stalker-request-url.ts b/libs/shared/interfaces/src/lib/stalker-request-url.util.ts similarity index 86% rename from apps/electron-backend/src/app/events/stalker-request-url.ts rename to libs/shared/interfaces/src/lib/stalker-request-url.util.ts index 1e5226027..3d17c2a6f 100644 --- a/apps/electron-backend/src/app/events/stalker-request-url.ts +++ b/libs/shared/interfaces/src/lib/stalker-request-url.util.ts @@ -1,8 +1,10 @@ -import { encodeStalkerCmdValue } from '@iptvnator/shared/interfaces'; +import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util'; /** * Builds the full Stalker portal request URL from an already-validated portal - * URL and the prepared request params. + * URL and the prepared request params. Shared by the Electron main process + * and the web-backend `/stalker` proxy so both transports emit the exact same + * wire format. * * The query string is assembled manually because the two parameter classes * need different encodings: