mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(stalker): authenticate before serving a static collection stream
Second Codex P1 on #1364, and a regression this PR introduced. `create_link` was also the request that warmed the portal session. Tokens live in memory only (`StalkerSessionService.tokenCache` is a plain Map), and the collection header builder reads the raw `getCachedToken()`. So a cold start from global Favorites or Recently Viewed — the portal never opened this session — took the static path, found no token, and handed a same-host gated stream headers with no `Authorization`: a 403 on exactly the streams the header contract exists for. The same raw accessor cannot tell a token negotiated for a pre-edit identity from a current one. `StreamResolverService` now calls `ensureToken()` before building a static playback. It is the right primitive: handshake + `get_profile` with no link minted, identity fingerprint validated, concurrent callers deduped, and an immediate null for simple portals — and calling it keeps this change out of `stalker-session.service.ts`, which PR 6 (#1354) is splitting. Best-effort by design: a static URL may point at a CDN that needs no credentials, so a failed handshake degrades to the token-less header set instead of costing the user their playback. Both halves are pinned by tests, and removing the call makes the cold-start test fail. The portal routes need no equivalent and do not get one: an item cannot be selected before its catalog has loaded, and every catalog load authenticates. That reasoning is now written down rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0278205984
commit
409d8235a2
3 files changed
+144
-2
No files matched your search
@@ -364,6 +364,26 @@ Callers pass the row they resolved the `cmd` from:
|
||||
An item with no row snapshot gets no verdict, except radio, which keeps its
|
||||
long-standing "directly usable command plays as-is" behaviour.
|
||||
|
||||
### The static path still needs the session
|
||||
|
||||
`create_link` was also the request that warmed the portal session, and tokens
|
||||
live in memory only (`StalkerSessionService.tokenCache`). Skipping it therefore
|
||||
has to account for streams that are still gated on the Bearer token:
|
||||
|
||||
- **Portal routes** (ITV, VOD, series, radio) are structurally warm. An item
|
||||
cannot be selected before its catalog has loaded, and every catalog load goes
|
||||
through `executeStalkerRequest`, which authenticates. No extra work needed.
|
||||
- **Collection routes** (global Favorites / Recently Viewed) are not. They read
|
||||
the persisted row and can play on a cold start, without the portal ever
|
||||
having been opened this session. `StreamResolverService` therefore calls
|
||||
`StalkerSessionService.ensureToken()` before building a static playback —
|
||||
handshake + `get_profile`, no link minted, and it validates the identity the
|
||||
cached token was negotiated for, which the raw `getCachedToken()` used by the
|
||||
header builder cannot. A simple portal returns `null` immediately.
|
||||
The call is best-effort: a static URL may point at a CDN needing no
|
||||
credentials at all, so a failed handshake degrades to the token-less header
|
||||
set rather than costing the user their playback.
|
||||
|
||||
### Resolved links are never stored
|
||||
|
||||
A temporary link lives about 5 seconds (`tv_tmp_link_ttl` /
|
||||
@@ -420,7 +440,8 @@ Revisit both only with a portal that demonstrably fails without them.
|
||||
the `series` exception, relative VOD commands.
|
||||
- `with-stalker-player.feature.spec.ts` — ITV/radio store paths and proof that
|
||||
Recently Viewed stores the `cmd`, never the stream URL.
|
||||
- `stream-resolver.service.spec.ts` — the collection route.
|
||||
- `stream-resolver.service.spec.ts` — the collection route, plus the cold
|
||||
full-portal session warm-up and its best-effort degradation.
|
||||
- `stalker-playback-context.service.spec.ts` — headers only, query-insensitive
|
||||
key.
|
||||
- `apps/web-e2e/src/stalker.e2e.ts` — mock scenario `00:1A:79:00:00:0A` serves
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('StreamResolverService', () => {
|
||||
stalkerSession = {
|
||||
getCachedToken: jest.fn(() => null),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest.fn().mockResolvedValue({ token: null }),
|
||||
};
|
||||
epgBridge = {
|
||||
getChannelPrograms: jest.fn(),
|
||||
@@ -853,6 +854,86 @@ describe('StreamResolverService', () => {
|
||||
expect(playback.isLive).toBe(true);
|
||||
});
|
||||
|
||||
it('authenticates a cold full-portal session before a static stream', async () => {
|
||||
// Skipping create_link also skips the request that used to warm the
|
||||
// session. Tokens are in-memory only, so a cold start from global
|
||||
// Favorites would otherwise hand a same-host gated stream headers
|
||||
// with no Authorization and take a 403.
|
||||
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>)
|
||||
);
|
||||
// Cold: no token until ensureToken has run.
|
||||
stalkerSession.getCachedToken.mockReturnValue(null);
|
||||
stalkerSession.ensureToken.mockImplementation(async () => {
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN-COLD');
|
||||
return { token: 'TOKEN-COLD' };
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::91',
|
||||
name: 'Cold Static Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '91',
|
||||
stalkerCmd: 'ffrt3 https://stalker.example.com/live/91.m3u8',
|
||||
stalkerItem: {
|
||||
id: '91',
|
||||
cmd: 'ffrt3 https://stalker.example.com/live/91.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: 'stalker-1' })
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/live/91.m3u8'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-COLD');
|
||||
});
|
||||
|
||||
it('still plays a static stream when the session cannot be established', async () => {
|
||||
// The static URL may point at a CDN that needs no credentials, so a
|
||||
// failed handshake must not cost the user their playback.
|
||||
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.ensureToken.mockRejectedValue(new Error('portal down'));
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::92',
|
||||
name: 'Cdn Static Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '92',
|
||||
stalkerCmd: 'ffrt3 https://cdn.example.com/live/92.m3u8',
|
||||
stalkerItem: {
|
||||
id: '92',
|
||||
cmd: 'ffrt3 https://cdn.example.com/live/92.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe('https://cdn.example.com/live/92.m3u8');
|
||||
expect(playback.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mints a link for a flagged Stalker favorite', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
StalkerSessionService,
|
||||
type StalkerLinkFlagSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
UnifiedCollectionItem,
|
||||
createLogger,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: { readonly items?: Channel[] };
|
||||
@@ -75,6 +78,7 @@ export class StreamResolverService {
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly portalRepair = inject(StalkerPortalRepairService);
|
||||
private readonly logger = createLogger('StreamResolver');
|
||||
private readonly m3uEpgTimeoutMs = 3000;
|
||||
private readonly portalEpgTimeoutMs = 10000;
|
||||
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
|
||||
@@ -349,6 +353,17 @@ export class StreamResolverService {
|
||||
item.stalkerCmd ?? ''
|
||||
);
|
||||
if (staticUrl) {
|
||||
// Skipping `create_link` also skips the only authenticated
|
||||
// request this route used to make, and it was what warmed the
|
||||
// session. Tokens live in memory only, so on a cold start from
|
||||
// global Favorites/Recently Viewed a same-host stream gated on
|
||||
// the portal Bearer token would get headers without one and 403.
|
||||
// `ensureToken` performs the handshake + get_profile — and
|
||||
// validates the identity the cached token was negotiated for,
|
||||
// which the raw `getCachedToken` below cannot — without minting a
|
||||
// link; a simple portal returns null immediately.
|
||||
await this.warmStalkerSession(playlist);
|
||||
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
@@ -417,6 +432,31 @@ export class StreamResolverService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish the portal session a static stream may still be gated on.
|
||||
*
|
||||
* Best-effort on purpose: the static URL can just as well point at a
|
||||
* foreign CDN that needs no credentials, so a failed handshake must not
|
||||
* cost the user their playback. It degrades to the token-less header set,
|
||||
* which is exactly what this path produced before.
|
||||
*/
|
||||
private async warmStalkerSession(
|
||||
playlist: Playlist | undefined
|
||||
): Promise<void> {
|
||||
if (!playlist) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.stalkerSession.ensureToken(playlist);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Could not establish the Stalker session for a static stream',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection routes must hand players the SAME portal header set the
|
||||
* Stalker live layout builds — an auth-gated stream opened from Favorites
|
||||
|
||||
Reference in new issue
Block a user