From 580a056035f91969392b4af55b96bc5bb53b202a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 10:37:27 +0200 Subject: [PATCH] feat(stalker): bind secret playback contexts in main --- .../stalker-playback-context.service.spec.ts | 187 +++++++++ .../stalker-playback-context.service.ts | 386 ++++++++++++++---- .../stalker-auth-session.spec.ts | 96 +++++ .../stalker-session/stalker-auth-session.ts | 68 +++ 4 files changed, 656 insertions(+), 81 deletions(-) diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts index c2e252773..29edf9ace 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts @@ -2,6 +2,7 @@ import { LEGACY_DEFAULT_STALKER_SERIAL } from '@iptvnator/shared/interfaces'; import { getStalkerPlaybackContextHeaders, rememberStalkerPlaybackContext, + StalkerPlaybackContextService, } from './stalker-playback-context.service'; describe('stalker playback context', () => { @@ -52,4 +53,190 @@ describe('stalker playback context', () => { expect(headers).not.toHaveProperty('SN'); expect(headers['Cookie']).not.toContain('__cfduid='); }); + + it('does not reuse a legacy context for another query or origin path', () => { + const origin = 'http://legacy-context.example.test'; + rememberStalkerPlaybackContext({ + streamUrl: `${origin}/stream/1.ts?ticket=one`, + portalUrl: `${origin}/stalker_portal/server/load.php`, + macAddress, + token: 'token-1', + }); + + expect( + getStalkerPlaybackContextHeaders(`${origin}/stream/1.ts?ticket=two`) + ).toBeNull(); + expect( + getStalkerPlaybackContextHeaders(`${origin}/another.ts`) + ).toBeNull(); + }); +}); + +describe('opaque Stalker playback contexts', () => { + let now: number; + let nextRef: number; + let service: StalkerPlaybackContextService; + + beforeEach(() => { + now = 1_000; + nextRef = 0; + service = new StalkerPlaybackContextService({ + createRef: () => `opaque-ref-${++nextRef}`, + now: () => now, + }); + }); + + function register( + overrides: Partial< + Parameters[0] + > = {} + ): string { + return service.register({ + authGeneration: 4, + coordinatorEpoch: 7, + headers: { + Authorization: 'Bearer synthetic-token', + Cookie: 'mac=02:00:00:00:00:01; session=synthetic', + }, + leaseRef: 'lease-a', + senderId: 10, + sessionKey: 'session-a', + streamUrl: 'https://media.example.test/live.ts?ticket=one#ignored', + ...overrides, + }); + } + + it('binds a single-use context to sender and exact normalized stream URL', () => { + const contextRef = register(); + + expect( + service.consume({ + contextRef, + senderId: 11, + streamUrl: 'https://media.example.test/live.ts?ticket=one', + }) + ).toBeNull(); + expect( + service.consume({ + contextRef, + senderId: 10, + streamUrl: 'https://media.example.test/live.ts?ticket=other', + }) + ).toBeNull(); + + expect( + service.consume({ + contextRef, + senderId: 10, + streamUrl: 'https://media.example.test/live.ts?ticket=one', + }) + ).toEqual({ + Authorization: 'Bearer synthetic-token', + Cookie: 'mac=02:00:00:00:00:01; session=synthetic', + }); + expect( + service.consume({ + contextRef, + senderId: 10, + streamUrl: 'https://media.example.test/live.ts?ticket=one', + }) + ).toBeNull(); + }); + + it('expires contexts after exactly two minutes', () => { + const contextRef = register(); + now += 120_000; + + expect( + service.consume({ + contextRef, + senderId: 10, + streamUrl: 'https://media.example.test/live.ts?ticket=one', + }) + ).toBeNull(); + expect(service.size).toBe(0); + }); + + it('invalidates by lease, session, auth generation, coordinator epoch, and sender', () => { + const leaseRef = register(); + service.invalidateLease('lease-a'); + expect(service.has(leaseRef)).toBe(false); + + const sessionRef = register({ leaseRef: 'lease-b' }); + service.invalidateSession('session-a'); + expect(service.has(sessionRef)).toBe(false); + + const generationRef = register({ + authGeneration: 5, + sessionKey: 'session-b', + }); + service.invalidateAuthGeneration('session-b', 5); + expect(service.has(generationRef)).toBe(false); + + const epochRef = register({ + coordinatorEpoch: 8, + sessionKey: 'session-c', + }); + service.invalidateCoordinatorEpoch('session-c', 8); + expect(service.has(epochRef)).toBe(false); + + const senderRef = register({ senderId: 12, sessionKey: 'session-d' }); + service.cleanupSender(12); + expect(service.has(senderRef)).toBe(false); + }); + + it('invalidates an old generation without deleting the replacement generation', () => { + const oldRef = register({ + authGeneration: 1, + sessionKey: 'session-shared', + }); + const newRef = register({ + authGeneration: 2, + sessionKey: 'session-shared', + streamUrl: 'https://media.example.test/new.ts', + }); + + service.invalidateAuthGeneration('session-shared', 1); + + expect(service.has(oldRef)).toBe(false); + expect(service.has(newRef)).toBe(true); + }); + + it('copies headers into and out of the private registry', () => { + const headers = { Authorization: 'Bearer original' }; + const contextRef = register({ headers }); + headers.Authorization = 'Bearer mutated'; + + const resolved = service.consume({ + contextRef, + senderId: 10, + streamUrl: 'https://media.example.test/live.ts?ticket=one', + }); + expect(resolved).toEqual({ Authorization: 'Bearer original' }); + if (resolved) { + resolved.Authorization = 'Bearer returned-mutation'; + } + expect(JSON.stringify({ contextRef })).not.toContain('original'); + }); + + it('rejects invalid URLs, headers, refs, and binding metadata', () => { + expect(() => register({ streamUrl: 'file:///tmp/video' })).toThrow( + 'invalid-playback-context' + ); + expect(() => + register({ headers: { Cookie: 'session=one\r\nInjected: yes' } }) + ).toThrow('invalid-playback-context'); + expect(() => register({ authGeneration: -1 })).toThrow( + 'invalid-playback-context' + ); + expect(() => register({ coordinatorEpoch: -1 })).toThrow( + 'invalid-playback-context' + ); + expect(() => register({ senderId: -1 })).toThrow( + 'invalid-playback-context' + ); + expect(() => register({ leaseRef: '' })).toThrow( + 'invalid-playback-context' + ); + }); }); diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts index 1b6b3a1e7..1d42f0dcf 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { buildStalkerSerialCfduid, normalizeStalkerSerialNumber, @@ -7,69 +8,222 @@ 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-'; +const PLAYBACK_CONTEXT_TTL_MS = 2 * 60 * 1000; +const MAX_CONTEXTS = 4096; +const MAX_REF_LENGTH = 512; +const MAX_BINDING_LENGTH = 1024; +const MAX_URL_LENGTH = 16 * 1024; +const MAX_HEADERS = 64; +const MAX_HEADER_NAME_LENGTH = 256; +const MAX_HEADER_VALUE_LENGTH = 16 * 1024; -const CONTEXT_TTL_MS = 15 * 60 * 1000; - -type PlaybackContext = { - headers: Record; - createdAt: number; -}; - -const playbackContextByStream = new Map(); -const playbackContextByOrigin = new Map(); - -function normalizeStreamUrl(streamUrl: string): string { - const normalized = String(streamUrl ?? '').trim(); - if (!normalized) return ''; - - const spaceIndex = normalized.indexOf(' '); - const maybeWrapped = - spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized; - - try { - const parsed = new URL(maybeWrapped); - return `${parsed.origin}${parsed.pathname}`; - } catch { - return maybeWrapped.split('?')[0]; - } +export interface StalkerPlaybackContextRegistration { + readonly authGeneration: number; + readonly coordinatorEpoch: number; + readonly headers: Readonly>; + readonly leaseRef: string; + readonly senderId: number; + readonly sessionKey: string; + readonly streamUrl: string; } -function getOrigin(streamUrl: string): string { - const normalized = String(streamUrl ?? '').trim(); - if (!normalized) return ''; - const spaceIndex = normalized.indexOf(' '); - const maybeWrapped = - spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized; - try { - return new URL(maybeWrapped).origin; - } catch { - return ''; - } +export interface StalkerPlaybackContextConsumption { + readonly contextRef: string; + readonly senderId: number; + readonly streamUrl: string; } -function removeExpiredContextEntries() { - const now = Date.now(); - for (const [key, value] of playbackContextByStream.entries()) { - if (now - value.createdAt > CONTEXT_TTL_MS) { - playbackContextByStream.delete(key); +export interface StalkerPlaybackContextServiceOptions { + readonly createRef?: () => string; + readonly now?: () => number; +} + +interface OpaquePlaybackContext { + readonly authGeneration: number; + readonly coordinatorEpoch: number; + readonly expiresAt: number; + readonly headers: Readonly>; + readonly leaseRef: string; + readonly senderId: number; + readonly sessionKey: string; + readonly streamUrl: string; +} + +/** + * Main-process-only, sender-bound registry for full-session playback. + * + * The renderer receives only the opaque key. Cookie and authorization values + * stay in this registry and are released once to an exact player request. + */ +export class StalkerPlaybackContextService { + readonly #contexts = new Map(); + readonly #createRef: () => string; + readonly #now: () => number; + + constructor(options: StalkerPlaybackContextServiceOptions = {}) { + this.#createRef = + options.createRef ?? (() => randomBytes(32).toString('base64url')); + this.#now = options.now ?? Date.now; + } + + get size(): number { + this.#pruneExpired(); + return this.#contexts.size; + } + + register(input: StalkerPlaybackContextRegistration): string { + this.#pruneExpired(); + assertSafeInteger(input.senderId); + assertSafeInteger(input.authGeneration); + assertSafeInteger(input.coordinatorEpoch); + assertBinding(input.leaseRef); + assertBinding(input.sessionKey); + const streamUrl = normalizeExactStreamUrl(input.streamUrl); + const headers = copyHeaders(input.headers); + + if (this.#contexts.size >= MAX_CONTEXTS) { + throw invalidContext(); + } + + let contextRef = ''; + for (let attempt = 0; attempt < 4; attempt += 1) { + const candidate = this.#createRef(); + assertBinding(candidate, MAX_REF_LENGTH); + if (!this.#contexts.has(candidate)) { + contextRef = candidate; + break; + } + } + if (!contextRef) { + throw invalidContext(); + } + + this.#contexts.set(contextRef, { + authGeneration: input.authGeneration, + coordinatorEpoch: input.coordinatorEpoch, + expiresAt: this.#now() + PLAYBACK_CONTEXT_TTL_MS, + headers, + leaseRef: input.leaseRef, + senderId: input.senderId, + sessionKey: input.sessionKey, + streamUrl, + }); + return contextRef; + } + + consume( + input: StalkerPlaybackContextConsumption + ): Record | null { + this.#pruneExpired(); + if ( + !isSafeInteger(input.senderId) || + !isBinding(input.contextRef, MAX_REF_LENGTH) + ) { + return null; + } + const context = this.#contexts.get(input.contextRef); + if (!context || context.senderId !== input.senderId) { + return null; + } + + let streamUrl: string; + try { + streamUrl = normalizeExactStreamUrl(input.streamUrl); + } catch { + return null; + } + if (context.streamUrl !== streamUrl) { + return null; + } + + this.#contexts.delete(input.contextRef); + return { ...context.headers }; + } + + has(contextRef: string): boolean { + this.#pruneExpired(); + return this.#contexts.has(contextRef); + } + + invalidateLease(leaseRef: string): void { + this.#deleteWhere((context) => context.leaseRef === leaseRef); + } + + invalidateSession(sessionKey: string): void { + this.#deleteWhere((context) => context.sessionKey === sessionKey); + } + + invalidateAuthGeneration(sessionKey: string, authGeneration: number): void { + this.#deleteWhere( + (context) => + context.sessionKey === sessionKey && + context.authGeneration === authGeneration + ); + } + + invalidateCoordinatorEpoch( + sessionKey: string, + coordinatorEpoch: number + ): void { + this.#deleteWhere( + (context) => + context.sessionKey === sessionKey && + context.coordinatorEpoch === coordinatorEpoch + ); + } + + cleanupSender(senderId: number): void { + this.#deleteWhere((context) => context.senderId === senderId); + } + + clear(): void { + this.#contexts.clear(); + } + + #deleteWhere(predicate: (context: OpaquePlaybackContext) => boolean): void { + for (const [contextRef, context] of this.#contexts) { + if (predicate(context)) { + this.#contexts.delete(contextRef); + } } } - for (const [key, value] of playbackContextByOrigin.entries()) { - if (now - value.createdAt > CONTEXT_TTL_MS) { - playbackContextByOrigin.delete(key); - } + + #pruneExpired(): void { + const now = this.#now(); + this.#deleteWhere((context) => context.expiresAt <= now); } } +export const stalkerPlaybackContextService = + new StalkerPlaybackContextService(); + +interface LegacyPlaybackContext { + readonly createdAt: number; + readonly headers: Readonly>; +} + +const legacyPlaybackContexts = new Map(); + +/** + * Compatibility bridge for the legacy STALKER_REQUEST path. New full-session + * traffic must use {@link StalkerPlaybackContextService} and an opaque ref. + */ export function rememberStalkerPlaybackContext(input: { streamUrl: string; portalUrl: string; macAddress: string; serialNumber?: string; token?: string; -}) { - const streamKey = normalizeStreamUrl(input.streamUrl); - if (!streamKey || !input.macAddress) return; +}): void { + let streamUrl: string; + try { + streamUrl = normalizeExactStreamUrl(input.streamUrl); + } catch { + return; + } + if (!input.macAddress) { + return; + } const serialNumber = normalizeStalkerSerialNumber(input.serialNumber); const cookieParts = [ @@ -77,33 +231,26 @@ export function rememberStalkerPlaybackContext(input: { 'stb_lang=en_US@rg=dezzzz', 'timezone=Europe/Berlin', ]; - if (serialNumber) { cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`); } - const streamOrigin = getOrigin(input.streamUrl) || undefined; - + const streamOrigin = new URL(streamUrl).origin; let portalOrigin: string | undefined; try { portalOrigin = new URL(input.portalUrl).origin; } catch { portalOrigin = undefined; } - const crossOriginStream = - Boolean(streamOrigin) && - Boolean(portalOrigin) && - streamOrigin !== portalOrigin; - + portalOrigin !== undefined && streamOrigin !== portalOrigin; const headers: Record = crossOriginStream ? { - // Align with known working clients for direct tokenized stream URLs. - 'User-Agent': STALKER_STREAM_USER_AGENT, Accept: '*/*', - Range: STALKER_STREAM_RANGE_HEADER, - 'Icy-MetaData': '1', Connection: 'keep-alive', + 'Icy-MetaData': '1', + Range: STALKER_STREAM_RANGE_HEADER, + 'User-Agent': STALKER_STREAM_USER_AGENT, } : { Cookie: cookieParts.join('; '), @@ -115,42 +262,119 @@ export function rememberStalkerPlaybackContext(input: { headers['Origin'] = portalOrigin; headers['Referer'] = portalOrigin; } - if (!crossOriginStream && serialNumber) { headers['SN'] = serialNumber; } - if (!crossOriginStream && input.token) { headers['Authorization'] = `Bearer ${input.token}`; } - playbackContextByStream.set(streamKey, { - headers, + legacyPlaybackContexts.set(streamUrl, { createdAt: Date.now(), + headers: Object.freeze({ ...headers }), }); - if (streamOrigin) { - playbackContextByOrigin.set(streamOrigin, { - headers, - createdAt: Date.now(), - }); - } - removeExpiredContextEntries(); + pruneLegacyContexts(); } export function getStalkerPlaybackContextHeaders( streamUrl: string ): Record | null { - removeExpiredContextEntries(); - const streamKey = normalizeStreamUrl(streamUrl); - if (streamKey) { - const entry = playbackContextByStream.get(streamKey); - if (entry) { - return { ...entry.headers }; + pruneLegacyContexts(); + let key: string; + try { + key = normalizeExactStreamUrl(streamUrl); + } catch { + return null; + } + const context = legacyPlaybackContexts.get(key); + return context ? { ...context.headers } : null; +} + +function normalizeExactStreamUrl(streamUrl: string): string { + const trimmed = String(streamUrl ?? '').trim(); + if (!trimmed || trimmed.length > MAX_URL_LENGTH) { + throw invalidContext(); + } + const separator = trimmed.indexOf(' '); + const unwrapped = + separator > 0 ? trimmed.slice(separator + 1).trim() : trimmed; + const url = new URL(unwrapped); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username !== '' || + url.password !== '' + ) { + throw invalidContext(); + } + url.hash = ''; + return url.toString(); +} + +function copyHeaders( + headers: Readonly> +): Readonly> { + const entries = Object.entries(headers); + if (entries.length > MAX_HEADERS) { + throw invalidContext(); + } + const result: Record = {}; + for (const [name, value] of entries) { + if ( + !isHeaderName(name) || + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_HEADER_VALUE_LENGTH || + /[\r\n\0]/u.test(value) + ) { + throw invalidContext(); + } + result[name] = value; + } + return Object.freeze(result); +} + +function isHeaderName(value: string): boolean { + return ( + value.length > 0 && + value.length <= MAX_HEADER_NAME_LENGTH && + /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value) + ); +} + +function assertSafeInteger(value: number): void { + if (!isSafeInteger(value)) { + throw invalidContext(); + } +} + +function isSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +function assertBinding(value: string, maximum = MAX_BINDING_LENGTH): void { + if (!isBinding(value, maximum)) { + throw invalidContext(); + } +} + +function isBinding(value: string, maximum: number): boolean { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maximum && + !/[\0\r\n]/u.test(value) + ); +} + +function pruneLegacyContexts(): void { + const now = Date.now(); + for (const [streamUrl, context] of legacyPlaybackContexts) { + if (now - context.createdAt > PLAYBACK_CONTEXT_TTL_MS) { + legacyPlaybackContexts.delete(streamUrl); } } - - const origin = getOrigin(streamUrl); - if (!origin) return null; - const originEntry = playbackContextByOrigin.get(origin); - return originEntry ? { ...originEntry.headers } : null; +} + +function invalidContext(): Error { + return new Error('invalid-playback-context'); } diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts index 93bc4c1c2..72f64b685 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.spec.ts @@ -311,4 +311,100 @@ describe('StalkerAuthSession', () => { expect(JSON.stringify(outcome)).not.toContain('token-secret'); expect(JSON.stringify(outcome)).not.toContain('private-password'); }); + + it('prepares exact same-origin playback headers from the private token and live cookie jar', async () => { + const outcome = resolved({ + kind: 'ready', + profile: { login: 'player-user', status: 0 }, + status: 0, + }); + await outcome.cookieJar.collectResponseCookies( + outcome.endpoint, + 'session=rotated; Path=/; HttpOnly' + ); + const auth = new StalkerAuthSession(outcome, transport, { + createHttpSession: () => ({ + request: async () => { + throw new Error('No request expected'); + }, + }), + }); + await auth.start(); + + const playback = await auth.preparePlayback( + '/stream/live.ts?ticket=synthetic' + ); + + expect(playback.streamUrl).toBe( + 'https://portal.test/stream/live.ts?ticket=synthetic' + ); + expect(playback.headers).toMatchObject({ + Authorization: 'Bearer token-secret', + Cookie: expect.stringContaining('session=rotated'), + Referer: 'https://portal.test/c/', + 'User-Agent': expect.stringContaining('MAG250'), + 'X-User-Agent': 'Model: MAG250', + }); + expect(playback.headers.Cookie).toContain('mac=00:1A:79:AA:BB:CC'); + expect(playback.headers).not.toHaveProperty('SN'); + }); + + it('uses a secret-free direct-stream profile for cross-origin playback', async () => { + const { auth } = harness( + { + kind: 'ready', + profile: { login: 'player-user', status: 0 }, + status: 0, + }, + () => { + throw new Error('No request expected'); + } + ); + await auth.start(); + + await expect( + auth.preparePlayback( + 'https://media.example.test/live.ts?ticket=synthetic' + ) + ).resolves.toEqual({ + headers: { + Accept: '*/*', + Connection: 'keep-alive', + 'Icy-MetaData': '1', + Range: 'bytes=0-', + 'User-Agent': 'KSPlayer', + }, + streamUrl: 'https://media.example.test/live.ts?ticket=synthetic', + }); + }); + + it('rejects playback preparation before ready and for unsafe URL schemes', async () => { + const { auth: pendingAuth } = harness( + { + kind: 'credentials-required', + profile: { status: 2 }, + status: 2, + }, + () => success({ js: false }) + ); + + await expect( + pendingAuth.preparePlayback('https://portal.test/live.ts') + ).rejects.toThrow('stalker-playback-not-ready'); + + const { auth: readyAuth } = harness( + { + kind: 'ready', + profile: { login: 'player-user', status: 0 }, + status: 0, + }, + () => { + throw new Error('No request expected'); + } + ); + await readyAuth.start(); + await expect( + readyAuth.preparePlayback('file:///tmp/live.ts') + ).rejects.toThrow('stalker-playback-invalid-url'); + }); }); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts index b94495559..799a14f51 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-auth-session.ts @@ -23,9 +23,11 @@ import type { StalkerTransportConfig, StalkerTransportResult, } from './stalker-session.types'; +import type { StalkerCookieJar } from './stalker-cookie-jar'; const USERNAME_MAX_BYTES = 512; const PASSWORD_MAX_BYTES = 2048; +const STALKER_STREAM_USER_AGENT = 'KSPlayer'; interface StalkerHttpSessionLike { request(request: StalkerHttpRequest): Promise; @@ -38,6 +40,11 @@ export interface StalkerAuthSessionDependencies { ) => StalkerHttpSessionLike; } +export interface StalkerPreparedPlayback { + readonly headers: Readonly>; + readonly streamUrl: string; +} + export interface StalkerAuthCredentials { password: string; username: string; @@ -85,6 +92,7 @@ export type StalkerAuthenticatedRequestOutcome = type AuthState = 'new' | 'awaiting-credentials' | 'ready' | 'failed'; export class StalkerAuthSession { + readonly #cookieJar: StalkerCookieJar; readonly #endpoint: string; readonly #http: StalkerHttpSessionLike; readonly #identity: StalkerIdentityProfile; @@ -101,6 +109,7 @@ export class StalkerAuthSession { transport: StalkerTransportConfig, dependencies: StalkerAuthSessionDependencies = {} ) { + this.#cookieJar = resolved.cookieJar; this.#endpoint = resolved.endpoint; this.#identity = resolved.identity; this.#profile = resolved.profile.profile; @@ -280,6 +289,36 @@ export class StalkerAuthSession { return this.#acceptedCredentials !== undefined; } + async preparePlayback(streamUrl: string): Promise { + if (this.#state !== 'ready') { + throw new Error('stalker-playback-not-ready'); + } + + const normalizedUrl = normalizePlaybackUrl(streamUrl, this.#endpoint); + if (new URL(normalizedUrl).origin !== new URL(this.#endpoint).origin) { + return { + headers: { + Accept: '*/*', + Connection: 'keep-alive', + 'Icy-MetaData': '1', + Range: 'bytes=0-', + 'User-Agent': STALKER_STREAM_USER_AGENT, + }, + streamUrl: normalizedUrl, + }; + } + + const cookie = await this.#cookieJar.getCookieHeader(normalizedUrl); + return { + headers: { + ...this.#identity.headers, + Authorization: `Bearer ${this.#token}`, + ...(cookie ? { Cookie: cookie } : {}), + }, + streamUrl: normalizedUrl, + }; + } + async request( parameters: Readonly> ): Promise { @@ -360,6 +399,35 @@ export class StalkerAuthSession { } } +function normalizePlaybackUrl(value: string, endpoint: string): string { + if ( + typeof value !== 'string' || + value.trim() === '' || + value.length > 16 * 1024 + ) { + throw new Error('stalker-playback-invalid-url'); + } + const trimmed = value.trim(); + const separator = trimmed.indexOf(' '); + const command = + separator > 0 ? trimmed.slice(separator + 1).trim() : trimmed; + let url: URL; + try { + url = new URL(command, endpoint); + } catch { + throw new Error('stalker-playback-invalid-url'); + } + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username !== '' || + url.password !== '' + ) { + throw new Error('stalker-playback-invalid-url'); + } + url.hash = ''; + return url.toString(); +} + function normalizeResponse( outcome: StalkerHttpRequestOutcome, stage: StalkerSessionStage,