mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(stalker): bind the playback token to the endpoint the headers claim
The collection playback route resolved the session token from the raw playlist row while building the headers from the post-repair endpoint, so a completed portal repair could send a token negotiated for one host to another — and key the session cache differently from every other consumer, forcing a redundant handshake. The token now comes from the same coordinates the headers carry. A foreign-host stream skips the token entirely: the header builder gives it the credential-free profile anyway, so obtaining one only stalled playback behind a handshake against a portal that may be slow or offline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c477df59c0
commit
4abc65c775
2 files changed
+84
-16
No files matched your search
@@ -4,7 +4,10 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
DataService,
|
||||
@@ -47,7 +50,6 @@ describe('StreamResolverService', () => {
|
||||
getCachedToken: jest.fn(() => null),
|
||||
ensureToken: jest.fn().mockResolvedValue({ token: null }),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest.fn().mockResolvedValue({ token: null }),
|
||||
};
|
||||
epgBridge = {
|
||||
getChannelPrograms: jest.fn(),
|
||||
@@ -823,6 +825,50 @@ describe('StreamResolverService', () => {
|
||||
expect(playback.origin).toBe('https://stalker.example.com');
|
||||
});
|
||||
|
||||
it('binds the playback token to the endpoint the headers claim', async () => {
|
||||
// A completed repair moved the endpoint. The headers are built from
|
||||
// that moved endpoint, so the session must be negotiated for it too —
|
||||
// authenticating against the pre-repair row would send a token minted
|
||||
// for one host to another (and key the session cache differently from
|
||||
// every other consumer, forcing a redundant handshake).
|
||||
const stored = {
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://old.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>;
|
||||
const repaired =
|
||||
'https://new.example.com/stalker_portal/server/load.php';
|
||||
playlistsService.getPlaylistById.mockReturnValue(of(stored));
|
||||
jest.spyOn(
|
||||
TestBed.inject(StalkerPortalRepairService),
|
||||
'applyOverride'
|
||||
).mockImplementation(
|
||||
(playlist: any) => ({ ...playlist, portalUrl: repaired }) as any
|
||||
);
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN88' });
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://new.example.com:8080/live/88.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Moved Portal Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://old.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ portalUrl: repaired })
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN88');
|
||||
expect(playback.origin).toBe('https://new.example.com');
|
||||
});
|
||||
|
||||
it('authenticates a cold session for a direct-URL radio favorite', async () => {
|
||||
// A direct-URL radio favorite skips create_link entirely, so on a
|
||||
// cold session nothing has authenticated and the in-memory cache is
|
||||
@@ -861,9 +907,11 @@ describe('StreamResolverService', () => {
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77');
|
||||
});
|
||||
|
||||
it('still plays when cold-session authentication fails', async () => {
|
||||
// Many portals do not gate the stream itself — a failed handshake
|
||||
// must not block playback, only omit the Bearer header.
|
||||
it('falls back to create_link when the cold session cannot be established', async () => {
|
||||
// A portal-owned static stream with no usable session would be served
|
||||
// knowing it will 401, so playback is not blocked — it takes the
|
||||
// `create_link` route instead, which mints a URL carrying its own
|
||||
// token and is the only path that can trigger the lazy portal repair.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
@@ -877,6 +925,9 @@ describe('StreamResolverService', () => {
|
||||
stalkerSession.ensureToken.mockRejectedValue(
|
||||
new Error('handshake refused')
|
||||
);
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://stalker.example.com/radio/99-tmp.mp3' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::99',
|
||||
@@ -891,8 +942,9 @@ describe('StreamResolverService', () => {
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/radio/99.mp3'
|
||||
'https://stalker.example.com/radio/99-tmp.mp3'
|
||||
);
|
||||
// No session, so no Bearer — the minted URL carries its own token.
|
||||
expect(playback.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1131,10 +1183,12 @@ describe('StreamResolverService', () => {
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://new.example.com/portal.php',
|
||||
macAddress: 'AA:BB:CC:00:00:99',
|
||||
isFullStalkerPortal: false,
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN-NEW');
|
||||
// A full portal, so the Bearer assertion below is meaningful: only a
|
||||
// portal with a session has a token to attach at all.
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN-NEW' });
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::93',
|
||||
|
||||
@@ -523,19 +523,34 @@ export class StreamResolverService {
|
||||
macAddress: resolved.macAddress,
|
||||
portalUrl: resolved.portalUrl,
|
||||
} as Playlist;
|
||||
const token = await this.resolveStalkerPlaybackToken(
|
||||
item.playlistId,
|
||||
playlist
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
headerPlaylist,
|
||||
resolved.streamUrl
|
||||
);
|
||||
// Classified before authenticating, for the same reason the static
|
||||
// branch classifies first: the header builder gives a foreign host the
|
||||
// credential-free profile, so a token obtained here would be discarded
|
||||
// — after stalling playback behind a handshake against a portal that
|
||||
// may be slow or offline while the CDN is perfectly reachable.
|
||||
//
|
||||
// When it IS needed, the token is resolved from `headerPlaylist`
|
||||
// rather than the row it came from: those are the exact coordinates
|
||||
// the headers claim, so the bearer token and the MAC cookie cannot end
|
||||
// up bound to a different endpoint than the one they are sent to. A
|
||||
// repair override that moved the endpoint is the live case — it
|
||||
// reaches `resolved.portalUrl` but not the raw row, and every other
|
||||
// session consumer (`ensureStalkerSession`, `executeStalkerRequest`)
|
||||
// already authenticates against the override, so this also stops the
|
||||
// resolver from keying the session cache differently and re-shaking.
|
||||
const token =
|
||||
playlist && !crossOriginStream
|
||||
? await this.resolveStalkerPlaybackToken(headerPlaylist)
|
||||
: null;
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
headerPlaylist,
|
||||
token,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
headerPlaylist,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(headerPlaylist);
|
||||
|
||||
return {
|
||||
@@ -565,7 +580,6 @@ export class StreamResolverService {
|
||||
* stay non-fatal: many portals do not gate the stream itself.
|
||||
*/
|
||||
private async resolveStalkerPlaybackToken(
|
||||
playlistId: string,
|
||||
playlist: Playlist | undefined
|
||||
): Promise<string | null> {
|
||||
// The shared mode contract, not the raw flag: a legacy row with an
|
||||
|
||||
Reference in new issue
Block a user