diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 69753f335..7a46a78c1 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -137,12 +137,13 @@ 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. Two surfaces apply it: `WebPlayerViewComponent` for every +configured. Three surfaces apply it: `WebPlayerViewComponent` for every built-in video player (configuring the override **before** handing the -source over, clearing on destroy), and the Stalker live layout for the -dedicated radio audio player, which never mounts a `WebPlayerViewComponent`. -Individual player components must not call the bridge themselves — a -narrower call would overwrite the credentialed override. +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: diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index b21f655a0..6a015dc46 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -189,6 +189,10 @@ 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`, diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index cabef2bae..da145fc93 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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; 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) + ); + 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) + ); + 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({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 32ac6425d..839f40001 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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, }; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 3798d6434..cdb9dff70 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -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 + )['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 () => { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 4318c157a..a84606938 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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 { @@ -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 { @@ -587,6 +601,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') {