mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
1 parent
a6186a46c8
commit
65f81b7110
15 files changed
+361
-109
No files matched your search
@@ -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-';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
+7
-9
@@ -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');
|
||||
});
|
||||
|
||||
+31
-18
@@ -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
-1
@@ -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';
|
||||
|
||||
+4
-2
@@ -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:
|
||||
Reference in new issue
Block a user