mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(playback): forward portal Cookie/Authorization to built-in players (#1335)
* feat(playback): forward portal Cookie/Authorization to built-in players The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal session cookie or Bearer token played exclusively in external MPV/VLC — the long-running "only VLC works" cluster (#849, #910, #732). - request-header-overrides.service: the scoped override now carries Cookie and Authorization, attached only to requests on the exact stream origin, in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls drop credentials fail-closed; control characters in header values are rejected. Chosen over session.cookies.set(): jar cookies attach only to credentialed requests, which would force withCredentials into every engine and break against the Access-Control-Allow-Origin:* IPTV panels send, and jar scoping is port-blind. - WebPlayerViewComponent is now the single owner of the scoped override for every built-in player: it extracts the full header set from the resolved playback, configures the override BEFORE handing the source over (players render only once the source exists), and clears the scoped layer on destroy. HtmlVideoPlayerComponent's own three-header call is removed — it would overwrite the credentialed override. - Stalker VOD, series episodes and radio now build the same portal header set ITV already had (they previously carried no portal headers at all); same-origin playback sends the real User-Agent alongside X-User-Agent. - Stream classification is host-based via one shared predicate (isStalkerStreamCredentialSafe): same-host port changes and scheme upgrades keep the portal profile (the #1158 class), a foreign host or https->http downgrade keeps the credential-free KSPlayer profile. The main-process fallback context uses the same predicate so isStalkerDirectStreamProfile can no longer discard renderer headers. - setUserAgent bridge gains an optional credentials parameter; preload, ipcMain handler and ElectronBridgeApi updated together. - stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose create_link returns a local /stream/gated/video.mp4 that 403s without the mac cookie + current Bearer token; new Electron e2e proves a built-in player actually plays it (and that the gate refuses bare requests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): apply header override to Stalker radio, redact mock cookie log Address Codex review feedback on #1335: - The radio branch of the Stalker live layout renders the dedicated audio player, never WebPlayerViewComponent, so the resolved portal headers were built but never applied — an auth-gated radio stream still 403'd. The override sync is extracted into ElectronStreamHeadersService (single owner of the scoped override slot, with clear-only-while-owning semantics so a destroyed consumer cannot wipe a newer consumer's override), applied by WebPlayerViewComponent for video players and by the radio branch before the audio element gets its URL. The service feature-detects the bridge method so partial bridges behave like the PWA instead of throwing. - The gated-stream mock no longer logs the raw Cookie header on 403 — presence only, matching the Authorization logging. - The gated scenario now serves an audio fixture for radio create_link and the Electron e2e covers the radio path end-to-end (bare request 403s, built-in audio player advances past the gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): claim radio header ownership before awaiting the IPC Codex round-2 P2: leaving the radio route while the header IPC was still in flight left the portal cookie/token installed — ngOnDestroy saw a null scope URL (it was recorded only after the await) and could not clear the override. Ownership is now claimed synchronously before awaiting, destroy invalidates the pending playback continuation, and the apply's stillCurrent verdict is honored. Regression test covers destroy-during-pending-IPC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): carry portal headers into collection playback Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed resolved through StreamResolverService.resolveStalker(), which returned no portal headers — the video path handed the header owner an empty set and collection radio bypassed it entirely, so auth-gated streams still 403'd from collections. - resolveStalker() now builds the same profile as the live layout via the shared classifier: portal-owned streams get mac cookie/Bearer token/MAG UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer profile (both create_link results and direct radio URLs). - UnifiedLiveTabComponent applies the scoped override for radio before the audio element gets its URL (ownership claimed before awaiting the IPC, round-2 lesson), and clears it on close and destroy. - Regression tests: resolver header profiles for portal-host and foreign streams; unified tab radio apply-then-clear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): release the radio override when a new selection mounts no player Codex round-4 P2: after radio installed its credentials, selecting an item that never mounts a player surface (external video playback, failed resolution) left the old Cookie/Authorization installed — no WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both radio hosts (unified collection tab and the Stalker live layout, which has the identical hole) now release the previously owned radio scope at the start of every new selection; the slot-ownership semantics keep this a no-op when another playback already owns the override. Regression test in the live-layout spec pins the failed-selection path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(playback): state the exact override release points Codex round-5 P2 flagged that the media 'ended' event does not clear the scoped override while the player stays mounted. That is deliberate, not a gap: a mounted player still owns the session — replay or a seek into an unbuffered range must keep working against a gated stream, and clearing on 'ended' would 403 exactly the streams this PR fixes. The credentials only ever travel to the exact origin that issued them, and every dismount path (channel/source change, player close/destroy, radio close, playerless selection) releases them. The security doc and the release note now say precisely that instead of the ambiguous "cleared when playback ends". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(playback): fit the release note back under the 400-character cap 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
6c065124ed
commit
fc7f23b229
36 files changed
+2048
-152
No files matched your search
@@ -0,0 +1,12 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
issues: [849, 910, 732]
|
||||
---
|
||||
|
||||
Stalker streams that require the portal session now play in the built-in
|
||||
players (HTML5, Video.js, ArtPlayer), not only in VLC/MPV: the player's
|
||||
requests carry the portal cookie and token, scoped to that stream and
|
||||
dropped when the player closes or the channel changes. VOD, series and
|
||||
radio get the same headers live TV had — also from Favorites and Recently
|
||||
Viewed.
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
addStalkerPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
stalkerMockServer,
|
||||
test,
|
||||
waitForStalkerCatalog,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* End-to-end proof that a BUILT-IN player's media requests carry the portal
|
||||
* credentials (mac cookie + Bearer token) — the root of the long-running
|
||||
* "only VLC works" cluster (#849, #910, #732): the web players used to
|
||||
* receive only User-Agent/Referer/Origin, so any stream gated on the portal
|
||||
* session could never play inline.
|
||||
*
|
||||
* The mock's `gated-stream` scenario makes `create_link` return this
|
||||
* server's own `/stream/gated/video.mp4`, which answers 403 unless the
|
||||
* request presents the mac cookie AND the MAC's current access token. A unit
|
||||
* test cannot show that a header reached the video element; playback
|
||||
* advancing past that gate can only happen when the scoped Electron header
|
||||
* override attached the credentials to the actual media request.
|
||||
*/
|
||||
|
||||
const GATED_MAC = '00:1A:79:00:00:09';
|
||||
const GATED_STREAM_URL = `${stalkerMockServer}/stream/gated/video.mp4`;
|
||||
// The full-portal URL shape: the app handshakes and holds a Bearer token,
|
||||
// which is exactly what the gated stream endpoint demands.
|
||||
const FULL_PORTAL_URL = `${stalkerMockServer}/stalker_portal/server/load.php`;
|
||||
|
||||
test('@electron @stalker built-in player plays an auth-gated portal stream', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
// First prove the gate is real: a credential-less request is refused, so
|
||||
// a green playback assertion below cannot be a permissive-mock artifact.
|
||||
const bareResponse = await request.get(GATED_STREAM_URL);
|
||||
expect(bareResponse.status()).toBe(403);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
macAddress: GATED_MAC,
|
||||
portalUrl: FULL_PORTAL_URL,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
// The portal lands on Movies; live playback lives in the ITV layout.
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: /live|itv/i })
|
||||
.click();
|
||||
await app.mainWindow.waitForURL(/stalker.*itv/);
|
||||
|
||||
// The ITV view renders channels only after a category is selected;
|
||||
// index 0 is the "All channels" pseudo-category.
|
||||
const categories = app.mainWindow.locator('.category-item');
|
||||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||||
await categories.first().click();
|
||||
|
||||
const channels = app.mainWindow.locator(
|
||||
'[data-test-id="channel-item"]'
|
||||
);
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await channels.first().click();
|
||||
|
||||
const video = app.mainWindow
|
||||
.locator('app-web-player-view video')
|
||||
.first();
|
||||
await expect(video).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Advancing playback past the 403 gate is only possible when the
|
||||
// media requests carried the portal cookie and Authorization header.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
video.evaluate(
|
||||
(element: HTMLVideoElement) => element.currentTime
|
||||
),
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('playback-diagnostic-banner')
|
||||
).toBeHidden();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron @stalker built-in audio player plays an auth-gated radio stream', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
// The radio branch renders the dedicated audio player instead of
|
||||
// WebPlayerViewComponent, so it exercises the Stalker live layout's own
|
||||
// header wiring — a gap the ITV test above cannot catch.
|
||||
const bareResponse = await request.get(
|
||||
`${stalkerMockServer}/stream/gated/audio.mp4`
|
||||
);
|
||||
expect(bareResponse.status()).toBe(403);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
macAddress: GATED_MAC,
|
||||
portalUrl: FULL_PORTAL_URL,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await app.mainWindow.getByRole('link', { name: /radio/i }).click();
|
||||
await app.mainWindow.waitForURL(/stalker.*radio/);
|
||||
|
||||
const categories = app.mainWindow.locator('.category-item');
|
||||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||||
await categories.first().click();
|
||||
|
||||
const channels = app.mainWindow.locator(
|
||||
'[data-test-id="channel-item"]'
|
||||
);
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await channels.first().click();
|
||||
|
||||
// The bare <audio> element renders zero-size (its UI is custom), so
|
||||
// assert attachment rather than visibility.
|
||||
const audio = app.mainWindow.locator('app-audio-player audio').first();
|
||||
await expect(audio).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
audio.evaluate(
|
||||
(element: HTMLAudioElement) => element.currentTime
|
||||
),
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -140,7 +140,33 @@ describe('main preload DB IPC contract', () => {
|
||||
'set-user-agent',
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/live.m3u8'
|
||||
'https://stream.example/live.m3u8',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards scoped stream credentials alongside the header override', async () => {
|
||||
const api = getExposedApi();
|
||||
|
||||
await api.setUserAgent(
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/live.m3u8',
|
||||
{
|
||||
authorization: 'Bearer TOKEN',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
|
||||
'set-user-agent',
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/live.m3u8',
|
||||
{
|
||||
authorization: 'Bearer TOKEN',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -450,8 +450,19 @@ const electronApi: ElectronBridgeApi = {
|
||||
setUserAgent: (
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
) => ipcRenderer.invoke('set-user-agent', userAgent, referer, scopeUrl),
|
||||
scopeUrl?: string | null,
|
||||
credentials?: {
|
||||
authorization?: string | null;
|
||||
cookie?: string | null;
|
||||
} | null
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'set-user-agent',
|
||||
userAgent,
|
||||
referer,
|
||||
scopeUrl,
|
||||
credentials
|
||||
),
|
||||
openInMpv: (
|
||||
url: string,
|
||||
title: string,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { configureRequestHeaderOverride } from '../services/request-header-overrides.service';
|
||||
import {
|
||||
configureRequestHeaderOverride,
|
||||
type StreamCredentialHeaders,
|
||||
} from '../services/request-header-overrides.service';
|
||||
|
||||
export default class SharedEvents {
|
||||
static bootstrapSharedEvents(): Electron.IpcMain {
|
||||
@@ -7,21 +10,46 @@ export default class SharedEvents {
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('set-user-agent', (_event, userAgent, referer, scopeUrl) => {
|
||||
setUserAgent(userAgent, referer, scopeUrl);
|
||||
return true;
|
||||
});
|
||||
function sanitizeCredentials(value: unknown): StreamCredentialHeaders | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { cookie, authorization } = value as Record<string, unknown>;
|
||||
return {
|
||||
authorization:
|
||||
typeof authorization === 'string' ? authorization : undefined,
|
||||
cookie: typeof cookie === 'string' ? cookie : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
'set-user-agent',
|
||||
(_event, userAgent, referer, scopeUrl, credentials) => {
|
||||
setUserAgent(
|
||||
userAgent,
|
||||
referer,
|
||||
scopeUrl,
|
||||
sanitizeCredentials(credentials)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets scoped request headers for the currently selected stream.
|
||||
* @param userAgent user agent to use
|
||||
* @param referer referer to use
|
||||
* @param scopeUrl stream URL used to limit the override to the active origin
|
||||
* @param credentials portal Cookie/Authorization for auth-gated streams;
|
||||
* applied only to the exact origin of `scopeUrl` and only while the
|
||||
* scoped override is active
|
||||
*/
|
||||
export function setUserAgent(
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
scopeUrl?: string | null,
|
||||
credentials?: StreamCredentialHeaders | null
|
||||
): void {
|
||||
configureRequestHeaderOverride(userAgent, referer, scopeUrl);
|
||||
configureRequestHeaderOverride(userAgent, referer, scopeUrl, credentials);
|
||||
}
|
||||
@@ -339,3 +339,220 @@ describe('request header overrides', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Portal credentials (Cookie/Authorization) in the scoped override — the
|
||||
* mechanism that lets built-in players play auth-gated portal streams. The
|
||||
* security contract pinned here: credentials apply ONLY to the exact stream
|
||||
* origin, never ride on the broader UA/Referer scope, never enter the
|
||||
* unscoped (playlist-level) layer, and are dropped when the scoped override
|
||||
* is cleared or replaced.
|
||||
*/
|
||||
describe('request header override credentials', () => {
|
||||
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
|
||||
const SEGMENT_URL = 'http://portal.example:8080/live/segment-1.ts';
|
||||
const CREDENTIALS = {
|
||||
authorization: 'Bearer TOKEN123',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
mockOnBeforeSendHeaders.mockClear();
|
||||
});
|
||||
|
||||
it('attaches cookie and authorization to requests on the stream origin', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
|
||||
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
|
||||
expect(headers['User-Agent']).toBe('MAG250');
|
||||
expect(headers['Referer']).toBe('http://portal.example');
|
||||
});
|
||||
|
||||
it('does not attach credentials to the referer origin', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
// The UA/Referer scope includes the referer origin (port 80), but
|
||||
// the credentials belong to the stream origin (:8080) only.
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(
|
||||
listener,
|
||||
'http://portal.example/some/page'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe('MAG250');
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never attaches credentials to a third-party host', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(
|
||||
listener,
|
||||
'http://cdn.other-host.example/seg.ts',
|
||||
{ Accept: '*/*' }
|
||||
);
|
||||
|
||||
expect(headers).toEqual({ Accept: '*/*' });
|
||||
});
|
||||
|
||||
it('ignores credentials passed without a scope URL', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'PlaylistAgent/1.0',
|
||||
undefined,
|
||||
undefined,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['User-Agent']).toBe('PlaylistAgent/1.0');
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops credentials when the next stream replaces the scoped override', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
configureRequestHeaderOverride(
|
||||
'OtherAgent/1.0',
|
||||
'http://other.example',
|
||||
'http://other.example/stream.m3u8'
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the credentialed override when playback ends', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
// The renderer's playback-end clear: empty values with a scope URL.
|
||||
configureRequestHeaderOverride(undefined, undefined, STREAM_URL);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL, {
|
||||
Accept: '*/*',
|
||||
});
|
||||
|
||||
expect(headers).toEqual({ Accept: '*/*' });
|
||||
});
|
||||
|
||||
it('keeps a credentials-only override active without UA or referer', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
undefined,
|
||||
undefined,
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
|
||||
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
|
||||
});
|
||||
|
||||
it('replaces existing credential headers case-insensitively', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL, {
|
||||
authorization: 'Bearer STALE',
|
||||
cookie: 'stale=1',
|
||||
});
|
||||
|
||||
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
|
||||
expect(headers['cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
|
||||
expect(headers['authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects credential values containing control characters', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride('MAG250', undefined, STREAM_URL, {
|
||||
authorization: 'Bearer TOKEN\r\nX-Injected: 1',
|
||||
cookie: 'mac=00\r\nX-Injected: 1',
|
||||
});
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
expect(headers['X-Injected']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { session } from 'electron';
|
||||
|
||||
export type StreamCredentialHeaders = {
|
||||
authorization?: string | null;
|
||||
cookie?: string | null;
|
||||
};
|
||||
|
||||
type HeaderOverride = {
|
||||
authorization?: string;
|
||||
cookie?: string;
|
||||
/**
|
||||
* Origin the credentials belong to — always the stream URL's own origin.
|
||||
* Cookie/Authorization are attached only on an exact match, never on the
|
||||
* broader `scopeOrigins` set that User-Agent/Referer/Origin use.
|
||||
*/
|
||||
credentialOrigin?: string;
|
||||
origin?: string;
|
||||
referer?: string;
|
||||
scopeOrigins?: Set<string>;
|
||||
@@ -29,8 +42,19 @@ let activeScopedHeaderOverride: HeaderOverride | null = null;
|
||||
let listenerRegistered = false;
|
||||
|
||||
function normalizeHeaderValue(value?: string | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
// A control character in a header value is never legitimate and could
|
||||
// otherwise smuggle extra headers into the raw request.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getOrigin(value?: string | null): string | undefined {
|
||||
@@ -116,6 +140,8 @@ function handleBeforeSendHeaders(
|
||||
return;
|
||||
}
|
||||
|
||||
const requestOrigin = getOrigin(details.url);
|
||||
|
||||
for (const override of overrides) {
|
||||
if (override.userAgent) {
|
||||
setRequestHeader(requestHeaders, 'User-Agent', override.userAgent);
|
||||
@@ -128,6 +154,25 @@ function handleBeforeSendHeaders(
|
||||
if (override.origin) {
|
||||
setRequestHeader(requestHeaders, 'Origin', override.origin);
|
||||
}
|
||||
|
||||
// Portal credentials are attached only to requests going to the
|
||||
// stream's own origin — never to a referer-origin sibling and never
|
||||
// to third-party hosts a manifest may point at.
|
||||
const credentialsApply =
|
||||
Boolean(override.credentialOrigin) &&
|
||||
requestOrigin === override.credentialOrigin;
|
||||
|
||||
if (credentialsApply && override.cookie) {
|
||||
setRequestHeader(requestHeaders, 'Cookie', override.cookie);
|
||||
}
|
||||
|
||||
if (credentialsApply && override.authorization) {
|
||||
setRequestHeader(
|
||||
requestHeaders,
|
||||
'Authorization',
|
||||
override.authorization
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
callback({ requestHeaders });
|
||||
@@ -148,13 +193,33 @@ function ensureHeaderOverrideListener(): void {
|
||||
export function configureRequestHeaderOverride(
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
scopeUrl?: string | null,
|
||||
credentials?: StreamCredentialHeaders | null
|
||||
): void {
|
||||
const normalizedUserAgent = normalizeHeaderValue(userAgent);
|
||||
const normalizedReferer = normalizeHeaderValue(referer);
|
||||
const isScopedOverride = scopeUrl !== undefined && scopeUrl !== null;
|
||||
const scopeOrigin = getOrigin(scopeUrl);
|
||||
// Credentials are portal secrets: they require a scoped override whose
|
||||
// stream URL yields a concrete origin to pin them to. Anything else is
|
||||
// dropped rather than applied broadly (fail closed). They live only in
|
||||
// this in-memory override — never in the session cookie jar and never on
|
||||
// disk — so they cannot outlive the app process.
|
||||
const normalizedCookie =
|
||||
isScopedOverride && scopeOrigin
|
||||
? normalizeHeaderValue(credentials?.cookie)
|
||||
: undefined;
|
||||
const normalizedAuthorization =
|
||||
isScopedOverride && scopeOrigin
|
||||
? normalizeHeaderValue(credentials?.authorization)
|
||||
: undefined;
|
||||
|
||||
if (!normalizedUserAgent && !normalizedReferer) {
|
||||
if (
|
||||
!normalizedUserAgent &&
|
||||
!normalizedReferer &&
|
||||
!normalizedCookie &&
|
||||
!normalizedAuthorization
|
||||
) {
|
||||
if (isScopedOverride) {
|
||||
clearScopedRequestHeaderOverride();
|
||||
} else {
|
||||
@@ -164,7 +229,6 @@ export function configureRequestHeaderOverride(
|
||||
}
|
||||
|
||||
const refererOrigin = getOrigin(normalizedReferer);
|
||||
const scopeOrigin = getOrigin(scopeUrl);
|
||||
const override: HeaderOverride = {
|
||||
origin: refererOrigin,
|
||||
referer: normalizedReferer,
|
||||
@@ -177,6 +241,11 @@ export function configureRequestHeaderOverride(
|
||||
Boolean(origin)
|
||||
)
|
||||
);
|
||||
if (normalizedCookie || normalizedAuthorization) {
|
||||
override.credentialOrigin = scopeOrigin;
|
||||
override.cookie = normalizedCookie;
|
||||
override.authorization = normalizedAuthorization;
|
||||
}
|
||||
activeScopedHeaderOverride = override;
|
||||
} else {
|
||||
activeHeaderOverride = override;
|
||||
|
||||
@@ -52,4 +52,58 @@ describe('stalker playback context', () => {
|
||||
expect(headers).not.toHaveProperty('SN');
|
||||
expect(headers['Cookie']).not.toContain('__cfduid=');
|
||||
});
|
||||
|
||||
it('keeps the portal profile for a same-host stream on another port', () => {
|
||||
// Must match the renderer's classification: a same-host stream on a
|
||||
// different port stays portal-owned, or isStalkerDirectStreamProfile
|
||||
// would discard the renderer's credentialed headers for it.
|
||||
const streamUrl = 'http://same-host.example.test:8080/stream/1.ts';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'http://same-host.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(headers['Cookie']).toContain(`mac=${macAddress}`);
|
||||
expect(headers['Authorization']).toBe('Bearer token-1');
|
||||
expect(headers['User-Agent']).not.toBe('KSPlayer');
|
||||
});
|
||||
|
||||
it('uses the credential-free direct profile for foreign hosts', () => {
|
||||
const streamUrl = 'http://cdn.foreign.example.test/stream/1.ts';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'http://portal.foreign-case.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(headers['User-Agent']).toBe('KSPlayer');
|
||||
expect(headers).not.toHaveProperty('Cookie');
|
||||
expect(headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
it('uses the credential-free profile on an https→http downgrade', () => {
|
||||
const streamUrl = 'http://downgrade.example.test/stream/1.ts';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'https://downgrade.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(headers['User-Agent']).toBe('KSPlayer');
|
||||
expect(headers).not.toHaveProperty('Cookie');
|
||||
expect(headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
isStalkerStreamCredentialSafe,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
@@ -34,14 +35,20 @@ function normalizeStreamUrl(streamUrl: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getOrigin(streamUrl: string): string {
|
||||
function unwrapStreamUrl(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;
|
||||
return spaceIndex > 0
|
||||
? normalized.slice(spaceIndex + 1).trim()
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function getOrigin(streamUrl: string): string {
|
||||
const unwrapped = unwrapStreamUrl(streamUrl);
|
||||
if (!unwrapped) return '';
|
||||
try {
|
||||
return new URL(maybeWrapped).origin;
|
||||
return new URL(unwrapped).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -91,10 +98,17 @@ export function rememberStalkerPlaybackContext(input: {
|
||||
portalOrigin = undefined;
|
||||
}
|
||||
|
||||
// Classified by the shared predicate so this fallback context can never
|
||||
// disagree with the renderer's header builder: same host (port change or
|
||||
// scheme upgrade included) keeps the portal profile, a foreign host or
|
||||
// an https→http downgrade gets the credential-free direct profile.
|
||||
const crossOriginStream =
|
||||
Boolean(streamOrigin) &&
|
||||
Boolean(portalOrigin) &&
|
||||
streamOrigin !== portalOrigin;
|
||||
!isStalkerStreamCredentialSafe(
|
||||
input.portalUrl,
|
||||
unwrapStreamUrl(input.streamUrl)
|
||||
);
|
||||
|
||||
const headers: Record<string, string> = crossOriginStream
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { resolveStreamUrl } from '../data-generator.js';
|
||||
import { buildRequestOrigin } from '../marketing-poster-url.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
import { getScenario } from '../scenarios.js';
|
||||
|
||||
/**
|
||||
* Stalker create_link — returns a playable stream URL.
|
||||
@@ -18,7 +20,21 @@ export function handleCreateLink(req: Request, res: Response): void {
|
||||
const itemIndex = cmd
|
||||
.split('')
|
||||
.reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
|
||||
const streamUrl = resolveStreamUrl(cmd, itemIndex);
|
||||
// The /stalker proxy route dispatches a synthetic request without
|
||||
// Express' .get(), so fall back to the Host header there instead of
|
||||
// crashing — the gated scenario is only meaningful for direct clients
|
||||
// that can attach credentials to media requests anyway.
|
||||
const requestOrigin =
|
||||
typeof req.get === 'function'
|
||||
? buildRequestOrigin(req)
|
||||
: `http://${req.headers['host'] ?? 'localhost:3210'}`;
|
||||
// Radio plays in an <audio> element, which needs a fixture with an audio
|
||||
// track; ITV/VOD get the video fixture.
|
||||
const gatedFile =
|
||||
req.query['type'] === 'radio' ? 'audio.mp4' : 'video.mp4';
|
||||
const streamUrl = getScenario(mac).gatedStream
|
||||
? `${requestOrigin}/stream/gated/${gatedFile}`
|
||||
: resolveStreamUrl(cmd, itemIndex);
|
||||
|
||||
console.log(`[create_link] MAC=${mac} cmd=${cmd} → ${streamUrl}`);
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface ScenarioConfig {
|
||||
marketingFixture?: true;
|
||||
/** Answer `get_profile` with `status: 2` until `auth_second_step=1`. */
|
||||
requiresLogin?: true;
|
||||
/**
|
||||
* `create_link` returns this server's own `/stream/gated/video.mp4`,
|
||||
* which answers 403 unless the request carries the portal mac cookie AND
|
||||
* the MAC's Bearer token — proves a player's media requests really carry
|
||||
* the portal credentials (the "only VLC works" cluster).
|
||||
*/
|
||||
gatedStream?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +143,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
embeddedSeriesFraction: 0,
|
||||
marketingFixture: true,
|
||||
},
|
||||
'00:1a:79:00:00:09': {
|
||||
name: 'gated-stream',
|
||||
description:
|
||||
'Streams gated on portal credentials — create_link returns a ' +
|
||||
'local URL that 403s without the mac cookie + Bearer token',
|
||||
seed: 9009,
|
||||
categoryCount: { itv: 2, radio: 1, vod: 1, series: 1 },
|
||||
itemsPerCategory: 5,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 3,
|
||||
isSeriesFraction: 0,
|
||||
embeddedSeriesFraction: 0,
|
||||
gatedStream: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,11 @@ import express, { Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import portalRouter, { createPortalRouter } from './app/routes/portal.route.js';
|
||||
import dispatchPortalAction from './app/routes/dispatch.js';
|
||||
import { invalidateSession, resetAuthState } from './app/auth-store.js';
|
||||
import {
|
||||
checkRequestAuthorization,
|
||||
invalidateSession,
|
||||
resetAuthState,
|
||||
} from './app/auth-store.js';
|
||||
import { resetWatchdogPings } from './app/handlers/get-events.handler.js';
|
||||
import { resetAll, resetMac } from './app/data-store.js';
|
||||
import { SCENARIOS } from './app/scenarios.js';
|
||||
@@ -172,6 +176,58 @@ app.get('/stalker', (req: Request, res: Response) => {
|
||||
res.json({ payload: plainTextBody ?? captured });
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth-gated media endpoints for the `gated-stream` scenario. A real portal's
|
||||
* streamer sits behind the same session gate as the API, so these routes
|
||||
* require the mac cookie AND the MAC's Bearer token and answer 403
|
||||
* otherwise. They are the only automated proof that a player's actual media
|
||||
* requests carry the portal credentials — a unit test cannot show that a
|
||||
* header reached the video (or audio) element.
|
||||
*
|
||||
* The bodies are the shared clear (non-DRM) fragmented-MP4 fixtures from the
|
||||
* DASH e2e suite (video for ITV, audio-only for radio); `sendFile` supplies
|
||||
* Range support for progressive playback.
|
||||
*/
|
||||
const GATED_STREAM_FIXTURES: Record<string, string> = {
|
||||
'audio.mp4': join(
|
||||
process.cwd(),
|
||||
'apps/web-e2e/src/fixtures/dash/clear-audio.mp4'
|
||||
),
|
||||
'video.mp4': join(
|
||||
process.cwd(),
|
||||
'apps/web-e2e/src/fixtures/dash/clear-video.mp4'
|
||||
),
|
||||
};
|
||||
|
||||
app.get('/stream/gated/:file', (req: Request, res: Response) => {
|
||||
const fixture = GATED_STREAM_FIXTURES[req.params['file'] ?? ''];
|
||||
if (!fixture) {
|
||||
res.status(404).type('text/plain').send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = checkRequestAuthorization(req, true);
|
||||
if (failure) {
|
||||
// Log only header PRESENCE: the cookie carries the mac session
|
||||
// credential and must never reach terminal/CI logs verbatim.
|
||||
console.log(
|
||||
`[gated-stream] 403 (${failure}) cookie=${
|
||||
req.headers['cookie'] ? 'present' : '<none>'
|
||||
} auth=${req.headers['authorization'] ? 'present' : '<none>'}`
|
||||
);
|
||||
res.status(403).type('text/plain').send(failure);
|
||||
return;
|
||||
}
|
||||
|
||||
// `dotfiles: 'allow'`: express refuses any path with a dot-segment by
|
||||
// default, and git worktrees live under `.claude/worktrees/…` — without
|
||||
// this the fixture 404s in every worktree checkout.
|
||||
res.sendFile(fixture, {
|
||||
dotfiles: 'allow',
|
||||
headers: { 'Content-Type': 'video/mp4' },
|
||||
});
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
|
||||
@@ -122,8 +122,9 @@ policy.
|
||||
## Scoped Request Header Overrides
|
||||
|
||||
Inline playback can request temporary `User-Agent`, `Referer`, and `Origin`
|
||||
header overrides through `window.electron.setUserAgent(userAgent, referer,
|
||||
scopeUrl)`.
|
||||
header overrides — and, for auth-gated portal streams, `Cookie` and
|
||||
`Authorization` credentials — through `window.electron.setUserAgent(userAgent,
|
||||
referer, scopeUrl, credentials?)`.
|
||||
|
||||
The Electron backend handles that IPC in `apps/electron-backend/src/app/events/shared.events.ts`
|
||||
and delegates to `apps/electron-backend/src/app/services/request-header-overrides.service.ts`.
|
||||
@@ -131,11 +132,24 @@ The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders
|
||||
listener and updates layered in-memory overrides instead of stacking a new
|
||||
listener for every channel change.
|
||||
|
||||
`ElectronStreamHeadersService` (`libs/ui/playback`) is the single renderer
|
||||
owner of the scoped override slot: it extracts the full header set from the
|
||||
resolved playback (including the Stalker mac cookie and Bearer token), and
|
||||
its `clear()` releases the slot only while the caller's stream still owns it,
|
||||
so a consumer being destroyed cannot wipe an override a newer consumer just
|
||||
configured. Three surfaces apply it: `WebPlayerViewComponent` for every
|
||||
built-in video player (configuring the override **before** handing the
|
||||
source over, clearing on destroy), and — for the dedicated radio audio
|
||||
player, which never mounts a `WebPlayerViewComponent` — the Stalker live
|
||||
layout and the unified collection tab (global/portal Favorites and Recently
|
||||
Viewed). Individual player components must not call the bridge themselves —
|
||||
a narrower call would overwrite the credentialed override.
|
||||
|
||||
Rules:
|
||||
|
||||
- empty playlist-level `userAgent` and `referer` clear all active overrides
|
||||
- empty channel-level `userAgent` and `referer` with a `scopeUrl` clear only
|
||||
the scoped channel override, preserving playlist-level defaults
|
||||
- empty channel-level values with a `scopeUrl` clear only the scoped channel
|
||||
override, preserving playlist-level defaults
|
||||
- channel playback should pass the stream URL as `scopeUrl`
|
||||
- scoped overrides apply only to the active stream origin and referer origin
|
||||
- playlist-level user agents and referrers may call the bridge without a
|
||||
@@ -143,10 +157,43 @@ Rules:
|
||||
the whole M3U playlist
|
||||
- header names are replaced case-insensitively before canonical `User-Agent`,
|
||||
`Referer`, and `Origin` names are written
|
||||
- header values containing control characters are rejected outright (header
|
||||
smuggling)
|
||||
|
||||
Credential rules (`credentials.cookie` / `credentials.authorization`) are
|
||||
deliberately stricter than the general scope:
|
||||
|
||||
- credentials are accepted **only** with a `scopeUrl` that parses to a
|
||||
concrete origin; an unscoped (playlist-level) call silently drops them —
|
||||
fail closed, never fail broad
|
||||
- they are attached **only** to requests whose origin equals the stream URL's
|
||||
exact origin — never to the referer-origin sibling that `User-Agent`/`Referer`
|
||||
also cover, and never to third-party hosts an HLS manifest may point at
|
||||
- they live only in the in-memory override: never in the session cookie jar,
|
||||
never on disk, so they cannot outlive the app process
|
||||
- they are dropped whenever the scoped override is replaced (channel or
|
||||
source change) or released — player close/destroy, a radio host's close, or
|
||||
a new selection that mounts no player surface. The media `ended` event
|
||||
deliberately does **not** clear the override: the mounted player still owns
|
||||
the session (replay, or a seek into an unbuffered range, must keep working
|
||||
against a gated stream), and the credentials only ever travel to the exact
|
||||
origin that issued them; every dismount path above releases them
|
||||
|
||||
The header-injection design was chosen over `session.cookies.set()`
|
||||
deliberately: jar cookies only attach to credentialed requests, which would
|
||||
force `withCredentials` into every web engine and break against the
|
||||
`Access-Control-Allow-Origin: *` that IPTV panels typically send, and jar
|
||||
scoping is domain-based (port-blind) — weaker than the exact-origin match
|
||||
above. Injecting at `onBeforeSendHeaders` sits below the CORS/credentials
|
||||
layer, so the request stays "uncredentialed" for the fetch spec while the
|
||||
wire request carries the portal session.
|
||||
|
||||
When changing this flow, keep stale header cleanup covered. Switching from a
|
||||
channel or playlist with custom headers to one without custom headers must clear
|
||||
the previous override.
|
||||
the previous override. The Electron e2e
|
||||
`apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts` pins the
|
||||
end-to-end contract against a mock stream that answers 403 without the portal
|
||||
credentials.
|
||||
|
||||
## Main-Process Remote Requests
|
||||
|
||||
|
||||
@@ -183,6 +183,55 @@ the cross-portal collection resolver (`StreamResolverService`) use
|
||||
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
|
||||
replies against the portal base URL.
|
||||
|
||||
## Playback Header Contract
|
||||
|
||||
Every playback kind — ITV, VOD, series episodes, and radio — resolves its
|
||||
stream and attaches the same portal header set through
|
||||
`buildStalkerExternalPlaybackHeaders()`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts`).
|
||||
The collection routes (Favorites/Recently Viewed) share the contract:
|
||||
`StreamResolverService.resolveStalker()` builds the identical profile for the
|
||||
streams it resolves, so a channel opened from a collection carries the same
|
||||
credentials as one opened from the portal.
|
||||
The resolved `ResolvedPortalPlayback.headers` feed both the external players
|
||||
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
|
||||
scoped Electron request-header override (`ElectronStreamHeadersService`,
|
||||
applied by `WebPlayerViewComponent` for the video players and by the Stalker
|
||||
live layout for the radio audio player, which renders outside
|
||||
`WebPlayerViewComponent` — see `docs/architecture/electron-security.md`,
|
||||
"Scoped Request Header Overrides").
|
||||
|
||||
Two stream profiles exist, selected by one shared predicate:
|
||||
|
||||
- **Portal-owned** (`isStalkerStreamCredentialSafe()` in
|
||||
`@iptvnator/shared/interfaces`): the stream host equals the portal host —
|
||||
including a different port or an http→https upgrade, the routine IPTV panel
|
||||
shape (#1158 class). These streams get the full MAG profile: `Cookie`
|
||||
(`mac=…` plus protocol cookies), `Authorization: Bearer <token>` when a
|
||||
session token exists, `User-Agent` (playlist override or the MAG UA — the
|
||||
API path always sent both, the playback set historically sent only
|
||||
`X-User-Agent`), `X-User-Agent`, `SN` when a real serial exists, and
|
||||
`Origin`/`Referer` set to the portal origin.
|
||||
- **Foreign / direct** (different host, or an https→http downgrade): the
|
||||
credential-free `KSPlayer` direct-stream profile (`User-Agent: KSPlayer`,
|
||||
`Accept`, `Range`, `Icy-MetaData`, `Connection`). Portal credentials must
|
||||
never reach a third-party host; direct stream URLs carry their access token
|
||||
in the URL minted by `create_link`.
|
||||
|
||||
The Electron main process keeps a fallback header context per resolved
|
||||
`create_link` URL (`stalker-playback-context.service.ts`) for external-player
|
||||
launches that arrive without renderer headers. It classifies streams with the
|
||||
same shared predicate — if the two ever diverged,
|
||||
`isStalkerDirectStreamProfile` in the external-player path would discard the
|
||||
renderer's credentialed headers for streams the main process misread as
|
||||
direct.
|
||||
|
||||
The mock server's `gated-stream` scenario (MAC `00:1A:79:00:00:09`) makes
|
||||
`create_link` return a local `/stream/gated/video.mp4` that answers 403
|
||||
without the mac cookie and current Bearer token;
|
||||
`apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts` uses it to
|
||||
prove a built-in player's media requests really carry the credentials.
|
||||
|
||||
## Live TV and Radio
|
||||
|
||||
The Stalker live route and radio route intentionally share
|
||||
|
||||
@@ -23,7 +23,10 @@ describe('StreamResolverService', () => {
|
||||
let xtreamApi: { getShortEpg: jest.Mock };
|
||||
let xtreamUrl: { constructLiveUrl: jest.Mock };
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
|
||||
let stalkerSession: {
|
||||
getCachedToken: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,6 +43,7 @@ describe('StreamResolverService', () => {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
stalkerSession = {
|
||||
getCachedToken: jest.fn(() => null),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
epgBridge = {
|
||||
@@ -670,6 +674,10 @@ describe('StreamResolverService', () => {
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
|
||||
// A direct radio URL on a host foreign to the portal gets the
|
||||
// credential-free KSPlayer direct-stream profile — the same rule the
|
||||
// Stalker live layout applies (the previous playlist-field passthrough
|
||||
// predated the shared profile classifier).
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
epgMode: 'portal',
|
||||
@@ -680,21 +688,19 @@ describe('StreamResolverService', () => {
|
||||
radio: 'true',
|
||||
url: 'https://media.example.com/direct-radio.mp3',
|
||||
http: expect.objectContaining({
|
||||
referrer: 'https://ref.example.com',
|
||||
'user-agent': 'IPTVnator',
|
||||
origin: 'https://origin.example.com',
|
||||
'user-agent': 'KSPlayer',
|
||||
}),
|
||||
}),
|
||||
playback: expect.objectContaining({
|
||||
streamUrl: 'https://media.example.com/direct-radio.mp3',
|
||||
title: 'Direct Radio',
|
||||
thumbnail: 'direct-radio.png',
|
||||
userAgent: 'IPTVnator',
|
||||
referer: 'https://ref.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
userAgent: 'KSPlayer',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(detail.playback.headers?.['Cookie']).toBeUndefined();
|
||||
expect(detail.playback.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves relative Stalker create_link responses against the portal base', async () => {
|
||||
@@ -730,6 +736,78 @@ describe('StreamResolverService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the portal header set to Stalker collection playback on the portal host', async () => {
|
||||
// The collection routes must carry the same credentials as the live
|
||||
// layout — an auth-gated stream opened from Favorites/Recent 403s
|
||||
// without the mac cookie and Bearer token (Codex round-3 finding).
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Gated Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1');
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:11:22:33:44:55'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN77');
|
||||
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
|
||||
expect(playback.referer).toBe('https://stalker.example.com');
|
||||
expect(playback.origin).toBe('https://stalker.example.com');
|
||||
});
|
||||
|
||||
it('keeps Stalker collection playback from a foreign CDN credential-free', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg http://cdn.other.example/live/88.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Direct Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toBeUndefined();
|
||||
expect(playback.headers?.['Authorization']).toBeUndefined();
|
||||
expect(playback.headers?.['User-Agent']).toBe('KSPlayer');
|
||||
expect(playback.referer).toBeUndefined();
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
getStalkerPortalOrigin,
|
||||
isCrossOriginStalkerStream,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
StalkerSessionService,
|
||||
@@ -331,14 +334,11 @@ export class StreamResolverService {
|
||||
item.stalkerCmd ?? ''
|
||||
);
|
||||
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
|
||||
return {
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
streamUrl: normalizedCmd,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
userAgent: playlist?.userAgent,
|
||||
referer: playlist?.referrer,
|
||||
origin: playlist?.origin,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = item.radio === 'true' ? 'radio' : 'itv';
|
||||
@@ -367,7 +367,9 @@ export class StreamResolverService {
|
||||
|
||||
const rawCmd = response?.js?.cmd ?? '';
|
||||
|
||||
return {
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
// Shared normalizer from the Stalker store: strips the solution
|
||||
// prefix and resolves relative `/media/...` or `?...` responses
|
||||
// against the portal base instead of returning them verbatim.
|
||||
@@ -376,9 +378,60 @@ export class StreamResolverService {
|
||||
item.stalkerCmd ?? '',
|
||||
rawCmd
|
||||
),
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection routes must hand players the SAME portal header set the
|
||||
* Stalker live layout builds — an auth-gated stream opened from Favorites
|
||||
* or Recently Viewed 403s without the mac cookie/Bearer token exactly
|
||||
* like one opened from the portal itself (the header owner then scopes
|
||||
* them to the stream origin; foreign hosts get the credential-free
|
||||
* profile from the shared classifier).
|
||||
*/
|
||||
private buildStalkerPlayback(
|
||||
item: UnifiedCollectionItem,
|
||||
playlist: Playlist | undefined,
|
||||
resolved: {
|
||||
macAddress: string;
|
||||
portalUrl: string;
|
||||
streamUrl: string;
|
||||
isLive?: boolean;
|
||||
}
|
||||
): ResolvedPortalPlayback {
|
||||
// The item may carry portal/mac overrides for playlists that no
|
||||
// longer exist; the builder only reads header-relevant fields.
|
||||
const headerPlaylist = {
|
||||
...(playlist ?? {}),
|
||||
macAddress: resolved.macAddress,
|
||||
portalUrl: resolved.portalUrl,
|
||||
} as Playlist;
|
||||
const token = this.stalkerSession.getCachedToken(item.playlistId);
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
headerPlaylist,
|
||||
token,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
headerPlaylist,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(headerPlaylist);
|
||||
|
||||
return {
|
||||
streamUrl: resolved.streamUrl,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
isLive: resolved.isLive,
|
||||
headers,
|
||||
userAgent: headers['User-Agent'] || playlist?.userAgent,
|
||||
referer: crossOriginStream
|
||||
? undefined
|
||||
: playlist?.referrer || portalOrigin,
|
||||
origin: crossOriginStream
|
||||
? undefined
|
||||
: playlist?.origin || portalOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -743,6 +743,15 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
});
|
||||
|
||||
it('renders inline audio for Stalker radio items and skips external playback', async () => {
|
||||
// Radio renders the dedicated audio player, never the shared web
|
||||
// player wrapper — so this test also pins that the tab itself
|
||||
// configures the scoped header override (portal cookie/token for
|
||||
// auth-gated streams) before the audio element gets the URL, and
|
||||
// clears it again on close.
|
||||
const setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(
|
||||
window.electron as unknown as Record<string, unknown>
|
||||
)['setUserAgent'] = setUserAgent;
|
||||
const item = {
|
||||
...buildLiveItem('stalker'),
|
||||
name: 'Jazz Radio',
|
||||
@@ -754,6 +763,12 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
streamUrl: 'https://example.com/jazz.mp3',
|
||||
title: 'Jazz Radio',
|
||||
thumbnail: 'jazz.png',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00:1A:79:00:00:01',
|
||||
Authorization: 'Bearer TOKEN99',
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
id: '40001',
|
||||
@@ -802,6 +817,25 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
expect(audioPlayer.url()).toBe('https://example.com/jazz.mp3');
|
||||
expect(audioPlayer.icon()).toBe('jazz.png');
|
||||
expect(audioPlayer.channelName()).toBe('Jazz Radio');
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
'https://example.com/jazz.mp3',
|
||||
{
|
||||
authorization: 'Bearer TOKEN99',
|
||||
cookie: 'mac=00:1A:79:00:00:01',
|
||||
}
|
||||
);
|
||||
|
||||
component.onClose();
|
||||
|
||||
// Closing the radio player must drop the portal credentials.
|
||||
expect(setUserAgent).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
'https://example.com/jazz.mp3'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders shared EPG view for Xtream items and records recent history', async () => {
|
||||
|
||||
+36
-1
@@ -55,6 +55,7 @@ import { GlobalFavoritesListComponent } from '../global-favorites-list/global-fa
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ElectronStreamHeadersService,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -103,9 +104,12 @@ export class UnifiedLiveTabComponent {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
/** Stream URL of the radio playback whose header override this tab configured. */
|
||||
private radioHeaderScopeUrl: string | null = null;
|
||||
|
||||
readonly player = this.settingsStore.player;
|
||||
readonly supportsEpg = this.runtime.supportsEpg;
|
||||
@@ -338,7 +342,13 @@ export class UnifiedLiveTabComponent {
|
||||
() => this.progressTick.update((tick) => tick + 1),
|
||||
30_000
|
||||
);
|
||||
this.destroyRef.onDestroy(() => clearInterval(tickInterval));
|
||||
this.destroyRef.onDestroy(() => {
|
||||
clearInterval(tickInterval);
|
||||
// Invalidate a playback continuation still awaiting its header
|
||||
// IPC and drop any radio credentials owned by this tab.
|
||||
this.selectionRequestId += 1;
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
});
|
||||
}
|
||||
|
||||
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
|
||||
@@ -539,6 +549,10 @@ export class UnifiedLiveTabComponent {
|
||||
this.activeUid.set(null);
|
||||
this.activeItem.set(null);
|
||||
this.activeTimeshift.set(null);
|
||||
// Radio credentials must not outlive the closed player; the service
|
||||
// no-ops when a newer playback already owns the override slot.
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
this.radioHeaderScopeUrl = null;
|
||||
}
|
||||
|
||||
onEpgMappingChanged(): void {
|
||||
@@ -577,6 +591,12 @@ export class UnifiedLiveTabComponent {
|
||||
this.activeDetail.set(null);
|
||||
this.activeTimeshift.set(null);
|
||||
this.isSelecting.set(true);
|
||||
// A previously owned radio override must not survive into a
|
||||
// selection that never mounts a player surface of its own — external
|
||||
// video playback and failed resolutions would otherwise keep the old
|
||||
// radio credentials installed for that origin.
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
this.radioHeaderScopeUrl = null;
|
||||
|
||||
try {
|
||||
const detail =
|
||||
@@ -587,6 +607,21 @@ export class UnifiedLiveTabComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.radio === 'true') {
|
||||
// Radio renders the dedicated audio player, never
|
||||
// WebPlayerViewComponent, so the scoped Electron header
|
||||
// override (portal cookie/token for auth-gated streams) is
|
||||
// configured here BEFORE the audio element gets the URL.
|
||||
// Ownership is claimed synchronously so a close/destroy
|
||||
// during the pending IPC can still clear the credentials.
|
||||
const headerSync = this.streamHeaders.apply(detail.playback);
|
||||
this.radioHeaderScopeUrl = detail.playback.streamUrl;
|
||||
const stillCurrent = headerSync ? await headerSync : true;
|
||||
if (!stillCurrent || requestId !== this.selectionRequestId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeDetail.set(detail);
|
||||
|
||||
if (this.supportsEpg && detail.epgMode === 'm3u') {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { buildStalkerExternalPlaybackHeaders } from './stalker-live-playback.utils';
|
||||
import {
|
||||
STALKER_MAG_USER_AGENT,
|
||||
STALKER_STREAM_USER_AGENT,
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
isCrossOriginStalkerStream,
|
||||
} from './stalker-live-playback.utils';
|
||||
import { STALKER_SERIAL_NUMBER } from './stalker-session.service';
|
||||
|
||||
function createPlaylist(
|
||||
@@ -63,4 +68,118 @@ describe('buildStalkerExternalPlaybackHeaders', () => {
|
||||
const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1];
|
||||
expect(cfduid).toHaveLength(32);
|
||||
});
|
||||
|
||||
it('sends the MAG User-Agent alongside X-User-Agent for portal-owned streams', () => {
|
||||
// The API request path always sent both; the playback header set
|
||||
// previously carried only X-User-Agent (audit finding 5).
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist(),
|
||||
'TOKEN',
|
||||
'http://portal.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
expect(headers['X-User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
expect(headers['Cookie']).toContain('mac=00:1A:79:AA:BB:CC');
|
||||
expect(headers['Authorization']).toBe('Bearer TOKEN');
|
||||
});
|
||||
|
||||
it('lets a playlist-level custom User-Agent take precedence over the MAG UA', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist({ userAgent: 'CustomAgent/9.9' }),
|
||||
'TOKEN',
|
||||
'http://portal.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe('CustomAgent/9.9');
|
||||
expect(headers['X-User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
});
|
||||
|
||||
it('keeps portal credentials for a same-host stream on another port', () => {
|
||||
// Panels routinely serve streams from :8080 next to the portal on
|
||||
// :80, and exactly those streams are gated on the portal cookie
|
||||
// (#1158 class) — a strict origin comparison used to push them onto
|
||||
// the credential-free KSPlayer profile.
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist(),
|
||||
'TOKEN',
|
||||
'http://portal.test:8080/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['Cookie']).toContain('mac=00:1A:79:AA:BB:CC');
|
||||
expect(headers['Authorization']).toBe('Bearer TOKEN');
|
||||
expect(headers['User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
});
|
||||
|
||||
it('uses the credential-free direct profile for foreign hosts', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist(),
|
||||
'TOKEN',
|
||||
'http://cdn.other.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe(STALKER_STREAM_USER_AGENT);
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the credential-free profile on an https→http downgrade', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist({
|
||||
portalUrl: 'https://portal.test/stalker_portal/c/index.html',
|
||||
}),
|
||||
'TOKEN',
|
||||
'http://portal.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe(STALKER_STREAM_USER_AGENT);
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCrossOriginStalkerStream', () => {
|
||||
it('treats same-host port and scheme-upgrade changes as portal-owned', () => {
|
||||
const playlist = createPlaylist();
|
||||
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
'http://portal.test:8080/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
'https://portal.test/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats foreign hosts and TLS downgrades as cross-origin', () => {
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
createPlaylist(),
|
||||
'http://cdn.other.test/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
createPlaylist({
|
||||
portalUrl:
|
||||
'https://portal.test/stalker_portal/c/index.html',
|
||||
}),
|
||||
'http://portal.test/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the playlist or stream URL is missing', () => {
|
||||
expect(isCrossOriginStalkerStream(undefined, 'http://x.test/1')).toBe(
|
||||
false
|
||||
);
|
||||
expect(isCrossOriginStalkerStream(createPlaylist(), undefined)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
isStalkerStreamCredentialSafe,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
normalizeStalkerSerialNumber,
|
||||
@@ -23,20 +26,24 @@ export function getStalkerPortalOrigin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the stream must be treated as foreign to the portal — a
|
||||
* different HOST or an https→http downgrade — and therefore must not carry
|
||||
* the portal's credentials. A same-host stream on another port or an
|
||||
* upgraded scheme stays portal-owned: IPTV panels routinely serve streams
|
||||
* from `:8080` next to the portal on `:80`, and those are exactly the
|
||||
* streams gated on the portal's mac cookie/token (#1158 class; curl
|
||||
* semantics, matching the validated redirect layer).
|
||||
*/
|
||||
export function isCrossOriginStalkerStream(
|
||||
playlist: PlaylistMeta | undefined | null,
|
||||
streamUrl?: string
|
||||
): boolean {
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
if (!portalOrigin || !streamUrl) {
|
||||
if (!playlist?.portalUrl || !streamUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(streamUrl).origin !== portalOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return !isStalkerStreamCredentialSafe(playlist.portalUrl, streamUrl);
|
||||
}
|
||||
|
||||
export function buildStalkerExternalPlaybackHeaders(
|
||||
@@ -48,6 +55,10 @@ export function buildStalkerExternalPlaybackHeaders(
|
||||
return {};
|
||||
}
|
||||
|
||||
// Foreign-host (or TLS-downgraded) streams get the credential-free
|
||||
// KSPlayer direct-stream profile: their access token travels in the URL
|
||||
// that create_link minted, and the portal's mac cookie/Bearer token must
|
||||
// never reach a third-party host.
|
||||
if (isCrossOriginStalkerStream(playlist, streamUrl)) {
|
||||
return {
|
||||
'User-Agent': STALKER_STREAM_USER_AGENT,
|
||||
@@ -71,8 +82,14 @@ export function buildStalkerExternalPlaybackHeaders(
|
||||
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
|
||||
}
|
||||
|
||||
// Both the real User-Agent and Stalker's X-User-Agent, matching what the
|
||||
// API request path sends — a portal that filters streams on the MAG UA
|
||||
// never saw it here before (audit finding: same-origin set only
|
||||
// X-User-Agent). A playlist-level custom UA keeps precedence.
|
||||
const magUserAgent = playlist.userAgent?.trim() || STALKER_MAG_USER_AGENT;
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieParts.join('; '),
|
||||
'User-Agent': magUserAgent,
|
||||
'X-User-Agent': STALKER_MAG_USER_AGENT,
|
||||
};
|
||||
|
||||
|
||||
+83
@@ -284,4 +284,87 @@ describe('withStalkerPlayer', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the portal header set to VOD playback on the portal host', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
};
|
||||
session.getCachedToken.mockReturnValue('TOKEN99');
|
||||
// Same host as the portal, different port — the #1158-class stream
|
||||
// shape that is gated on the portal cookie.
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { data: [{ id: 77 }] } })
|
||||
.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://demo.example:8080/video_77.mpg' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Movie Title',
|
||||
'thumb.jpg'
|
||||
);
|
||||
|
||||
expect(session.getCachedToken).toHaveBeenCalledWith(PLAYLIST._id);
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:1A:79:00:00:01'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
|
||||
expect(playback.headers?.['X-User-Agent']).toBeDefined();
|
||||
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
|
||||
expect(playback.referer).toBe('http://demo.example');
|
||||
expect(playback.origin).toBe('http://demo.example');
|
||||
});
|
||||
|
||||
it('keeps VOD playback from a foreign CDN credential-free', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
};
|
||||
session.getCachedToken.mockReturnValue('TOKEN99');
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { data: [{ id: 77 }] } })
|
||||
.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://cdn.example/video_77.mpg' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Movie Title',
|
||||
'thumb.jpg'
|
||||
);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toBeUndefined();
|
||||
expect(playback.headers?.['Authorization']).toBeUndefined();
|
||||
expect(playback.headers?.['User-Agent']).toBe('KSPlayer');
|
||||
expect(playback.referer).toBeUndefined();
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attaches the portal header set to radio playback resolved from the portal', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
};
|
||||
session.getCachedToken.mockReturnValue('TOKEN99');
|
||||
store.setSelectedContentType('radio');
|
||||
const radioItem = {
|
||||
id: 'radio-2',
|
||||
cmd: '/media/radio_2.mpg',
|
||||
name: 'Portal FM',
|
||||
o_name: 'Portal FM',
|
||||
logo: 'portal-fm.png',
|
||||
category_id: '4001',
|
||||
};
|
||||
store.setSelectedItem(radioItem);
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://demo.example/radio_2.mpg' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveRadioPlayback(radioItem);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:1A:79:00:00:01'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
|
||||
expect(playback.referer).toBe('http://demo.example');
|
||||
expect(playback.origin).toBe('http://demo.example');
|
||||
});
|
||||
});
|
||||
+53
-6
@@ -196,14 +196,38 @@ export function withStalkerPlayer() {
|
||||
const selectedItemId =
|
||||
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
|
||||
|
||||
// VOD and series streams sit behind the same portal gate
|
||||
// as ITV: without the mac cookie/Bearer token an
|
||||
// auth-enforcing portal answers 403 (they previously
|
||||
// carried no portal headers at all — audit finding 5).
|
||||
const token = stalkerSession.getCachedToken(playlist._id);
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
playlist,
|
||||
token,
|
||||
streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title: title ?? '',
|
||||
thumbnail,
|
||||
startTime,
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
headers,
|
||||
userAgent:
|
||||
headers['User-Agent'] ||
|
||||
playlist.userAgent ||
|
||||
STALKER_MAG_USER_AGENT,
|
||||
referer: crossOriginStream
|
||||
? undefined
|
||||
: playlist.referrer || portalOrigin,
|
||||
origin: crossOriginStream
|
||||
? undefined
|
||||
: playlist.origin || portalOrigin,
|
||||
contentInfo: {
|
||||
playlistId: playlist._id,
|
||||
contentXtreamId:
|
||||
@@ -319,13 +343,36 @@ export function withStalkerPlayer() {
|
||||
item.o_name || item.name || item.title
|
||||
);
|
||||
|
||||
// Radio streams come off the same portal as ITV and are
|
||||
// gated the same way — give them the identical header set
|
||||
// (they previously carried no portal headers at all).
|
||||
const token = stalkerSession.getCachedToken(playlist._id);
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
playlist,
|
||||
token,
|
||||
streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title: item.o_name || item.name || item.title || '',
|
||||
thumbnail: item.logo ?? item.cover ?? null,
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
headers,
|
||||
userAgent:
|
||||
headers['User-Agent'] ||
|
||||
playlist.userAgent ||
|
||||
STALKER_MAG_USER_AGENT,
|
||||
referer: crossOriginStream
|
||||
? undefined
|
||||
: playlist.referrer || portalOrigin,
|
||||
origin: crossOriginStream
|
||||
? undefined
|
||||
: playlist.origin || portalOrigin,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+116
@@ -279,6 +279,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
@@ -1127,6 +1128,121 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
expect(audioPlayer.channelName()).toBe('Jazz FM');
|
||||
expect(audioPlayer.dispatchAdjacentChannelAction()).toBe(false);
|
||||
});
|
||||
|
||||
it('configures the scoped Electron header override before radio playback starts', async () => {
|
||||
// The radio branch renders the dedicated audio player, not
|
||||
// WebPlayerViewComponent — without this wiring an auth-gated portal
|
||||
// radio stream 403s because its credentials never reach the request.
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
selectedCategoryId.set('radio-all');
|
||||
selectedItem.set(null);
|
||||
selectedItvId.set(undefined);
|
||||
fixture.detectChanges();
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00:1A:79:00:00:01',
|
||||
Authorization: 'Bearer TOKEN99',
|
||||
},
|
||||
});
|
||||
|
||||
await component.playChannel(radioChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(window.electron?.setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
'http://portal.example/radio_2.mpg',
|
||||
{
|
||||
authorization: 'Bearer TOKEN99',
|
||||
cookie: 'mac=00:1A:79:00:00:01',
|
||||
}
|
||||
);
|
||||
|
||||
const audioPlayer = fixture.debugElement.query(
|
||||
By.directive(StubAudioPlayerComponent)
|
||||
).componentInstance as StubAudioPlayerComponent;
|
||||
expect(audioPlayer.url()).toBe('http://portal.example/radio_2.mpg');
|
||||
});
|
||||
|
||||
it('clears the radio header override when destroyed while the apply IPC is pending', async () => {
|
||||
// Leaving the radio route mid-apply must not leave the portal
|
||||
// cookie/token installed: ownership is claimed before awaiting the
|
||||
// IPC, so ngOnDestroy can always name the stream to clear.
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
selectedCategoryId.set('radio-all');
|
||||
selectedItem.set(null);
|
||||
selectedItvId.set(undefined);
|
||||
fixture.detectChanges();
|
||||
let resolveApply: (() => void) | undefined;
|
||||
let signalApplyIssued!: () => void;
|
||||
const applyIssued = new Promise<void>(
|
||||
(resolve) => (signalApplyIssued = resolve)
|
||||
);
|
||||
// Once: only the apply call gets the pending promise — the destroy
|
||||
// clear below also calls the bridge and must not steal the resolver.
|
||||
(
|
||||
window.electron?.setUserAgent as jest.Mock
|
||||
).mockImplementationOnce(() => {
|
||||
signalApplyIssued();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveApply = () => resolve(true);
|
||||
});
|
||||
});
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
headers: { Cookie: 'mac=00:1A:79:00:00:01' },
|
||||
});
|
||||
|
||||
const playPromise = component.playChannel(radioChannels()[0]);
|
||||
// The header IPC has been issued but is still pending.
|
||||
await applyIssued;
|
||||
|
||||
fixture.destroy();
|
||||
resolveApply?.();
|
||||
await playPromise;
|
||||
|
||||
expect(window.electron?.setUserAgent).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
'http://portal.example/radio_2.mpg'
|
||||
);
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
});
|
||||
|
||||
it('releases the radio override when the next selection never mounts a player', async () => {
|
||||
// Switching from a playing radio to a channel whose resolution fails
|
||||
// (or plays externally) mounts no player surface — the selection
|
||||
// itself must release the previously installed credentials.
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
selectedCategoryId.set('radio-all');
|
||||
selectedItem.set(null);
|
||||
selectedItvId.set(undefined);
|
||||
fixture.detectChanges();
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
headers: { Cookie: 'mac=00:1A:79:00:00:01' },
|
||||
});
|
||||
await component.playChannel(radioChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
|
||||
stalkerStore.selectedContentType.set('itv');
|
||||
resolveItvPlayback.mockRejectedValueOnce(new Error('portal down'));
|
||||
await component.playChannel(itvChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(window.electron?.setUserAgent).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
'http://portal.example/radio_2.mpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildProgram(channelId: string, title: string): EpgProgram {
|
||||
|
||||
+35
@@ -51,6 +51,7 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ElectronStreamHeadersService,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -117,6 +118,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly liveSidebarStateService = inject(
|
||||
@@ -361,6 +363,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private epgLoadRequestId = 0;
|
||||
private playbackRequestId = 0;
|
||||
/** Stream URL of the radio playback whose header override this layout configured. */
|
||||
private radioHeaderScopeUrl: string | null = null;
|
||||
private playbackResolution: {
|
||||
channelId: string;
|
||||
promise: Promise<ResolvedPortalPlayback>;
|
||||
@@ -557,6 +561,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
this.removeScrollListener();
|
||||
// Invalidate any playback continuation still awaiting its header
|
||||
// IPC, then drop the radio credentials — they must not outlive this
|
||||
// layout. The service no-ops when a newer playback already owns the
|
||||
// override slot.
|
||||
this.playbackRequestId += 1;
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
}
|
||||
|
||||
isSelectedChannel(item: StalkerItvChannel): boolean {
|
||||
@@ -573,6 +583,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
const channelId = normalizeStalkerEntityId(item.id);
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
this.ensureChannelWithinRenderWindow(channelId);
|
||||
// A previously owned radio override must not survive into a
|
||||
// selection that never mounts a player surface of its own — external
|
||||
// video playback and failed resolutions would otherwise keep the old
|
||||
// radio credentials installed for that origin.
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
this.radioHeaderScopeUrl = null;
|
||||
|
||||
try {
|
||||
const isRadioMode = this.isRadioMode();
|
||||
@@ -588,6 +604,25 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
if (isRadioMode) {
|
||||
// The radio branch renders the dedicated audio player, not
|
||||
// WebPlayerViewComponent, so the scoped Electron header
|
||||
// override (portal cookie/token for auth-gated streams) must
|
||||
// be configured here BEFORE the audio element gets the URL.
|
||||
// Ownership is claimed synchronously, before awaiting the
|
||||
// IPC: if this layout is destroyed while the apply is still
|
||||
// in flight, ngOnDestroy must already know which stream's
|
||||
// override to clear — otherwise the credentials would
|
||||
// outlive the route.
|
||||
const headerSync = this.streamHeaders.apply(playback);
|
||||
this.radioHeaderScopeUrl = playback.streamUrl;
|
||||
const stillCurrent = headerSync ? await headerSync : true;
|
||||
if (
|
||||
!stillCurrent ||
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.selectedChannelId() !== channelId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.activePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export * from './lib/xtream-vod-stream.interface';
|
||||
export * from './lib/stalker-item.normalizer';
|
||||
export * from './lib/stalker-identity.utils';
|
||||
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';
|
||||
|
||||
|
||||
@@ -186,6 +186,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
|
||||
@@ -624,7 +635,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,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:');
|
||||
}
|
||||
@@ -14,4 +14,5 @@ export * from './lib/portal-inline-player/up-next-rail.util';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
export * from './lib/vod-details/vod-details.component';
|
||||
export * from './lib/web-player-view/electron-stream-headers.service';
|
||||
export * from './lib/web-player-view/web-player-view.component';
|
||||
@@ -124,7 +124,7 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
|
||||
});
|
||||
|
||||
it('passes channel headers and stream URL to Electron header overrides', () => {
|
||||
it('does not configure Electron header overrides itself — WebPlayerViewComponent owns them', () => {
|
||||
jest.spyOn(
|
||||
component.videoPlayer.nativeElement,
|
||||
'load'
|
||||
@@ -145,39 +145,9 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
url: 'https://stream.example/video.mp4',
|
||||
});
|
||||
|
||||
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/video.mp4'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears Electron header overrides for channels without custom headers', () => {
|
||||
jest.spyOn(
|
||||
component.videoPlayer.nativeElement,
|
||||
'load'
|
||||
).mockImplementation(() => undefined);
|
||||
jest.spyOn(
|
||||
component.videoPlayer.nativeElement,
|
||||
'play'
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
http: {
|
||||
'user-agent': '',
|
||||
origin: '',
|
||||
referrer: '',
|
||||
},
|
||||
radio: 'false',
|
||||
url: 'https://stream.example/video.mp4',
|
||||
});
|
||||
|
||||
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
|
||||
'',
|
||||
'',
|
||||
'https://stream.example/video.mp4'
|
||||
);
|
||||
// A second three-header call from here would overwrite the richer
|
||||
// scoped override (incl. Cookie/Authorization) the host configured.
|
||||
expect(electronApi.setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces and reloads native video sources when switching episodes', () => {
|
||||
|
||||
@@ -187,18 +187,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const url = channel.url + (channel.epgParams ?? '');
|
||||
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
|
||||
|
||||
void window.electron
|
||||
?.setUserAgent(
|
||||
channel.http?.['user-agent'],
|
||||
channel.http?.referrer,
|
||||
channel.url
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[HtmlVideoPlayer] Failed to configure Electron request headers:',
|
||||
error
|
||||
);
|
||||
});
|
||||
// The scoped Electron header override is owned by
|
||||
// WebPlayerViewComponent, which configures the full header set
|
||||
// (incl. portal Cookie/Authorization) before this component
|
||||
// receives the channel. Re-issuing the three-header call here
|
||||
// would overwrite that richer override.
|
||||
|
||||
if (extension === 'mpd') {
|
||||
debugHtmlPlayer(
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
|
||||
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
|
||||
const GATED_PLAYBACK = {
|
||||
streamUrl: STREAM_URL,
|
||||
title: 'Gated Channel',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
Authorization: 'Bearer TOKEN123',
|
||||
},
|
||||
};
|
||||
|
||||
describe('ElectronStreamHeadersService', () => {
|
||||
let service: ElectronStreamHeadersService;
|
||||
let setUserAgent: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(window as unknown as { electron?: unknown }).electron = {
|
||||
setUserAgent,
|
||||
};
|
||||
service = TestBed.inject(ElectronStreamHeadersService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('returns null without an Electron bridge (PWA)', () => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
|
||||
expect(service.apply(GATED_PLAYBACK)).toBeNull();
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts the full header set and scopes it to the stream URL', async () => {
|
||||
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
{
|
||||
authorization: 'Bearer TOKEN123',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers explicit userAgent/referer fields over the headers map', async () => {
|
||||
await service.apply({
|
||||
...GATED_PLAYBACK,
|
||||
userAgent: 'ExplicitAgent/1.0',
|
||||
referer: 'http://explicit.example',
|
||||
});
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'ExplicitAgent/1.0',
|
||||
'http://explicit.example',
|
||||
STREAM_URL,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the credentials object when the playback carries none', async () => {
|
||||
await service.apply({
|
||||
streamUrl: 'https://example.com/live/plain.m3u8',
|
||||
title: 'Plain Channel',
|
||||
userAgent: 'PlainAgent/1.0',
|
||||
});
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'PlainAgent/1.0',
|
||||
undefined,
|
||||
'https://example.com/live/plain.m3u8',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a superseded apply as not current', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
setUserAgent.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) =>
|
||||
resolvers.push(() => resolve(true))
|
||||
)
|
||||
);
|
||||
|
||||
const first = service.apply(GATED_PLAYBACK);
|
||||
const second = service.apply({
|
||||
...GATED_PLAYBACK,
|
||||
streamUrl: 'http://portal.example:8080/live/ch2.ts',
|
||||
});
|
||||
|
||||
resolvers[0]();
|
||||
resolvers[1]();
|
||||
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(second).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('clears the override only while the caller still owns the slot', async () => {
|
||||
await service.apply(GATED_PLAYBACK);
|
||||
setUserAgent.mockClear();
|
||||
|
||||
// A stale consumer (e.g. a destroyed component) must not wipe the
|
||||
// override a newer playback configured.
|
||||
service.clear('http://portal.example:8080/other-stream.ts');
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
|
||||
service.clear(STREAM_URL);
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
STREAM_URL
|
||||
);
|
||||
});
|
||||
|
||||
it('does not clear twice for the same stream', async () => {
|
||||
await service.apply(GATED_PLAYBACK);
|
||||
service.clear(STREAM_URL);
|
||||
setUserAgent.mockClear();
|
||||
|
||||
service.clear(STREAM_URL);
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves instead of throwing when the bridge rejects', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* silence */
|
||||
});
|
||||
setUserAgent.mockRejectedValue(new Error('ipc down'));
|
||||
|
||||
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
|
||||
function getHeaderValue(
|
||||
headers: ResolvedPortalPlayback['headers'] | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchingKey = Object.keys(headers).find(
|
||||
(key) => key.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return matchingKey ? headers[matchingKey] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single owner of the scoped Electron request-header override for built-in
|
||||
* playback. There is exactly one scoped override slot in the main process, so
|
||||
* every surface that plays a stream inline goes through this service:
|
||||
* `WebPlayerViewComponent` for the web video players, and the Stalker live
|
||||
* layout for the dedicated radio audio player, which never mounts a
|
||||
* `WebPlayerViewComponent` at all.
|
||||
*
|
||||
* `apply()` extracts the full header set from the resolved playback —
|
||||
* including the portal Cookie/Authorization that auth-gated streams require —
|
||||
* and hands it to the main process scoped to the stream's URL. A playback
|
||||
* without custom headers deliberately clears the previous scoped override so
|
||||
* stale headers never leak onto the next stream. `clear()` releases the slot
|
||||
* only while the caller's stream still owns it, so a consumer being destroyed
|
||||
* cannot wipe the override a newer consumer just configured.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronStreamHeadersService {
|
||||
/** Guards against a superseded apply resolving its header IPC late. */
|
||||
private syncSequence = 0;
|
||||
/** Stream URL the currently configured override belongs to. */
|
||||
private currentScopeUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Configures the scoped override for this playback. Resolves `true` when
|
||||
* this apply is still the newest one at the time the main process
|
||||
* acknowledged it — callers must only hand the source to a player on
|
||||
* `true`, so the first media request already carries the headers. Returns
|
||||
* null when there is no Electron bridge (PWA), where sources apply
|
||||
* synchronously and no override exists.
|
||||
*/
|
||||
apply(playback: ResolvedPortalPlayback): Promise<boolean> | null {
|
||||
// Feature-detect the bridge method rather than the bridge object:
|
||||
// partial bridges (tests, older preloads) must behave like the PWA
|
||||
// instead of throwing inside a playback flow.
|
||||
if (typeof window.electron?.setUserAgent !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sequence = ++this.syncSequence;
|
||||
const userAgent =
|
||||
playback.userAgent ??
|
||||
getHeaderValue(playback.headers, 'User-Agent');
|
||||
const referer =
|
||||
playback.referer ?? getHeaderValue(playback.headers, 'Referer');
|
||||
const cookie = getHeaderValue(playback.headers, 'Cookie');
|
||||
const authorization = getHeaderValue(playback.headers, 'Authorization');
|
||||
|
||||
this.currentScopeUrl = playback.streamUrl;
|
||||
return window.electron
|
||||
.setUserAgent(
|
||||
userAgent,
|
||||
referer,
|
||||
playback.streamUrl,
|
||||
cookie || authorization ? { authorization, cookie } : undefined
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[ElectronStreamHeaders] Failed to configure request headers:',
|
||||
error
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.then(() => sequence === this.syncSequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the scoped override — portal credentials must not outlive the
|
||||
* playback session that needed them. No-ops when another stream has taken
|
||||
* ownership of the slot since, and always in the PWA.
|
||||
*/
|
||||
clear(streamUrl: string | null): void {
|
||||
if (
|
||||
typeof window.electron?.setUserAgent !== 'function' ||
|
||||
streamUrl === null ||
|
||||
this.currentScopeUrl !== streamUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncSequence += 1;
|
||||
this.currentScopeUrl = null;
|
||||
void window.electron
|
||||
.setUserAgent(undefined, undefined, streamUrl)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,68 @@
|
||||
@if (selectedPlayer() === 'videojs') {
|
||||
@defer (on immediate) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
<!-- The source hand-off is deferred until the scoped Electron header
|
||||
override is configured; mounting the player before `vjsOptions`
|
||||
exists would fire a source-less (or credential-less) first load. -->
|
||||
@if (vjsOptions) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'html5') {
|
||||
@defer (on immediate) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
@if (channel) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'artplayer') {
|
||||
@defer (on immediate) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
@if (channel) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
@@ -913,6 +913,122 @@ describe('WebPlayerViewComponent', () => {
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe('Electron scoped header override ownership', () => {
|
||||
const GATED_STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
|
||||
const GATED_PLAYBACK = {
|
||||
streamUrl: GATED_STREAM_URL,
|
||||
title: 'Gated Channel',
|
||||
isLive: true,
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
Authorization: 'Bearer TOKEN123',
|
||||
},
|
||||
};
|
||||
let setUserAgent: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(window as unknown as { electron?: unknown }).electron = {
|
||||
setUserAgent,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('configures the full header set — incl. credentials — before handing the source to the player', async () => {
|
||||
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
// The source is handed over only after the override IPC resolves,
|
||||
// so the first media request already carries the credentials.
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
GATED_STREAM_URL,
|
||||
{
|
||||
authorization: 'Bearer TOKEN123',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
}
|
||||
);
|
||||
expect(component.channel).toBeUndefined();
|
||||
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.channel.url).toBe(GATED_STREAM_URL);
|
||||
});
|
||||
|
||||
it('omits the credentials object when the playback carries none', async () => {
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl: 'https://example.com/live/plain.m3u8',
|
||||
title: 'Plain Channel',
|
||||
userAgent: 'PlainAgent/1.0',
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'PlainAgent/1.0',
|
||||
undefined,
|
||||
'https://example.com/live/plain.m3u8',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('applies only the newest playback when a switch supersedes a pending header IPC', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
setUserAgent.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) =>
|
||||
resolvers.push(() => resolve(true))
|
||||
)
|
||||
);
|
||||
|
||||
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
|
||||
fixture.detectChanges();
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl: 'http://portal.example:8080/live/ch2.ts',
|
||||
title: 'Next Channel',
|
||||
isLive: true,
|
||||
headers: GATED_PLAYBACK.headers,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// The stale IPC completion must not hand the old source over.
|
||||
resolvers[0]();
|
||||
await fixture.whenStable();
|
||||
expect(component.channel).toBeUndefined();
|
||||
|
||||
resolvers[1]();
|
||||
await fixture.whenStable();
|
||||
expect(component.channel.url).toBe(
|
||||
'http://portal.example:8080/live/ch2.ts'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the scoped override on destroy so credentials do not outlive playback', async () => {
|
||||
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
setUserAgent.mockClear();
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
GATED_STREAM_URL
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
Signal,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
import {
|
||||
getDiagnosticCodecHint,
|
||||
getDiagnosticDescriptionKey,
|
||||
@@ -89,7 +91,7 @@ function resolveWebPlayerSharedControls(): boolean {
|
||||
],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent {
|
||||
export class WebPlayerViewComponent implements OnDestroy {
|
||||
storage = inject(StorageMap);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
@@ -221,15 +223,58 @@ export class WebPlayerViewComponent {
|
||||
});
|
||||
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
|
||||
|
||||
/** Stream URL the currently configured Electron header override belongs to. */
|
||||
private headerScopeStreamUrl: string | null = null;
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Track player changes so stale browser diagnostics are cleared on switch.
|
||||
this.selectedPlayer();
|
||||
|
||||
const playback = this.resolvedPlayback();
|
||||
const isLive = this.resolvedIsLive();
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.applyPlayback(playback, isLive);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Portal credentials must not outlive the playback session that
|
||||
// needed them: dropping the scoped override here keeps only the
|
||||
// playlist-level (unscoped) User-Agent/Referer defaults active. The
|
||||
// service no-ops if a newer consumer already owns the override slot.
|
||||
this.streamHeaders.clear(this.headerScopeStreamUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the scoped Electron request headers BEFORE the stream source
|
||||
* is handed to a player, so the very first media request already carries
|
||||
* them — an auth-gated portal stream answers 403 without its
|
||||
* Cookie/Authorization, and several engines treat that first failure as
|
||||
* fatal. In the PWA there is no header bridge and the source applies
|
||||
* synchronously, exactly as before.
|
||||
*/
|
||||
private applyPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
isLive: boolean
|
||||
): void {
|
||||
const headerSync = this.streamHeaders.apply(playback);
|
||||
this.headerScopeStreamUrl = playback.streamUrl;
|
||||
const handOff = (): void => {
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
||||
this.setVjsOptions(playback.streamUrl, isLive);
|
||||
};
|
||||
|
||||
if (!headerSync) {
|
||||
handOff();
|
||||
return;
|
||||
}
|
||||
|
||||
void headerSync.then((stillCurrent) => {
|
||||
if (stillCurrent) {
|
||||
handOff();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user