mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
Merge remote-tracking branch 'origin/master' into agent/download-season-queue
# Conflicts: # apps/electron-backend/src/app/events/database/download-requests.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts # libs/ui/components/src/lib/season-container/episode-download.util.ts # libs/ui/components/src/lib/season-container/episode-utils.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.ts
This commit is contained in:
commit
31ac306bb0
243 files changed
+12935
-1106
No files matched your search
@@ -25,6 +25,7 @@ export * from './lib/playback-position.interface';
|
||||
export * from './lib/playlist-auto-update.interface';
|
||||
export * from './lib/playlist-backup.interface';
|
||||
export * from './lib/playlist-meta.type';
|
||||
export * from './lib/portal-account-playlist.utils';
|
||||
export * from './lib/preload-performance-marker.interface';
|
||||
export * from './lib/playlist-recently-viewed.interface';
|
||||
export * from './lib/playlist-recently-viewed.utils';
|
||||
@@ -37,12 +38,14 @@ export * from './lib/portal-playback.interface';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
export * from './lib/settings.interface';
|
||||
export * from './lib/stalker-cmd-encoding.util';
|
||||
export * from './lib/stalker-portal-actions.enum';
|
||||
export * from './lib/store-keys.enum';
|
||||
export * from './lib/stream-format.enum';
|
||||
export * from './lib/catalog-title-match.interface';
|
||||
export * from './lib/theme.enum';
|
||||
export * from './lib/season-marker.util';
|
||||
export * from './lib/stalker-account-info-dialog-data.interface';
|
||||
export * from './lib/title-normalization.util';
|
||||
export * from './lib/tmdb.interface';
|
||||
export * from './lib/vod-source.interface';
|
||||
@@ -65,7 +68,10 @@ 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';
|
||||
export * from './lib/stalker-vod-details.interface';
|
||||
|
||||
|
||||
@@ -193,6 +193,17 @@ export interface ElectronBridgeWindowState {
|
||||
isFullScreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal credentials for an auth-gated stream, passed alongside the scoped
|
||||
* header override. The main process attaches them only to requests going to
|
||||
* the exact origin of the override's `scopeUrl`, keeps them in memory only,
|
||||
* and drops them when the scoped override is cleared or replaced.
|
||||
*/
|
||||
export interface ElectronBridgeStreamCredentials {
|
||||
authorization?: string | null;
|
||||
cookie?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A playlist file the operating system asked the app to open — a command line
|
||||
* argument, a file association double-click, or macOS' `open-file` event. The
|
||||
@@ -632,7 +643,8 @@ export interface ElectronBridgeApi {
|
||||
setUserAgent: (
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
scopeUrl?: string | null,
|
||||
credentials?: ElectronBridgeStreamCredentials | null
|
||||
) => Promise<boolean>;
|
||||
openInMpv: (
|
||||
url: string,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { PlaylistMeta } from './playlist-meta.type';
|
||||
|
||||
/**
|
||||
* A playlist backed by an Xtream Codes account — the only kind the Xtream
|
||||
* account-info dialog can query (`player_api.php` needs all three fields).
|
||||
*/
|
||||
export function isXtreamAccountPlaylist(
|
||||
playlist: PlaylistMeta
|
||||
): playlist is PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
return Boolean(
|
||||
playlist.serverUrl && playlist.username && playlist.password
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A playlist backed by a Stalker/Ministra portal. Portal URL + MAC address
|
||||
* are what every Stalker request is keyed on, so together they identify an
|
||||
* account the Stalker account-info dialog can describe.
|
||||
*/
|
||||
export function isStalkerAccountPlaylist(
|
||||
playlist: PlaylistMeta
|
||||
): playlist is PlaylistMeta & {
|
||||
portalUrl: string;
|
||||
macAddress: string;
|
||||
} {
|
||||
return Boolean(playlist.portalUrl && playlist.macAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Any playlist for which an account-info dialog exists.
|
||||
*/
|
||||
export function isPortalAccountPlaylist(playlist: PlaylistMeta): boolean {
|
||||
return (
|
||||
isXtreamAccountPlaylist(playlist) || isStalkerAccountPlaylist(playlist)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PlaylistMeta } from './playlist-meta.type';
|
||||
|
||||
/**
|
||||
* Dialog input for the Stalker account-info dialog. The meta row carries the
|
||||
* portal URL, MAC address, and device identity needed for a fresh portal
|
||||
* query; the cached `stalkerAccountInfo` snapshot lives only in the full
|
||||
* playlist payload, so the dialog loads it by id.
|
||||
*/
|
||||
export interface StalkerAccountInfoDialogData {
|
||||
playlist: PlaylistMeta;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util';
|
||||
|
||||
/**
|
||||
* One application-x-www-form-urlencoded decode, the way PHP's `$_GET` (and
|
||||
* Express' query parser) sees the value: `+` is a space, `%XX` decodes once.
|
||||
*/
|
||||
function portalVisibleValue(encoded: string): string {
|
||||
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
|
||||
}
|
||||
|
||||
describe('encodeStalkerCmdValue', () => {
|
||||
it('keeps a plain solution-prefixed cmd readable (space only)', () => {
|
||||
expect(encodeStalkerCmdValue('ffrt3 http://host/ch/123')).toBe(
|
||||
'ffrt3%20http://host/ch/123'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ?, = and : raw in a tokened cmd', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/ch/123?token=abc')
|
||||
).toBe('auto%20http://host/ch/123?token=abc');
|
||||
});
|
||||
|
||||
it('leaves a bare media path untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/12345.mpg')).toBe(
|
||||
'/media/12345.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('never double-encodes pre-encoded sequences', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/s/a%3Ab%20c.m3u8')
|
||||
).toBe('auto%20http://host/s/a%3Ab%20c.m3u8');
|
||||
});
|
||||
|
||||
it('passes a bare, malformed % through untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/100%.mpg')).toBe(
|
||||
'/media/100%.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes the query-structure characters &, # and ;', () => {
|
||||
expect(encodeStalkerCmdValue('a&b#c;d')).toBe('a%26b%23c%3Bd');
|
||||
});
|
||||
|
||||
it('keeps + raw so the portal sees a space, like it does for a real STB', () => {
|
||||
const encoded = encodeStalkerCmdValue('auto http://host/s/a+b.ts');
|
||||
expect(encoded).toBe('auto%20http://host/s/a+b.ts');
|
||||
expect(portalVisibleValue(encoded)).toBe('auto http://host/s/a b.ts');
|
||||
});
|
||||
|
||||
it('keeps IPv6 literals byte-identical', () => {
|
||||
expect(encodeStalkerCmdValue('ffmpeg http://[::1]:8080/ch/1')).toBe(
|
||||
'ffmpeg%20http://[::1]:8080/ch/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes quotes, backslash and control characters', () => {
|
||||
expect(encodeStalkerCmdValue(`a"b'c\\d\ne`)).toBe(
|
||||
'a%22b%27c%5Cd%0Ae'
|
||||
);
|
||||
});
|
||||
|
||||
it('percent-encodes non-ASCII as UTF-8', () => {
|
||||
expect(encodeStalkerCmdValue('auto http://host/канал/1')).toBe(
|
||||
'auto%20http://host/%D0%BA%D0%B0%D0%BD%D0%B0%D0%BB/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('survives WHATWG URL re-parsing byte-identically', () => {
|
||||
const corpus = [
|
||||
'ffrt3 http://host/ch/123',
|
||||
'auto http://host/ch/123?token=abc',
|
||||
'/media/12345.mpg',
|
||||
'auto http://host/s/a%3Ab%20c.m3u8',
|
||||
"a&b#c;d'e\"f",
|
||||
'ffmpeg http://[::1]:8080/ch/1',
|
||||
'auto http://host/канал/1',
|
||||
];
|
||||
for (const cmd of corpus) {
|
||||
const query = `cmd=${encodeStalkerCmdValue(cmd)}`;
|
||||
expect(new URL(`http://portal/load.php?${query}`).search).toBe(
|
||||
`?${query}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('is value-preserving after one server-side decode for every escaped character', () => {
|
||||
const original = `ffrt http://h/s?a=1&b=2#f;g "q" 'x' я`;
|
||||
expect(portalVisibleValue(encodeStalkerCmdValue(original))).toBe(
|
||||
original
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wire encoding for the Stalker `cmd` query parameter.
|
||||
*
|
||||
* A real MAG/STB sends `cmd` unencoded: the portal's own client JS
|
||||
* concatenates raw `key=value` pairs, WebKit's URL layer escapes only the
|
||||
* characters a URL cannot carry (space, quotes, non-ASCII), and PHP's `$_GET`
|
||||
* then applies exactly one form-urldecode. The portal therefore sees the
|
||||
* stored cmd decoded **once** — pre-encoded sequences such as `%3A` arrive as
|
||||
* `:`, and a literal `+` arrives as a space.
|
||||
*
|
||||
* `encodeURIComponent` (the previous behavior) broke that contract for any
|
||||
* cmd already containing `%`: `%3A` went out as `%253A` and reached the
|
||||
* portal still encoded, so strict panels and the stock
|
||||
* `preg_match`-based `create_link` handlers saw a different string than a
|
||||
* real STB would send.
|
||||
*
|
||||
* This encoder reproduces the reference wire bytes instead:
|
||||
*
|
||||
* - `%` passes through untouched (never double-encoded);
|
||||
* - characters the WHATWG URL serializer keeps raw in a query stay raw
|
||||
* (`/ : ? = + , @ $ [ ] ! * ( ) ~ - _ .`), so the bytes we emit survive
|
||||
* `new URL(...)` unchanged;
|
||||
* - everything else is percent-encoded. That covers the characters that
|
||||
* would restructure our request (`&`, `#`, and `;` for PHP setups with a
|
||||
* `;` argument separator) — a malicious portal cannot smuggle extra query
|
||||
* parameters through cmd — plus characters a URL cannot carry raw (space,
|
||||
* quotes, control characters, non-ASCII). All of them decode back to the
|
||||
* original byte on the server, so the portal-visible value is unaffected.
|
||||
*/
|
||||
|
||||
const SAFE_CMD_CHAR = /^[A-Za-z0-9\-_.~!*()/:?@$,+=[\]%]$/;
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Percent-encode every UTF-8 byte of a character. Deliberately not
|
||||
* `encodeURIComponent`, whose unreserved set (e.g. `'`) overlaps characters
|
||||
* this encoder must escape because `new URL(...)` would re-encode them and
|
||||
* change the wire bytes behind our back.
|
||||
*/
|
||||
function percentEncodeChar(char: string): string {
|
||||
let encoded = '';
|
||||
for (const byte of utf8Encoder.encode(char)) {
|
||||
encoded += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function encodeStalkerCmdValue(value: string): string {
|
||||
let encoded = '';
|
||||
for (const char of value) {
|
||||
encoded += SAFE_CMD_CHAR.test(char) ? char : percentEncodeChar(char);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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';
|
||||
|
||||
it('does not add SN header or force get_profile SN fields when serial is absent', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
sn: 'STALE-SN',
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
sn: 'STALE-SN',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBeUndefined();
|
||||
expect(context.headers).not.toHaveProperty('SN');
|
||||
expect(context.cookieString).not.toContain('__cfduid=');
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
expect(
|
||||
JSON.parse(String(context.requestParams['metrics']))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('preserves a provided serial exactly in headers, params, and metrics', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
serialNumber: 'CustomSn123',
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).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.objectContaining({
|
||||
sn: 'CustomSn123',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the legacy default serial as absent', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
serialNumber: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
sn: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
sn: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBeUndefined();
|
||||
expect(context.headers).not.toHaveProperty('SN');
|
||||
expect(context.cookieString).not.toContain('__cfduid=');
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
expect(
|
||||
JSON.parse(String(context.requestParams['metrics']))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('removes stale SN params from non-profile requests', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params: {
|
||||
type: 'itv',
|
||||
action: 'get_ordered_list',
|
||||
sn: 'STALE-SN',
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from './stalker-identity.utils';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export interface StalkerIdentityRequestInput {
|
||||
macAddress: string;
|
||||
params: Record<string, string | number>;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
token,
|
||||
serialNumber,
|
||||
}: StalkerIdentityRequestInput): StalkerIdentityRequestContext {
|
||||
const effectiveSerialNumber = normalizeStalkerSerialNumber(serialNumber);
|
||||
const requestParams = buildRequestParams(params, effectiveSerialNumber);
|
||||
const cookieString = buildCookieString(macAddress, effectiveSerialNumber);
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieString,
|
||||
'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',
|
||||
};
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
headers['SN'] = effectiveSerialNumber;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return {
|
||||
requestParams,
|
||||
headers,
|
||||
cookieString,
|
||||
...(effectiveSerialNumber ? { effectiveSerialNumber } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestParams(
|
||||
params: Record<string, string | number>,
|
||||
effectiveSerialNumber: string | undefined
|
||||
): Record<string, string | number> {
|
||||
const requestParams = { ...params };
|
||||
const isProfileRequest =
|
||||
requestParams['type'] === 'stb' &&
|
||||
requestParams['action'] === 'get_profile';
|
||||
|
||||
if (!isProfileRequest) {
|
||||
delete requestParams['sn'];
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
requestParams['sn'] = effectiveSerialNumber;
|
||||
} else {
|
||||
delete requestParams['sn'];
|
||||
}
|
||||
|
||||
if (typeof requestParams['metrics'] === 'string') {
|
||||
try {
|
||||
const parsedMetrics = JSON.parse(requestParams['metrics']);
|
||||
const nextMetrics = { ...(parsedMetrics ?? {}) };
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
nextMetrics.sn = effectiveSerialNumber;
|
||||
} else {
|
||||
delete nextMetrics.sn;
|
||||
}
|
||||
|
||||
requestParams['metrics'] = JSON.stringify(nextMetrics);
|
||||
} catch {
|
||||
// Keep original metrics payload when malformed.
|
||||
}
|
||||
}
|
||||
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
function buildCookieString(
|
||||
macAddress: string,
|
||||
effectiveSerialNumber: string | undefined
|
||||
): string {
|
||||
const cookieParts = [
|
||||
`mac=${macAddress}`,
|
||||
'stb_lang=en_US@rg=dezzzz',
|
||||
'timezone=Europe/Berlin',
|
||||
];
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
cookieParts.push(
|
||||
`__cfduid=${buildStalkerSerialCfduid(effectiveSerialNumber)}`
|
||||
);
|
||||
}
|
||||
|
||||
return cookieParts.join('; ');
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { buildStalkerRequestUrl } from './stalker-request-url.util';
|
||||
|
||||
const PORTAL = 'http://portal.example/stalker_portal/server/load.php';
|
||||
|
||||
/** Decode a query value the way PHP's `$_GET` would (one form-urldecode). */
|
||||
function portalVisibleValue(encoded: string): string {
|
||||
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
|
||||
}
|
||||
|
||||
function cmdWireValue(fullUrl: string): string {
|
||||
const match = /[?&]cmd=([^&]*)/.exec(fullUrl);
|
||||
if (!match) {
|
||||
throw new Error(`no cmd param in ${fullUrl}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
describe('buildStalkerRequestUrl', () => {
|
||||
it('builds the reference wire format for a typical create_link request', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
type: 'itv',
|
||||
action: 'create_link',
|
||||
cmd: 'ffrt3 http://host/ch/123',
|
||||
});
|
||||
|
||||
expect(fullUrl).toBe(
|
||||
'http://portal.example/stalker_portal/server/load.php' +
|
||||
'?type=itv&action=create_link' +
|
||||
'&cmd=ffrt3%20http://host/ch/123&JsHttpRequest=1-xml'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ffrt3 http://host/ch/123', 'ffrt3%20http://host/ch/123'],
|
||||
[
|
||||
'auto http://host/ch/123?token=abc',
|
||||
'auto%20http://host/ch/123?token=abc',
|
||||
],
|
||||
['/media/12345.mpg', '/media/12345.mpg'],
|
||||
[
|
||||
'auto http://host/s/a%3Ab%20c.m3u8',
|
||||
'auto%20http://host/s/a%3Ab%20c.m3u8',
|
||||
],
|
||||
])('sends cmd %s as %s', (cmd, expectedWireValue) => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
cmd,
|
||||
});
|
||||
|
||||
expect(cmdWireValue(fullUrl)).toBe(expectedWireValue);
|
||||
});
|
||||
|
||||
it('does not double-encode a cmd that already contains percent sequences', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
cmd: 'auto http://host/s/a%3Ab.m3u8?sig=x%2Fy',
|
||||
});
|
||||
|
||||
expect(fullUrl).not.toContain('%25');
|
||||
// After the portal's single decode, pre-encoded sequences resolve —
|
||||
// exactly what it would receive from a real STB.
|
||||
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(
|
||||
'auto http://host/s/a:b.m3u8?sig=x/y'
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks query-parameter injection through cmd without losing data', () => {
|
||||
const maliciousCmd =
|
||||
'http://host/ch/1?x=1&action=do_evil&mac=00:00:00:00:00:00#frag';
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
type: 'itv',
|
||||
cmd: maliciousCmd,
|
||||
});
|
||||
|
||||
const params = new URL(fullUrl).searchParams;
|
||||
expect(params.getAll('action')).toEqual(['create_link']);
|
||||
expect(params.get('mac')).toBeNull();
|
||||
expect(params.get('x')).toBeNull();
|
||||
expect(fullUrl).not.toContain('#');
|
||||
// The dangerous characters are escaped, not stripped: the portal
|
||||
// still receives the full original cmd string after one decode.
|
||||
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(maliciousCmd);
|
||||
});
|
||||
|
||||
it('keeps full encoding for non-cmd params', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'get_profile',
|
||||
metrics: '{"mac":"00:1A:79:AA:BB:CC"}',
|
||||
});
|
||||
|
||||
expect(fullUrl).toContain(
|
||||
'metrics=%7B%22mac%22%3A%2200%3A1A%3A79%3AAA%3ABB%3ACC%22%7D'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends JsHttpRequest only when missing', () => {
|
||||
const withoutIt = buildStalkerRequestUrl(PORTAL, { action: 'x' });
|
||||
expect(withoutIt.match(/JsHttpRequest/g)).toHaveLength(1);
|
||||
|
||||
const withIt = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'x',
|
||||
JsHttpRequest: '1-xml',
|
||||
});
|
||||
expect(withIt.match(/JsHttpRequest/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops any query string carried by the portal URL itself', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(
|
||||
'http://portal.example/portal.php?stale=1',
|
||||
{ action: 'handshake' }
|
||||
);
|
||||
|
||||
expect(fullUrl).toBe(
|
||||
'http://portal.example/portal.php?action=handshake&JsHttpRequest=1-xml'
|
||||
);
|
||||
});
|
||||
|
||||
it('emits URLs whose bytes survive WHATWG re-parsing (the axios transport)', () => {
|
||||
const cmds = [
|
||||
'ffrt3 http://host/ch/123',
|
||||
'auto http://host/ch/123?token=a%3Ab c',
|
||||
'/media/12345.mpg',
|
||||
'x&y=z#w;v',
|
||||
'auto http://host/канал/1',
|
||||
];
|
||||
|
||||
for (const cmd of cmds) {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
cmd,
|
||||
});
|
||||
expect(new URL(fullUrl).toString()).toBe(fullUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
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. 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:
|
||||
*
|
||||
* - `cmd` uses the minimal reference encoding (`encodeStalkerCmdValue`): a
|
||||
* real MAG sends cmd unencoded and the portal decodes the query exactly
|
||||
* once, so pre-encoded sequences (`%3A`) must pass through untouched while
|
||||
* `&`/`#`/`;` are still escaped so a malicious portal cannot append query
|
||||
* parameters through cmd.
|
||||
* - every other param is fully `encodeURIComponent`-encoded.
|
||||
*
|
||||
* Any query string on the portal URL itself is intentionally dropped (the
|
||||
* request params are the complete query), matching long-standing behavior.
|
||||
*/
|
||||
export function buildStalkerRequestUrl(
|
||||
url: string,
|
||||
requestParams: Record<string, string | number>
|
||||
): string {
|
||||
const urlObject = new URL(url);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(requestParams).forEach(([key, value]) => {
|
||||
if (key === 'cmd') {
|
||||
queryParts.push(`${key}=${encodeStalkerCmdValue(String(value))}`);
|
||||
} else {
|
||||
queryParts.push(`${key}=${encodeURIComponent(String(value))}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Always add JsHttpRequest parameter if not present (required by Stalker API)
|
||||
if (!requestParams['JsHttpRequest']) {
|
||||
queryParts.push('JsHttpRequest=1-xml');
|
||||
}
|
||||
|
||||
return `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { isStalkerStreamCredentialSafe } from './stalker-stream-profile.util';
|
||||
|
||||
describe('isStalkerStreamCredentialSafe', () => {
|
||||
const PORTAL = 'http://portal.example/stalker_portal/c/';
|
||||
|
||||
it('accepts a stream on the exact portal origin', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a same-host stream on a different port', () => {
|
||||
// The #1158 class: panels routinely serve streams from :8080 next to
|
||||
// the portal on :80, and those streams are gated on the portal cookie.
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://portal.example:8080/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a same-host scheme upgrade (http portal → https stream)', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'https://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an https→http downgrade on the same host', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
'https://portal.example/stalker_portal/c/',
|
||||
'http://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a different host', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://cdn.other.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('compares hostnames case-insensitively', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://PORTAL.example:8080/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-HTTP(S) stream schemes', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(PORTAL, 'rtsp://portal.example/ch1')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on missing or unparseable URLs', () => {
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(undefined, PORTAL)).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, 'not a url')).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe('not a url', PORTAL)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Decides whether a resolved Stalker stream URL may carry the portal's
|
||||
* credentials (mac cookie, Bearer token, serial-number headers).
|
||||
*
|
||||
* Both the renderer playback-header builder and the Electron main-process
|
||||
* playback-context fallback classify streams with this single predicate; if
|
||||
* the two ever disagreed, `isStalkerDirectStreamProfile` in the external
|
||||
* player path would silently discard the caller's credentialed headers.
|
||||
*
|
||||
* The rule mirrors the transport-security carve-out of the validated
|
||||
* redirect layer (docs/architecture/electron-security.md): IPTV portals
|
||||
* routinely hand out stream URLs on the same host but a different port or an
|
||||
* upgraded scheme, and losing the session cookie/token there breaks playback
|
||||
* outright (#1158, #849, #910). A different host would hand provider
|
||||
* credentials to a third party, and an https→http downgrade would replay a
|
||||
* TLS-obtained session in cleartext — both stay credential-free.
|
||||
*/
|
||||
export function isStalkerStreamCredentialSafe(
|
||||
portalUrl: string | undefined | null,
|
||||
streamUrl: string | undefined | null
|
||||
): boolean {
|
||||
if (!portalUrl || !streamUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let portal: URL;
|
||||
let stream: URL;
|
||||
try {
|
||||
portal = new URL(portalUrl);
|
||||
stream = new URL(streamUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stream.protocol !== 'http:' && stream.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(portal.protocol === 'https:' && stream.protocol === 'http:');
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
extractXtreamCredentialsFromUrl,
|
||||
normalizeXtreamServerUrl,
|
||||
resolveXtreamPortalExpiration,
|
||||
resolveXtreamPortalStatus,
|
||||
} from './xtream-portal.utils';
|
||||
|
||||
@@ -104,4 +105,39 @@ describe('xtream portal utilities', () => {
|
||||
).toBe('expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveXtreamPortalExpiration', () => {
|
||||
it('parses numeric-string exp_date into unix seconds', () => {
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: '1790000000' },
|
||||
})
|
||||
).toBe(1_790_000_000);
|
||||
});
|
||||
|
||||
it('treats unlimited and absent expirations as null', () => {
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: '0' },
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: '' },
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({ user_info: {} })
|
||||
).toBeNull();
|
||||
expect(resolveXtreamPortalExpiration(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-numeric exp_date values', () => {
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: 'never' },
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,17 @@ export function resolveXtreamPortalStatus(
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the account expiration from an Xtream `get_account_info`-style
|
||||
* response as unix seconds. Returns null when the portal doesn't report one
|
||||
* (unlimited accounts answer with empty/zero/absent `exp_date`).
|
||||
*/
|
||||
export function resolveXtreamPortalExpiration(
|
||||
response: XtreamPortalStatusResponseLike | null | undefined
|
||||
): number | null {
|
||||
return parseXtreamExpiration(response?.user_info?.exp_date);
|
||||
}
|
||||
|
||||
function normalizeAuthValue(
|
||||
value: boolean | number | string | null | undefined
|
||||
): boolean | null {
|
||||
|
||||
Reference in new issue
Block a user