fix(pwa): bring the Stalker transport to parity with Electron (#1348)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-02 14:51:36 +02:00
1 parent a6186a46c8
commit 65f81b7110
15 files changed
+361 -109

No files matched your search

+11
View File
@@ -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.
@@ -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';
@@ -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-';
+40 -12
View File
@@ -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<string, unknown>;
// `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<string, string> = { 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<string, unknown> = { ...rest };
if (query['action'] === 'handshake' && bearer) {
query['token'] = bearer;
}
const identity = buildStalkerIdentityRequestContext({
macAddress: mac,
params: query as Record<string, string | number>,
...(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<string, string> = 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;
+135 -19
View File
@@ -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<string, string>;
readonly params?: Record<string, string>;
@@ -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',
]);
}
);
+38 -32
View File
@@ -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<string, string | number> = 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'),
@@ -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;
}
});
});
+9
View File
@@ -138,6 +138,7 @@ export class PwaService extends DataService {
macAddress: string;
params: Record<string, string>;
token?: string;
serialNumber?: string;
}
) as T;
}
@@ -479,6 +480,7 @@ export class PwaService extends DataService {
params: Record<string, string>;
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()}`;
+35 -10
View File
@@ -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
@@ -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(
+2
View File
@@ -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';
@@ -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');
});
@@ -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<string, string | number>;
token?: string;
serialNumber?: string;
}
interface StalkerIdentityRequestContext {
export interface StalkerIdentityRequestContext {
requestParams: Record<string, string | number>;
headers: Record<string, string>;
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<string, string> = {
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<string, string | number> {
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.
}
@@ -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';
@@ -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: