mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(stalker): protocol-correct auth lifecycle (#1354)
This commit is contained in:
1 parent
e197409b10
commit
d2a83164ec
56 files changed
+4807
-709
No files matched your search
+14
-3
@@ -101,16 +101,27 @@
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
<!-- <mat-form-field class="w-full">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="username">{{
|
||||
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="username" formControlName="username" />
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="password">{{
|
||||
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="password" formControlName="password" />
|
||||
</mat-form-field> -->
|
||||
<input
|
||||
matInput
|
||||
type="password"
|
||||
id="password"
|
||||
formControlName="password"
|
||||
/>
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
+179
-4
@@ -2,13 +2,19 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
stalkerSessionFingerprint,
|
||||
StalkerPortalDiscoveryService,
|
||||
StalkerPortalError,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
|
||||
|
||||
describe('StalkerPortalImportComponent identity handling', () => {
|
||||
let component: StalkerPortalImportComponent;
|
||||
let portalDiscovery: { discover: jest.Mock };
|
||||
let store: { dispatch: jest.Mock };
|
||||
let snackBar: { open: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
portalDiscovery = {
|
||||
@@ -23,6 +29,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
snackBar = { open: jest.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -33,7 +40,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
{ provide: Store, useValue: store },
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: { open: jest.fn() },
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -72,7 +79,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
deviceId2: 'DEVICE-ID-2',
|
||||
signature1: 'SIGNATURE-1',
|
||||
signature2: 'SIGNATURE-2',
|
||||
}
|
||||
},
|
||||
// Credentials ride along for portals that answer get_profile
|
||||
// with status 2 (login/password required).
|
||||
{ credentials: { username: '', password: '' } }
|
||||
);
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
@@ -115,7 +125,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/c',
|
||||
'00:1A:79:AA:BB:CC',
|
||||
{}
|
||||
{},
|
||||
{ credentials: { username: '', password: '' } }
|
||||
);
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
@@ -131,6 +142,170 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
expect(playlist.signature2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the entered login and password into discovery', async () => {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-login',
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'00:1A:79:00:00:08',
|
||||
{},
|
||||
{ credentials: { username: 'user', password: 'secret' } }
|
||||
);
|
||||
// Persisted so runtime re-auth can repeat do_auth after the portal
|
||||
// drops the session.
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.username).toBe('user');
|
||||
expect(playlist.password).toBe('secret');
|
||||
});
|
||||
|
||||
it('persists the cadence and the identity the token was negotiated for', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
watchdogTimeoutSeconds: 90,
|
||||
timeslotSeconds: 11,
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-cadence',
|
||||
title: 'Cadence Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerWatchdogTimeout).toBe(90);
|
||||
expect(playlist.stalkerTimeslot).toBe(11);
|
||||
// Without this a later start would re-present the token under an
|
||||
// edited identity.
|
||||
expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('records credentials in the imported session fingerprint', async () => {
|
||||
// Otherwise the first runtime ensureToken() computes a fingerprint
|
||||
// WITH the credentials, mismatches the imported one, and discards the
|
||||
// session the import just established — silently defeating reuse for
|
||||
// exactly the login portals this flow exists for.
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-login-fp',
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerSessionIdentity).toBe(
|
||||
stalkerSessionFingerprint({
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
} as Playlist)
|
||||
);
|
||||
});
|
||||
|
||||
it('records the effective cadence when the portal advertises none', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-default-cadence',
|
||||
title: 'Quiet Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
// Stored absence has to keep meaning "never profiled", or every
|
||||
// later start would re-profile such a portal.
|
||||
expect(playlist.stalkerWatchdogTimeout).toBe(120);
|
||||
expect(playlist.stalkerTimeslot).toBe(0);
|
||||
});
|
||||
|
||||
it("relays the portal's own refusal instead of a generic error", async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
error: new StalkerPortalError('login-required'),
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-refused',
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends the portal's explanation to a blocked refusal", async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
error: new StalkerPortalError(
|
||||
'blocked',
|
||||
'device conflict - device_id mismatch'
|
||||
),
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-blocked',
|
||||
title: 'Blocked Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
// The translate mock returns keys, so both the kind headline and the
|
||||
// portal-message wrapper must be present.
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
|
||||
// A query merely MENTIONING /server/load.php must not make a
|
||||
// panel-style /c URL look canonical and abort the offline import.
|
||||
|
||||
+78
-3
@@ -13,11 +13,15 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
asStalkerPortalError,
|
||||
legacyTransformStalkerPortalUrl,
|
||||
normalizeStalkerPortalInputUrl,
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
StalkerPortalDiscoveryService,
|
||||
StalkerPortalIdentity,
|
||||
normalizeStalkerPortalIdentity,
|
||||
stalkerSessionFingerprint,
|
||||
type StalkerPortalErrorKind,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
createRandomId,
|
||||
@@ -129,18 +133,37 @@ export class StalkerPortalImportComponent {
|
||||
const discovery = await this.portalDiscovery.discover(
|
||||
originalUrl,
|
||||
formValue.macAddress ?? '',
|
||||
stalkerIdentity
|
||||
stalkerIdentity,
|
||||
{
|
||||
credentials: {
|
||||
username: formValue.username ?? '',
|
||||
password: formValue.password ?? '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let portalUrl: string;
|
||||
let isFullStalkerPortal: boolean;
|
||||
let stalkerToken: string | undefined;
|
||||
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
|
||||
// The import profile is the only get_profile some portals ever
|
||||
// see: later starts reuse the token and skip it, so the cadence
|
||||
// it advertises has to be persisted here or the watchdog would
|
||||
// stay on the 120 s default forever. Effective values, so stored
|
||||
// absence keeps meaning "never profiled".
|
||||
let stalkerWatchdogTimeout: number | undefined;
|
||||
let stalkerTimeslot: number | undefined;
|
||||
|
||||
if (discovery.status === 'resolved') {
|
||||
portalUrl = discovery.portalUrl;
|
||||
isFullStalkerPortal = discovery.isFullStalkerPortal;
|
||||
stalkerToken = discovery.token;
|
||||
if (stalkerToken) {
|
||||
stalkerWatchdogTimeout =
|
||||
discovery.watchdogTimeoutSeconds ??
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
|
||||
stalkerTimeslot = discovery.timeslotSeconds ?? 0;
|
||||
}
|
||||
|
||||
if (discovery.accountInfo) {
|
||||
stalkerAccountInfo = {
|
||||
@@ -167,10 +190,13 @@ export class StalkerPortalImportComponent {
|
||||
'[StalkerImport] Authentication failed:',
|
||||
discovery.error
|
||||
);
|
||||
// The portal explains its own refusals — a demanded login, a
|
||||
// rejected one, a device conflict — so relay those words
|
||||
// instead of the generic "check URL and MAC".
|
||||
this.snackBar.open(
|
||||
'Failed to authenticate with portal. Please check URL and MAC address.',
|
||||
this.buildAuthErrorMessage(discovery.error),
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
{ duration: 8000 }
|
||||
);
|
||||
return;
|
||||
} else if (
|
||||
@@ -219,6 +245,25 @@ export class StalkerPortalImportComponent {
|
||||
portalUrl,
|
||||
isFullStalkerPortal,
|
||||
stalkerToken,
|
||||
// What this token was negotiated for: endpoint, identity AND
|
||||
// credentials. Reuse is refused when any of them no longer
|
||||
// matches — and the credentials must be included here, or the
|
||||
// first runtime `ensureToken()` would compute a fingerprint
|
||||
// WITH them, mismatch this one, and throw away the session
|
||||
// the import just established.
|
||||
...(stalkerToken
|
||||
? {
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress: formValue.macAddress ?? '',
|
||||
username: formValue.username ?? '',
|
||||
password: formValue.password ?? '',
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
} as Playlist),
|
||||
}
|
||||
: {}),
|
||||
stalkerWatchdogTimeout,
|
||||
stalkerTimeslot,
|
||||
stalkerAccountInfo,
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
} as Playlist;
|
||||
@@ -230,6 +275,36 @@ export class StalkerPortalImportComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an authentication failure into a message the user can act on.
|
||||
* The portal explains refusals itself (`msg`/`block_msg`, or one of the
|
||||
* documented plain-text bodies); its own words are appended verbatim.
|
||||
*/
|
||||
private buildAuthErrorMessage(error: unknown): string {
|
||||
const portalError = asStalkerPortalError(error);
|
||||
const keyByKind: Record<StalkerPortalErrorKind, string> = {
|
||||
'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
|
||||
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
|
||||
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
|
||||
};
|
||||
const base = this.translate.instant(
|
||||
portalError
|
||||
? keyByKind[portalError.kind]
|
||||
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
|
||||
);
|
||||
|
||||
if (portalError?.portalText) {
|
||||
const detail = this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
{ message: portalError.portalText }
|
||||
);
|
||||
return `${base} ${detail}`;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
|
||||
stalkerSerialNumber?: string;
|
||||
stalkerDeviceId1?: string;
|
||||
|
||||
@@ -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,
|
||||
@@ -25,6 +28,7 @@ describe('StreamResolverService', () => {
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let stalkerSession: {
|
||||
getCachedToken: jest.Mock;
|
||||
ensureToken: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
@@ -44,8 +48,8 @@ describe('StreamResolverService', () => {
|
||||
};
|
||||
stalkerSession = {
|
||||
getCachedToken: jest.fn(() => null),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest.fn().mockResolvedValue({ token: null }),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
epgBridge = {
|
||||
getChannelPrograms: jest.fn(),
|
||||
@@ -792,7 +796,10 @@ describe('StreamResolverService', () => {
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
|
||||
// Playback goes through ensureToken, never the raw cache accessor:
|
||||
// that one skips the endpoint/identity/credential check, so an
|
||||
// edited playlist would put the old account's token in the headers.
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN77' });
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' },
|
||||
});
|
||||
@@ -808,7 +815,7 @@ describe('StreamResolverService', () => {
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1');
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalled();
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:11:22:33:44:55'
|
||||
);
|
||||
@@ -818,6 +825,202 @@ 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('carries a repaired simple→full mode into the playback headers', async () => {
|
||||
// A lazy repair rewrites the portal MODE as well as the URL. The
|
||||
// create_link request runs under the repaired mode and adopts a token,
|
||||
// so reading the stored row's stale `isFullStalkerPortal: false` when
|
||||
// resolving that token would drop the Bearer header from the very
|
||||
// playback the repair was meant to rescue.
|
||||
const stored = {
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://portal.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>;
|
||||
playlistsService.getPlaylistById.mockReturnValue(of(stored));
|
||||
jest.spyOn(
|
||||
TestBed.inject(StalkerPortalRepairService),
|
||||
'applyOverride'
|
||||
).mockImplementation(
|
||||
(playlist: any) =>
|
||||
({
|
||||
...playlist,
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
}) as any
|
||||
);
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'REPAIRED77' });
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://portal.example.com:8080/live/95.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::95',
|
||||
name: 'Repaired Portal Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '95',
|
||||
stalkerCmd: 'ffrt3 http://portal.example.com/media/95.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isFullStalkerPortal: true })
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer REPAIRED77');
|
||||
});
|
||||
|
||||
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
|
||||
// empty — the Bearer-gated stream would 403 in the audio player.
|
||||
// Re-presenting the persisted token costs one idempotent handshake.
|
||||
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,
|
||||
stalkerToken: 'PERSISTED77',
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue(null);
|
||||
stalkerSession.ensureToken.mockResolvedValue({
|
||||
token: 'PERSISTED77',
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::99',
|
||||
name: 'Gated Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '99',
|
||||
radio: 'true',
|
||||
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalled();
|
||||
// The fast path must stay a fast path: no create_link round trip.
|
||||
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77');
|
||||
});
|
||||
|
||||
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',
|
||||
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(null);
|
||||
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',
|
||||
name: 'Gated Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '99',
|
||||
radio: 'true',
|
||||
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'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();
|
||||
});
|
||||
|
||||
it('does not authenticate a simple portal for playback headers', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-2',
|
||||
portalUrl: 'https://simple.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue(null);
|
||||
|
||||
await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-2::99',
|
||||
name: 'Simple Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-2',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '99',
|
||||
radio: 'true',
|
||||
stalkerCmd: 'https://simple.example.com/radio/99.mp3',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Stalker collection playback from a foreign CDN credential-free', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
@@ -1027,10 +1230,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',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Channel,
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
isFullStalkerPortalPlaylist,
|
||||
Playlist,
|
||||
isStalkerStreamCredentialSafe,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -505,7 +506,7 @@ export class StreamResolverService {
|
||||
* them to the stream origin; foreign hosts get the credential-free
|
||||
* profile from the shared classifier).
|
||||
*/
|
||||
private buildStalkerPlayback(
|
||||
private async buildStalkerPlayback(
|
||||
item: UnifiedCollectionItem,
|
||||
playlist: Playlist | undefined,
|
||||
resolved: {
|
||||
@@ -514,24 +515,51 @@ export class StreamResolverService {
|
||||
streamUrl: string;
|
||||
isLive?: boolean;
|
||||
}
|
||||
): ResolvedPortalPlayback {
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
// The item may carry portal/mac overrides for playlists that no
|
||||
// longer exist; the builder only reads header-relevant fields.
|
||||
//
|
||||
// The override is applied to the ROW, not just folded in as the two
|
||||
// resolved coordinates: a repair rewrites the portal MODE as well as
|
||||
// the URL, and a playlist repaired from simple to full would otherwise
|
||||
// keep its stale `isFullStalkerPortal: false` here. The request that
|
||||
// just ran adopted a token under the repaired mode, but the mode-aware
|
||||
// token resolver below would read the stale flag and hand back none —
|
||||
// emitting a same-host gated stream without the Bearer header on the
|
||||
// very playback the repair existed to rescue.
|
||||
const headerPlaylist = {
|
||||
...(playlist ?? {}),
|
||||
...(playlist ? this.portalRepair.applyOverride(playlist) : {}),
|
||||
macAddress: resolved.macAddress,
|
||||
portalUrl: resolved.portalUrl,
|
||||
} as Playlist;
|
||||
const token = this.stalkerSession.getCachedToken(item.playlistId);
|
||||
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 {
|
||||
@@ -550,6 +578,40 @@ export class StreamResolverService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Bearer token a full portal's stream needs.
|
||||
*
|
||||
* A direct-URL radio favorite skips `create_link` entirely, so on a cold
|
||||
* session nothing has authenticated yet and the in-memory cache is empty —
|
||||
* a same-host Bearer-gated stream would then 403 in the built-in audio
|
||||
* player. `ensureToken()` re-presents the persisted token instead, which
|
||||
* is a single idempotent handshake rather than a full re-auth. Failures
|
||||
* stay non-fatal: many portals do not gate the stream itself.
|
||||
*/
|
||||
private async resolveStalkerPlaybackToken(
|
||||
playlist: Playlist | undefined
|
||||
): Promise<string | null> {
|
||||
// The shared mode contract, not the raw flag: a legacy row with an
|
||||
// absent flag but a canonical URL IS a full portal, and reading the
|
||||
// property directly would skip authentication for it — a restored
|
||||
// older backup opens a direct-URL radio favorite with no Bearer.
|
||||
if (!playlist || !isFullStalkerPortalPlaylist(playlist)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always through ensureToken, never the raw cache accessor: that one
|
||||
// skips the endpoint/identity/credential check, so after an edit this
|
||||
// playback path would put the previous account's token into the
|
||||
// stream headers. ensureToken returns the cached token when it is
|
||||
// still valid for this playlist, so a warm session costs nothing.
|
||||
try {
|
||||
const { token } = await this.stalkerSession.ensureToken(playlist);
|
||||
return token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildStalkerRadioChannel(
|
||||
item: UnifiedCollectionItem,
|
||||
playback: ResolvedPortalPlayback
|
||||
|
||||
@@ -2,6 +2,10 @@ export * from './lib/models';
|
||||
export * from './lib/stores';
|
||||
export * from './lib/stalker-account-info.service';
|
||||
export * from './lib/stalker-content-types';
|
||||
export * from './lib/stalker-portal-error';
|
||||
export * from './lib/stalker-response-classification';
|
||||
export * from './lib/stalker-session-store';
|
||||
export * from './lib/stalker-watchdog.controller';
|
||||
export * from './lib/stalker-itv-cache.service';
|
||||
export * from './lib/stalker-live-playback.utils';
|
||||
export * from './lib/stalker-portal-discovery.service';
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { DataService } from '@iptvnator/services';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerAuthApi } from './stalker-auth.api';
|
||||
|
||||
/**
|
||||
* Client-side mirror of the mock server's `auth-handlers.spec.ts`: the same
|
||||
* documented flows — status 2 → do_auth → profile retry, blocked profiles,
|
||||
* idempotent token reuse, not_valid propagation — asserted against the
|
||||
* requests the client actually sends.
|
||||
*/
|
||||
describe('StalkerAuthApi', () => {
|
||||
const portalUrl =
|
||||
'https://portal.example.com/stalker_portal/server/load.php';
|
||||
const macAddress = '00:1A:79:AA:BB:CC';
|
||||
|
||||
let sendIpcEvent: jest.Mock;
|
||||
let api: StalkerAuthApi;
|
||||
|
||||
const logger = {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as ReturnType<typeof createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: {
|
||||
subtle: {
|
||||
digest: jest.fn(
|
||||
async () => new Uint8Array(20).fill(1).buffer
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
sendIpcEvent = jest.fn();
|
||||
api = new StalkerAuthApi(
|
||||
{ sendIpcEvent } as unknown as DataService,
|
||||
logger
|
||||
);
|
||||
});
|
||||
|
||||
function callsByAction(action: string) {
|
||||
return sendIpcEvent.mock.calls.filter(
|
||||
(call) => call[1].params.action === action
|
||||
);
|
||||
}
|
||||
|
||||
it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1', not_valid: 0 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 2, template: 'auth', info: 'Login required' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 0, watchdog_timeout: 120, timeslot: 15 },
|
||||
});
|
||||
|
||||
const result = await api.authenticate(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{ deviceId1: 'DEV-1', deviceId2: 'DEV-2' },
|
||||
{ credentials: { username: 'user', password: 'secret' } }
|
||||
);
|
||||
|
||||
expect(result.token).toBe('TOKEN-1');
|
||||
expect(result.watchdogTimeoutSeconds).toBe(120);
|
||||
expect(result.timeslotSeconds).toBe(15);
|
||||
|
||||
// First profile request is NOT the second auth step.
|
||||
const profiles = callsByAction('get_profile');
|
||||
expect(profiles).toHaveLength(2);
|
||||
expect(profiles[0][1].params.auth_second_step).toBe('0');
|
||||
// The retry after do_auth is.
|
||||
expect(profiles[1][1].params.auth_second_step).toBe('1');
|
||||
|
||||
// do_auth carries the credentials and the device identity.
|
||||
const doAuth = callsByAction('do_auth');
|
||||
expect(doAuth).toHaveLength(1);
|
||||
expect(doAuth[0][1].params).toEqual(
|
||||
expect.objectContaining({
|
||||
login: 'user',
|
||||
password: 'secret',
|
||||
device_id: 'DEV-1',
|
||||
device_id2: 'DEV-2',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws login-required when the portal wants credentials and none are stored', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 2 } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'login-required',
|
||||
});
|
||||
// Empty credentials are never sent to the billing script.
|
||||
expect(callsByAction('do_auth')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('throws login-rejected when do_auth answers {js:false}', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 2 } })
|
||||
.mockResolvedValueOnce({ js: false });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'wrong' },
|
||||
})
|
||||
).rejects.toMatchObject({ kind: 'login-rejected' });
|
||||
});
|
||||
|
||||
it('throws login-rejected when the profile still demands a login after do_auth', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 2 } })
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({ js: { status: 2 } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'secret' },
|
||||
})
|
||||
).rejects.toMatchObject({ kind: 'login-rejected' });
|
||||
});
|
||||
|
||||
it('carries the portal explanation out of every login refusal', async () => {
|
||||
// The actionable sentence ("wrong password", "subscription expired")
|
||||
// rides along with the refusal, and the dialog renders it after the
|
||||
// generic line. Each exit must read the response IN HAND: the retry
|
||||
// explains its own rejection, and quoting the first profile back
|
||||
// would describe a request that already succeeded.
|
||||
const handshake = { js: { token: 'TOKEN-1', random: 'r1' } };
|
||||
const credentials = { username: 'user', password: 'secret' };
|
||||
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce(handshake)
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 2, msg: 'Enter your subscriber login' },
|
||||
});
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'login-required',
|
||||
portalText: 'Enter your subscriber login',
|
||||
});
|
||||
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce(handshake)
|
||||
.mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } })
|
||||
.mockResolvedValueOnce({ js: false });
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, { credentials })
|
||||
).rejects.toMatchObject({
|
||||
kind: 'login-rejected',
|
||||
portalText: 'Login needed',
|
||||
});
|
||||
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce(handshake)
|
||||
.mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } })
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 2, block_msg: 'Subscription expired' },
|
||||
});
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, { credentials })
|
||||
).rejects.toMatchObject({
|
||||
kind: 'login-rejected',
|
||||
// The retry's text, not the first profile's.
|
||||
portalText: 'Subscription expired',
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a blocked profile into the portal explanation', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
status: 1,
|
||||
msg: 'device conflict - device_id mismatch',
|
||||
block_msg: 'Your STB is damaged.<br/> Call the provider.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'blocked',
|
||||
portalText:
|
||||
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a bare {status:1} profile as refused', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 1 } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({ kind: 'blocked' });
|
||||
});
|
||||
|
||||
it('propagates the handshake not_valid flag into not_valid_token', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'NEW-TOKEN', random: 'r1', not_valid: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STALE-TOKEN',
|
||||
});
|
||||
|
||||
const profile = callsByAction('get_profile')[0][1];
|
||||
expect(profile.params.not_valid_token).toBe('1');
|
||||
});
|
||||
|
||||
it('re-presents a stored token and skips get_profile when it comes back unchanged', async () => {
|
||||
sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 0 },
|
||||
});
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STORED-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
token: 'STORED-TOKEN',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
expect(callsByAction('handshake')[0][1].params.token).toBe(
|
||||
'STORED-TOKEN'
|
||||
);
|
||||
expect(callsByAction('get_profile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('runs the full profile when the portal replaces a stale stored token', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'FRESH-TOKEN', random: 'r1', not_valid: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STALE-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
});
|
||||
|
||||
expect(result.token).toBe('FRESH-TOKEN');
|
||||
expect(result.reusedStoredToken).toBe(false);
|
||||
expect(callsByAction('get_profile')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not reuse an echoed token the portal flagged not_valid', async () => {
|
||||
// `not_valid: 1` is the portal saying the presented token is not a
|
||||
// live session. Adopting it because the string matched would be
|
||||
// unrecoverable: nothing writes a replacement back, so every later
|
||||
// start would re-present the same dead token.
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STORED-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
});
|
||||
|
||||
expect(result.reusedStoredToken).toBe(false);
|
||||
expect(callsByAction('get_profile')).toHaveLength(1);
|
||||
expect(callsByAction('get_profile')[0][1].params.not_valid_token).toBe(
|
||||
'1'
|
||||
);
|
||||
});
|
||||
|
||||
it('decodes a stringified status 2 into the login flow', async () => {
|
||||
// Portals routinely stringify numeric fields — the same reason
|
||||
// watchdog_timeout/timeslot accept strings. A strict === would read
|
||||
// "2" as a healthy profile and skip do_auth entirely.
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: '2' } })
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({ js: { status: '0' } });
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'secret' },
|
||||
});
|
||||
|
||||
expect(result.token).toBe('TOKEN-1');
|
||||
expect(callsByAction('do_auth')).toHaveLength(1);
|
||||
expect(
|
||||
callsByAction('get_profile')[1][1].params.auth_second_step
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
it('decodes a stringified status 1 as a refusal', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: '1' } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({ kind: 'blocked' });
|
||||
});
|
||||
|
||||
it('never sends get_profile once the caller abandoned the attempt', async () => {
|
||||
// The harm this prevents: `get_profile` is what adopts a token for
|
||||
// the MAC portal-side, so an abandoned discovery attempt reaching it
|
||||
// after another candidate authenticated would invalidate that
|
||||
// healthy candidate's token.
|
||||
const abandon = new AbortController();
|
||||
sendIpcEvent.mockImplementationOnce(async () => {
|
||||
abandon.abort();
|
||||
return { js: { token: 'TOKEN-1', random: 'r1' } };
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(callsByAction('handshake')).toHaveLength(1);
|
||||
expect(callsByAction('get_profile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not even handshake when already abandoned', async () => {
|
||||
const abandon = new AbortController();
|
||||
abandon.abort();
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechecks the abort after the async prehash, not just before the call', async () => {
|
||||
// getProfile awaits generatePrehash(); an abort landing during that
|
||||
// await would otherwise still let the adopting request go out.
|
||||
const abandon = new AbortController();
|
||||
const digest = globalThis.crypto.subtle.digest as jest.Mock;
|
||||
digest.mockImplementation(async () => {
|
||||
abandon.abort();
|
||||
return new Uint8Array(20).fill(1).buffer;
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', {
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the status-2 login flow when abandoned mid-way', async () => {
|
||||
const abandon = new AbortController();
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { token: 'TOKEN-1', random: 'r1' } })
|
||||
.mockImplementationOnce(async () => {
|
||||
abandon.abort();
|
||||
return { js: { status: 2 } };
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'secret' },
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(callsByAction('do_auth')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('classifies a transport auth-failure marker during get_profile', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stalkerAuthFailure: 'Unauthorized request.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'auth-failed',
|
||||
failureBody: 'Unauthorized request.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
import type { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
extractStalkerAuthFailureBody,
|
||||
STALKER_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
normalizeStalkerPortalIdentity,
|
||||
type StalkerPortalIdentity,
|
||||
} from './stalker-identity.utils';
|
||||
import {
|
||||
combineStalkerPortalMessages,
|
||||
StalkerPortalError,
|
||||
} from './stalker-portal-error';
|
||||
|
||||
export interface StalkerHandshakeResponse {
|
||||
js: {
|
||||
token: string;
|
||||
not_valid?: number;
|
||||
random?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StalkerProfileResponse {
|
||||
js: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
mac?: string;
|
||||
status?: number;
|
||||
msg?: string;
|
||||
block_msg?: string;
|
||||
/** Watchdog cadence in seconds, echoed by the portal (default 120). */
|
||||
watchdog_timeout?: number | string;
|
||||
/** Per-user jitter in seconds so watchdog pings do not align. */
|
||||
timeslot?: number | string;
|
||||
account_info?: {
|
||||
login?: string;
|
||||
expire_date?: number;
|
||||
tariff_plan_name?: string;
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface StalkerAuthConfirmationResponse {
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerPortalCredentials {
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface StalkerAuthenticateOptions {
|
||||
/**
|
||||
* Previously persisted session token. The handshake is idempotent: it is
|
||||
* re-presented, and the portal returns it unchanged while it is still the
|
||||
* MAC's session token.
|
||||
*/
|
||||
storedToken?: string;
|
||||
/** Login/password for portals that answer `get_profile` with status 2. */
|
||||
credentials?: StalkerPortalCredentials;
|
||||
/**
|
||||
* Skip the `get_profile` round trip when the stored token came back
|
||||
* unchanged — an unchanged token is already an adopted session.
|
||||
*/
|
||||
skipProfileWhenReused?: boolean;
|
||||
/**
|
||||
* Abandons the flow between portal calls.
|
||||
*
|
||||
* This is what a timed-out discovery attempt needs: `get_profile` is the
|
||||
* call that adopts a token for the MAC portal-side, so an abandoned
|
||||
* attempt reaching it AFTER another candidate authenticated would
|
||||
* invalidate that healthy candidate's token. Checking the signal before
|
||||
* each call stops the request from being sent, which is the only thing
|
||||
* that prevents it — a request already on the wire is processed by the
|
||||
* server whether or not the client tears the socket down.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface StalkerAuthenticationResult {
|
||||
token: string;
|
||||
accountInfo?: StalkerProfileResponse['js']['account_info'];
|
||||
/**
|
||||
* Raw envelope so callers can apply their own failure checks — endpoint
|
||||
* discovery rejects a candidate whose `get_profile` refuses even though
|
||||
* the handshake succeeded. Absent when the stored token was reused and
|
||||
* no profile was fetched.
|
||||
*/
|
||||
profileResponse?: StalkerProfileResponse;
|
||||
/** True when the stored token was accepted and `get_profile` was skipped. */
|
||||
reusedStoredToken?: boolean;
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
|
||||
interface StalkerHandshakeOutcome {
|
||||
token: string;
|
||||
random: string;
|
||||
/** The portal flagged the presented stored token as no longer valid. */
|
||||
notValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA1 hash using native Web Crypto API
|
||||
* Produces correct 40-character hex hash matching real Stalker clients
|
||||
*/
|
||||
async function sha1(str: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates SHA1 prehash from MAC address
|
||||
* This must match what real Stalker clients send
|
||||
*/
|
||||
async function generatePrehash(macAddress: string): Promise<string> {
|
||||
// Use MAC address with colons, uppercase - this is what most clients use
|
||||
const str = macAddress.toUpperCase();
|
||||
return (await sha1(str)).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random string for metrics
|
||||
*/
|
||||
function generateRandom(): string {
|
||||
const chars = 'abcdef0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Raised when a caller abandoned the flow between portal calls. */
|
||||
export class StalkerAuthAbortedError extends Error {
|
||||
constructor() {
|
||||
super('Stalker authentication was aborted');
|
||||
this.name = 'StalkerAuthAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new StalkerAuthAbortedError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when a response is one of the portal's plain-text auth-failure
|
||||
* bodies (already classified by the transport, or still raw on legacy paths).
|
||||
*/
|
||||
function assertNotAuthFailure(response: unknown): void {
|
||||
const failureBody = extractStalkerAuthFailureBody(response);
|
||||
if (failureBody) {
|
||||
throw new StalkerPortalError('auth-failed', failureBody, failureBody);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw Stalker authentication requests plus the documented login flow.
|
||||
*
|
||||
* Protocol (Stalker 4.9.35): `handshake` issues an idempotent token;
|
||||
* `get_profile` turns it into a session and decodes as: full profile = OK,
|
||||
* `status: 1` = blocked (see `msg`/`block_msg`), `status: 2` = login/password
|
||||
* required → `do_auth`, then `get_profile` again with `auth_second_step=1`.
|
||||
*/
|
||||
export class StalkerAuthApi {
|
||||
constructor(
|
||||
private readonly dataService: DataService,
|
||||
private readonly logger: ReturnType<typeof createLogger>
|
||||
) {}
|
||||
|
||||
async performHandshake(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {},
|
||||
storedToken?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StalkerHandshakeOutcome> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'handshake',
|
||||
// Re-presenting a persisted token is how a real client resumes a
|
||||
// session: an idempotent portal returns it unchanged.
|
||||
token: storedToken ?? '',
|
||||
prehash,
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
// Re-checked here rather than only at the call site: the prehash
|
||||
// above is async, so an abort can land while it is being
|
||||
// computed and the request would still go out.
|
||||
assertNotAborted(signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
assertNotAuthFailure(response);
|
||||
|
||||
if (response?.js?.token) {
|
||||
return {
|
||||
token: response.js.token,
|
||||
random: response.js.random || generateRandom(),
|
||||
notValid: Number(response.js.not_valid ?? 0) === 1,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.error('No token in response');
|
||||
throw new Error('Handshake failed: No token received');
|
||||
} catch (error) {
|
||||
this.logger.error('Handshake error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets account profile information to validate the portal and check
|
||||
* subscription. `authSecondStep` is set only on the retry after `do_auth`;
|
||||
* `notValidToken` propagates the handshake's `not_valid` flag.
|
||||
*/
|
||||
async getProfile(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string,
|
||||
identity: StalkerPortalIdentity,
|
||||
handshakeRandom: string,
|
||||
options: {
|
||||
authSecondStep?: boolean;
|
||||
notValidToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {}
|
||||
): Promise<StalkerProfileResponse> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
|
||||
// Build metrics JSON matching working app
|
||||
const metrics: Record<string, string> = {
|
||||
mac: macAddress,
|
||||
model: 'MAG250',
|
||||
type: 'STB',
|
||||
random: handshakeRandom,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { sn: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Generate prehash for get_profile (same as handshake)
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
hd: '1',
|
||||
not_valid_token: options.notValidToken ? '1' : '0',
|
||||
video_out: 'hdmi',
|
||||
auth_second_step: options.authSecondStep ? '1' : '0',
|
||||
num_banks: '2',
|
||||
metrics: JSON.stringify(metrics),
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { sn: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
...(normalizedIdentity.deviceId1
|
||||
? { device_id: normalizedIdentity.deviceId1 }
|
||||
: {}),
|
||||
...(normalizedIdentity.deviceId2
|
||||
? { device_id2: normalizedIdentity.deviceId2 }
|
||||
: {}),
|
||||
...(normalizedIdentity.signature1
|
||||
? { signature: normalizedIdentity.signature1 }
|
||||
: {}),
|
||||
...(normalizedIdentity.signature2
|
||||
? { signature2: normalizedIdentity.signature2 }
|
||||
: {}),
|
||||
prehash: prehash,
|
||||
stb_type: '',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
// The last possible moment before the call that adopts the MAC's
|
||||
// token portal-side — the prehash above is async, so the caller's
|
||||
// pre-check can be stale by now.
|
||||
assertNotAborted(options.signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
assertNotAuthFailure(response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error('Get profile error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs `do_auth` — the login/password step behind `get_profile`
|
||||
* status 2. Returns the portal's bare boolean verdict.
|
||||
*/
|
||||
async doAuth(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string,
|
||||
credentials: StalkerPortalCredentials,
|
||||
identity: StalkerPortalIdentity = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'do_auth',
|
||||
login: credentials.username ?? '',
|
||||
password: credentials.password ?? '',
|
||||
...(normalizedIdentity.deviceId1
|
||||
? { device_id: normalizedIdentity.deviceId1 }
|
||||
: {}),
|
||||
...(normalizedIdentity.deviceId2
|
||||
? { device_id2: normalizedIdentity.deviceId2 }
|
||||
: {}),
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
assertNotAborted(signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
assertNotAuthFailure(response);
|
||||
|
||||
// do_auth returns { js: true } on success
|
||||
return response?.js === true;
|
||||
} catch (error) {
|
||||
this.logger.error('do_auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full authentication flow: handshake → (optional token reuse) →
|
||||
* get_profile → (status 2 → do_auth → get_profile with
|
||||
* auth_second_step=1) → decoded profile.
|
||||
*/
|
||||
async authenticate(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {},
|
||||
options: StalkerAuthenticateOptions = {}
|
||||
): Promise<StalkerAuthenticationResult> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
|
||||
assertNotAborted(options.signal);
|
||||
const handshake = await this.performHandshake(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
normalizedIdentity,
|
||||
options.storedToken,
|
||||
options.signal
|
||||
);
|
||||
|
||||
// An unchanged stored token is already an adopted session — the
|
||||
// profile round trip is what token persistence saves. `not_valid`
|
||||
// vetoes that shortcut: it is the portal saying the presented token
|
||||
// is not a live session, and adopting it anyway would be
|
||||
// unrecoverable — nothing writes a new token back, so every later
|
||||
// start would re-present the same dead one.
|
||||
if (
|
||||
options.skipProfileWhenReused &&
|
||||
options.storedToken &&
|
||||
!handshake.notValid &&
|
||||
handshake.token === options.storedToken
|
||||
) {
|
||||
return { token: handshake.token, reusedStoredToken: true };
|
||||
}
|
||||
|
||||
// The abandoned-attempt guard that matters: this is the call that
|
||||
// adopts the token for the MAC portal-side.
|
||||
assertNotAborted(options.signal);
|
||||
const profile = await this.getProfile(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake.token,
|
||||
normalizedIdentity,
|
||||
handshake.random,
|
||||
{
|
||||
authSecondStep: false,
|
||||
notValidToken: handshake.notValid,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
|
||||
// The envelope the login flow actually settled on: after a status-2
|
||||
// `do_auth` that is the RETRIED profile, not the first one.
|
||||
const profileResponse = await this.resolveProfile(
|
||||
profile,
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake,
|
||||
normalizedIdentity,
|
||||
options
|
||||
);
|
||||
const resolved = profileResponse?.js;
|
||||
|
||||
return {
|
||||
token: handshake.token,
|
||||
accountInfo: resolved?.account_info,
|
||||
profileResponse,
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: toFiniteNumber(resolved?.watchdog_timeout),
|
||||
timeslotSeconds: toFiniteNumber(resolved?.timeslot),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes `js.status` and drives the status-2 login flow. Throws a
|
||||
* `StalkerPortalError` carrying the portal's own `msg`/`block_msg` text
|
||||
* when the portal refused the session.
|
||||
*/
|
||||
private async resolveProfile(
|
||||
profile: StalkerProfileResponse,
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
handshake: StalkerHandshakeOutcome,
|
||||
identity: StalkerPortalIdentity,
|
||||
options: StalkerAuthenticateOptions
|
||||
): Promise<StalkerProfileResponse> {
|
||||
let settled = profile;
|
||||
let js = profile?.js;
|
||||
|
||||
// Portals routinely stringify numeric fields (the same reason
|
||||
// `watchdog_timeout`/`timeslot` are typed `number | string`), so a
|
||||
// strict `=== 2` would read `"2"` as a healthy profile and skip the
|
||||
// whole login flow.
|
||||
if (toFiniteNumber(js?.status) === 2) {
|
||||
// The portal's own sentence travels WITH the refusal — "wrong
|
||||
// password", "subscription expired", "contact your provider" —
|
||||
// and reading it is the whole reason these errors carry
|
||||
// `portalText`. It is read at each exit rather than once up
|
||||
// front because `js` advances to the retry's response below: the
|
||||
// second refusal explains itself, and quoting the first one back
|
||||
// would describe a request that already succeeded.
|
||||
const loginText = () =>
|
||||
combineStalkerPortalMessages(js?.msg, js?.block_msg);
|
||||
const username = options.credentials?.username?.trim() ?? '';
|
||||
const password = options.credentials?.password ?? '';
|
||||
if (!username || !password) {
|
||||
throw new StalkerPortalError('login-required', loginText());
|
||||
}
|
||||
|
||||
assertNotAborted(options.signal);
|
||||
const accepted = await this.doAuth(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake.token,
|
||||
{ username, password },
|
||||
identity,
|
||||
options.signal
|
||||
);
|
||||
if (!accepted) {
|
||||
// `do_auth` answers a bare `{js: false}`, so the only text
|
||||
// available here is the profile's — which is the one that
|
||||
// asked for the login in the first place.
|
||||
throw new StalkerPortalError('login-rejected', loginText());
|
||||
}
|
||||
|
||||
assertNotAborted(options.signal);
|
||||
const retried = await this.getProfile(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake.token,
|
||||
identity,
|
||||
handshake.random,
|
||||
{
|
||||
authSecondStep: true,
|
||||
notValidToken: handshake.notValid,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
settled = retried;
|
||||
js = retried?.js;
|
||||
if (toFiniteNumber(js?.status) === 2) {
|
||||
throw new StalkerPortalError('login-rejected', loginText());
|
||||
}
|
||||
}
|
||||
|
||||
const portalText = combineStalkerPortalMessages(
|
||||
js?.msg,
|
||||
js?.block_msg
|
||||
);
|
||||
|
||||
if (toFiniteNumber(js?.status) === 1 || portalText) {
|
||||
this.logger.error('Profile error:', portalText ?? 'status 1');
|
||||
throw new StalkerPortalError('blocked', portalText);
|
||||
}
|
||||
|
||||
return settled;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,12 @@ describe('StalkerPortalDiscoveryService', () => {
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'http://ministra.example/server/load.php',
|
||||
MAC,
|
||||
{ serialNumber: 'SN1' }
|
||||
{ serialNumber: 'SN1' },
|
||||
// Import credentials ride along for portals that answer
|
||||
// get_profile with status 2 (login/password required).
|
||||
// A per-attempt abort signal: a timed-out authentication must
|
||||
// not send its get_profile behind the next candidate's back.
|
||||
{ credentials: undefined, signal: expect.any(AbortSignal) }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,7 +195,10 @@ describe('StalkerPortalDiscoveryService', () => {
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'http://gated.example/portal.php',
|
||||
MAC,
|
||||
{}
|
||||
{},
|
||||
// A per-attempt abort signal: a timed-out authentication must
|
||||
// not send its get_profile behind the next candidate's back.
|
||||
{ credentials: undefined, signal: expect.any(AbortSignal) }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -343,4 +351,153 @@ describe('StalkerPortalDiscoveryService', () => {
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts a timed-out authentication before advancing to the next candidate', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
mockProbes({
|
||||
'http://slow.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
|
||||
let captured: AbortSignal | undefined;
|
||||
authenticate.mockImplementation(
|
||||
(_url, _mac, _identity, options) => {
|
||||
captured = options?.signal;
|
||||
// Hangs past the auth budget, like the real symptom.
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
);
|
||||
|
||||
const discovery = service.discover(
|
||||
'http://slow.example/c',
|
||||
MAC
|
||||
);
|
||||
// Let the probe resolve so the auth attempt actually starts.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
jest.advanceTimersByTime(65_000);
|
||||
await Promise.resolve();
|
||||
|
||||
// The abandoned attempt is cancelled, so its get_profile never
|
||||
// goes out to adopt the MAC's token behind a later candidate.
|
||||
expect(captured?.aborted).toBe(true);
|
||||
|
||||
// …and discovery does not RACE it: a request already dispatched
|
||||
// cannot be un-sent, so the run drains it (bounded) instead of
|
||||
// probing the next candidate while it may still land.
|
||||
jest.advanceTimersByTime(15_000);
|
||||
// Outcome shape is unchanged: a timed-out confirmation is still
|
||||
// reported as a refusal of that endpoint.
|
||||
await expect(discovery).resolves.toMatchObject({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'http://slow.example/portal.php',
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('drains an abandoned attempt instead of probing the next candidate', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
// Two candidates both answer "auth required", so a resolved first
|
||||
// attempt would stop the run — only a timed-out one advances.
|
||||
mockProbes({
|
||||
'http://slow.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
'http://slow.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
|
||||
let settleFirst: (() => void) | undefined;
|
||||
authenticate
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
settleFirst = () => reject(new Error('late'));
|
||||
})
|
||||
)
|
||||
.mockResolvedValue({ token: 'SECOND' });
|
||||
|
||||
const discovery = service.discover('http://slow.example/c', MAC);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
jest.advanceTimersByTime(65_000);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// The first attempt is still on the wire: the second candidate
|
||||
// must NOT have been authenticated yet, or its token could be
|
||||
// invalidated by the first one's late get_profile.
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once it settles, the run continues immediately.
|
||||
settleFirst?.();
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(authenticate).toHaveBeenCalledTimes(2);
|
||||
await expect(discovery).resolves.toMatchObject({
|
||||
status: 'resolved',
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops discovery when the drain deadline expires with the attempt still live', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
// Cancellation is cooperative: neither transport can pull a
|
||||
// request off the wire. An attempt still unsettled 80 s in may
|
||||
// yet land its `get_profile`, which adopts the MAC's token
|
||||
// portal-side — so probing on would stake the next candidate's
|
||||
// freshly issued session on a request nobody can recall.
|
||||
mockProbes({
|
||||
'http://hung.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
'http://hung.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
|
||||
// Never settles — not even after the drain.
|
||||
authenticate
|
||||
.mockImplementationOnce(() => new Promise(() => undefined))
|
||||
.mockResolvedValue({ token: 'SECOND' });
|
||||
|
||||
const discovery = service.discover('http://hung.example/c', MAC);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
jest.advanceTimersByTime(65_000);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
jest.advanceTimersByTime(15_000);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
await expect(discovery).resolves.toMatchObject({
|
||||
status: 'auth-rejected',
|
||||
abandonedInFlight: true,
|
||||
});
|
||||
// The second candidate was never authenticated.
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { DataService } from '@iptvnator/services';
|
||||
import { STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerPortalCredentials,
|
||||
StalkerProfileResponse,
|
||||
StalkerSessionService,
|
||||
} from './stalker-session.service';
|
||||
@@ -26,6 +27,13 @@ export interface StalkerPortalEndpointResolution {
|
||||
token?: string;
|
||||
/** Account block from the classification `get_profile` (full portals only). */
|
||||
accountInfo?: StalkerProfileResponse['js']['account_info'];
|
||||
/**
|
||||
* Watchdog cadence the confirming `get_profile` advertised. Persisted at
|
||||
* import because a later start reuses the token and skips the only
|
||||
* response that carries it.
|
||||
*/
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,16 @@ export interface StalkerPortalDiscoveryRejection {
|
||||
status: 'auth-rejected';
|
||||
portalUrl: string;
|
||||
error?: unknown;
|
||||
/**
|
||||
* The abandoned attempt was STILL in flight when the drain deadline
|
||||
* expired, so discovery must not advance. Cancellation is cooperative —
|
||||
* neither transport can pull a request off the wire (the PWA `fetch()`
|
||||
* takes no signal, and the Electron main process runs its HTTP request to
|
||||
* completion) — so an attempt this far past its deadline may still land a
|
||||
* `get_profile`, which adopts the MAC's token portal-side and would
|
||||
* invalidate the session a later candidate had just established.
|
||||
*/
|
||||
abandonedInFlight?: boolean;
|
||||
}
|
||||
|
||||
/** No candidate answered like a Stalker portal (host down or not a portal). */
|
||||
@@ -51,15 +69,42 @@ export type StalkerPortalDiscoveryOutcome =
|
||||
|
||||
/** Per-request guard so a hanging host cannot stall discovery forever. */
|
||||
const PROBE_TIMEOUT_MS = 20_000;
|
||||
/** authenticate() is two sequential requests; give it a matching budget. */
|
||||
const AUTH_TIMEOUT_MS = 45_000;
|
||||
/**
|
||||
* `authenticate()` is up to FOUR sequential requests once a portal answers
|
||||
* `get_profile` with status 2 — handshake, profile, `do_auth`, profile retry —
|
||||
* and the Electron transport allows each non-`create_link` call 15 s. A budget
|
||||
* that only covered two would abort a valid but slow login-required portal
|
||||
* before its final profile and report it as `auth-rejected`.
|
||||
*/
|
||||
const AUTH_TIMEOUT_MS = 4 * 15_000 + 5_000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
/**
|
||||
* How long to wait for an abandoned attempt to settle before probing the next
|
||||
* candidate.
|
||||
*
|
||||
* Aborting cannot un-send a request: if its `get_profile` was already
|
||||
* dispatched, the portal adopts that token regardless of what the client does
|
||||
* to its socket. What we CAN do is refuse to race it — advancing while it is
|
||||
* still in flight is what lets it invalidate the token the next candidate
|
||||
* negotiates. Bounded by one request budget so a genuinely hung host cannot
|
||||
* stall discovery forever; past that the risk is accepted rather than hanging.
|
||||
*/
|
||||
const ABANDONED_DRAIN_MS = 15_000;
|
||||
|
||||
function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
onTimeout?: () => void
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('Stalker portal probe timed out')),
|
||||
timeoutMs
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
// Abandon the underlying operation BEFORE advancing: the timer
|
||||
// only rejects this wrapper, and a late `get_profile` would adopt
|
||||
// the MAC's token portal-side, invalidating whatever the next
|
||||
// candidate just negotiated.
|
||||
onTimeout?.();
|
||||
reject(new Error('Stalker portal probe timed out'));
|
||||
}, timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
@@ -95,7 +140,8 @@ export class StalkerPortalDiscoveryService {
|
||||
async discover(
|
||||
rawUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {}
|
||||
identity: StalkerPortalIdentity = {},
|
||||
options: { credentials?: StalkerPortalCredentials } = {}
|
||||
): Promise<StalkerPortalDiscoveryOutcome> {
|
||||
const candidates = buildStalkerEndpointCandidates(rawUrl);
|
||||
let authRejection: StalkerPortalDiscoveryRejection | null = null;
|
||||
@@ -114,11 +160,15 @@ export class StalkerPortalDiscoveryService {
|
||||
const outcome = await this.confirmFullPortal(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
identity,
|
||||
options.credentials
|
||||
);
|
||||
if (outcome.status === 'resolved') {
|
||||
return outcome;
|
||||
}
|
||||
if (outcome.abandonedInFlight) {
|
||||
return authRejection ?? outcome;
|
||||
}
|
||||
authRejection = authRejection ?? outcome;
|
||||
continue;
|
||||
}
|
||||
@@ -158,11 +208,17 @@ export class StalkerPortalDiscoveryService {
|
||||
const outcome = await this.confirmFullPortal(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
identity,
|
||||
options.credentials
|
||||
);
|
||||
if (outcome.status === 'resolved') {
|
||||
return outcome;
|
||||
}
|
||||
// An attempt still on the wire outranks further probing:
|
||||
// see `abandonedInFlight`.
|
||||
if (outcome.abandonedInFlight) {
|
||||
return authRejection ?? outcome;
|
||||
}
|
||||
// The endpoint is real but refused our credentials;
|
||||
// remember the first such endpoint in case no later
|
||||
// candidate resolves.
|
||||
@@ -184,18 +240,25 @@ export class StalkerPortalDiscoveryService {
|
||||
private async confirmFullPortal(
|
||||
candidate: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity
|
||||
identity: StalkerPortalIdentity,
|
||||
credentials?: StalkerPortalCredentials
|
||||
): Promise<
|
||||
StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection
|
||||
> {
|
||||
// Cooperative cancellation: `authenticate()` checks this before each
|
||||
// portal call, so a timed-out attempt never sends the `get_profile`
|
||||
// that would adopt the MAC's token behind the next candidate's back.
|
||||
const abandon = new AbortController();
|
||||
// Kept so a timed-out attempt can be drained rather than raced.
|
||||
const pending = this.stalkerSession.authenticate(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity,
|
||||
{ credentials, signal: abandon.signal }
|
||||
);
|
||||
try {
|
||||
const auth = await withTimeout(
|
||||
this.stalkerSession.authenticate(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
),
|
||||
AUTH_TIMEOUT_MS
|
||||
const auth = await withTimeout(pending, AUTH_TIMEOUT_MS, () =>
|
||||
abandon.abort()
|
||||
);
|
||||
// A handshake can hand out a token whose `get_profile` still
|
||||
// answers a structured denial (`{js:{error:'Invalid token'}}`);
|
||||
@@ -215,12 +278,41 @@ export class StalkerPortalDiscoveryService {
|
||||
isFullStalkerPortal: true,
|
||||
token: auth.token,
|
||||
accountInfo: auth.accountInfo,
|
||||
watchdogTimeoutSeconds: auth.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: auth.timeslotSeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
// Do not advance while the abandoned attempt may still be on the
|
||||
// wire: its `get_profile` adopts the MAC's token portal-side, so
|
||||
// racing it is exactly what invalidates the next candidate's
|
||||
// freshly issued session.
|
||||
//
|
||||
// Draining is the normal case and usually returns at once — an
|
||||
// aborted attempt settles as soon as its in-flight request errors
|
||||
// out. The deadline exists for the attempt that does not settle,
|
||||
// and reaching it is reported rather than swallowed: continuing
|
||||
// would stake a working candidate's session on a request nobody
|
||||
// can recall.
|
||||
const DRAINED = Symbol('drained');
|
||||
const outcome = await Promise.race([
|
||||
pending.then(
|
||||
() => DRAINED,
|
||||
() => DRAINED
|
||||
),
|
||||
new Promise<undefined>((resolve) =>
|
||||
setTimeout(resolve, ABANDONED_DRAIN_MS)
|
||||
),
|
||||
]);
|
||||
if (outcome !== DRAINED) {
|
||||
this.logger.warn(
|
||||
'Abandoned Stalker authentication is still in flight after the drain deadline; stopping discovery rather than racing it'
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 'auth-rejected',
|
||||
portalUrl: candidate,
|
||||
error,
|
||||
...(outcome === DRAINED ? {} : { abandonedInFlight: true }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
asStalkerPortalError,
|
||||
combineStalkerPortalMessages,
|
||||
StalkerPortalError,
|
||||
stripStalkerPortalMarkup,
|
||||
} from './stalker-portal-error';
|
||||
|
||||
describe('stripStalkerPortalMarkup', () => {
|
||||
it('strips the markup a real block_msg carries', () => {
|
||||
expect(
|
||||
stripStalkerPortalMarkup(
|
||||
'Your STB is damaged.<br/> Call the provider.'
|
||||
)
|
||||
).toBe('Your STB is damaged. Call the provider.');
|
||||
});
|
||||
|
||||
it('collapses whitespace and trims', () => {
|
||||
expect(stripStalkerPortalMarkup(' a \n b ')).toBe('a b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineStalkerPortalMessages', () => {
|
||||
it('joins msg and block_msg', () => {
|
||||
expect(
|
||||
combineStalkerPortalMessages('device conflict', 'STB damaged')
|
||||
).toBe('device conflict — STB damaged');
|
||||
});
|
||||
|
||||
it('drops empty and duplicated parts', () => {
|
||||
expect(combineStalkerPortalMessages('', undefined)).toBeUndefined();
|
||||
expect(combineStalkerPortalMessages('same', 'same')).toBe('same');
|
||||
});
|
||||
});
|
||||
|
||||
describe('asStalkerPortalError', () => {
|
||||
it('recognizes real instances', () => {
|
||||
const error = new StalkerPortalError('blocked', 'text');
|
||||
expect(asStalkerPortalError(error)).toBe(error);
|
||||
});
|
||||
|
||||
it('recognizes a structurally equivalent object across chunk boundaries', () => {
|
||||
const shaped = {
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'login-required',
|
||||
message: 'refused',
|
||||
};
|
||||
expect(asStalkerPortalError(shaped)?.kind).toBe('login-required');
|
||||
});
|
||||
|
||||
it('rejects ordinary errors and non-errors', () => {
|
||||
expect(asStalkerPortalError(new Error('nope'))).toBeNull();
|
||||
expect(asStalkerPortalError('Access denied.')).toBeNull();
|
||||
expect(asStalkerPortalError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Why a Stalker portal refused the session.
|
||||
*
|
||||
* - `login-required` — `get_profile` answered `status: 2`: the portal wants
|
||||
* `do_auth` with a login/password, and none are stored for the playlist.
|
||||
* - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing
|
||||
* script refused the credentials), or the profile still demanded a login
|
||||
* after a successful `do_auth`.
|
||||
* - `blocked` — `get_profile` answered `status: 1`: the account is blocked or
|
||||
* the device identity conflicts. `msg`/`block_msg` explain why.
|
||||
* - `auth-failed` — a request came back as one of the plain-text bodies
|
||||
* (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and
|
||||
* re-authentication did not recover it.
|
||||
*/
|
||||
export type StalkerPortalErrorKind =
|
||||
| 'login-required'
|
||||
| 'login-rejected'
|
||||
| 'blocked'
|
||||
| 'auth-failed';
|
||||
|
||||
/**
|
||||
* `block_msg` routinely carries markup ("Your STB is damaged.<br/> Call the
|
||||
* provider."); strip it before the text reaches a snackbar or error view.
|
||||
*/
|
||||
export function stripStalkerPortalMarkup(text: string): string {
|
||||
return text
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the portal's own `msg`/`block_msg` explanation into one plain-text
|
||||
* line, or returns undefined when the portal sent none.
|
||||
*/
|
||||
export function combineStalkerPortalMessages(
|
||||
msg: string | undefined,
|
||||
blockMsg: string | undefined
|
||||
): string | undefined {
|
||||
const parts = [msg, blockMsg]
|
||||
.map((part) => stripStalkerPortalMarkup(part ?? ''))
|
||||
.filter((part) => part.length > 0);
|
||||
// A duplicated msg/block_msg pair should not be echoed twice.
|
||||
const unique = parts.filter(
|
||||
(part, index) => parts.indexOf(part) === index
|
||||
);
|
||||
return unique.length > 0 ? unique.join(' — ') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A portal-explained authentication failure. `portalText` carries the server's
|
||||
* own words (already markup-stripped) when it sent any — the UI shows them
|
||||
* verbatim instead of a generic "unable to load" message.
|
||||
*/
|
||||
export class StalkerPortalError extends Error {
|
||||
constructor(
|
||||
readonly kind: StalkerPortalErrorKind,
|
||||
readonly portalText?: string,
|
||||
readonly failureBody?: StalkerAuthFailureBody
|
||||
) {
|
||||
super(
|
||||
`Stalker portal refused the session (${kind})` +
|
||||
(portalText ? `: ${portalText}` : '')
|
||||
);
|
||||
this.name = 'StalkerPortalError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognizes a StalkerPortalError across chunk boundaries: `instanceof` is
|
||||
* checked first, but a structurally equivalent object (same name + kind) is
|
||||
* accepted too so lazy-loaded consumers never mis-classify one.
|
||||
*/
|
||||
export function asStalkerPortalError(
|
||||
error: unknown
|
||||
): StalkerPortalError | null {
|
||||
if (error instanceof StalkerPortalError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { name?: unknown }).name === 'StalkerPortalError' &&
|
||||
typeof (error as { kind?: unknown }).kind === 'string'
|
||||
) {
|
||||
return error as StalkerPortalError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
/** When set, the atomic write fails AFTER the transform verified. */
|
||||
let persistError: Error | null;
|
||||
let setCachedToken: jest.Mock;
|
||||
let adoptDiscoveredSession: jest.Mock;
|
||||
let clearCachedToken: jest.Mock;
|
||||
let refreshActiveWatchdogPlaylist: jest.Mock;
|
||||
|
||||
@@ -61,6 +62,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
return of(next);
|
||||
});
|
||||
setCachedToken = jest.fn();
|
||||
adoptDiscoveredSession = jest.fn();
|
||||
clearCachedToken = jest.fn();
|
||||
refreshActiveWatchdogPlaylist = jest.fn();
|
||||
|
||||
@@ -81,6 +83,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
setCachedToken,
|
||||
adoptDiscoveredSession,
|
||||
clearCachedToken,
|
||||
refreshActiveWatchdogPlaylist,
|
||||
},
|
||||
@@ -210,12 +213,13 @@ describe('StalkerPortalRepairService', () => {
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
// The classification handshake already produced a token,
|
||||
// tagged with the playlist as its identity source.
|
||||
expect(setCachedToken).toHaveBeenCalledWith(
|
||||
// The classification handshake already produced a session; it is
|
||||
// adopted whole (token + cadence), tagged with the REPAIRED
|
||||
// configuration as its identity source.
|
||||
expect(adoptDiscoveredSession).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
'TOKEN1',
|
||||
expect.objectContaining({ _id: 'portal-1' })
|
||||
expect.objectContaining({ _id: 'portal-1' }),
|
||||
expect.objectContaining({ token: 'TOKEN1' })
|
||||
);
|
||||
// A repaired ACTIVE playlist must re-sync the watchdog now: a
|
||||
// simple→full flip has to start the keepalive mid-session.
|
||||
@@ -282,6 +286,82 @@ describe('StalkerPortalRepairService', () => {
|
||||
expect(writtenRow).toBeNull();
|
||||
});
|
||||
|
||||
it('discards a repair whose credentials changed while probing', async () => {
|
||||
// Discovery can run for tens of seconds; a login saved meanwhile
|
||||
// means the outcome was negotiated for an account the row no
|
||||
// longer belongs to, so committing it would adopt the wrong
|
||||
// session.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://panel.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'WRONG-ACCOUNT',
|
||||
});
|
||||
// The row the transform sees carries the NEW login.
|
||||
persistedRow = {
|
||||
...MISCLASSIFIED,
|
||||
username: 'edited-mid-probe',
|
||||
} as Playlist;
|
||||
|
||||
expect(
|
||||
await service.repairPortal({
|
||||
...MISCLASSIFIED,
|
||||
username: 'original',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
expect(adoptDiscoveredSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adopts the cadence the repair confirmation discovered', async () => {
|
||||
// Caching the token alone satisfies the retry, so NO
|
||||
// authentication path would ever apply the profile outcome — the
|
||||
// repaired playlist would keep the default cadence until the
|
||||
// token failed or the app restarted.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://panel.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'REPAIRED',
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 9,
|
||||
});
|
||||
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
expect(adoptDiscoveredSession).toHaveBeenCalledWith(
|
||||
MISCLASSIFIED._id,
|
||||
expect.objectContaining({
|
||||
portalUrl: 'http://panel.example/server/load.php',
|
||||
}),
|
||||
{
|
||||
token: 'REPAIRED',
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 9,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the playlist's stored credentials to discovery", async () => {
|
||||
// A login/password portal answers status 2 during confirmation;
|
||||
// without the credentials the probe reports `login-required` and
|
||||
// the source could never be repaired.
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
await service.repairPortal({
|
||||
...MISCLASSIFIED,
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
expect(discover).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
{ credentials: { username: 'user', password: 'secret' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('probes at most once per playlist per session', async () => {
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
@@ -438,7 +518,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
expect(setCachedToken).not.toHaveBeenCalled();
|
||||
expect(adoptDiscoveredSession).not.toHaveBeenCalled();
|
||||
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -322,6 +322,11 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
playlist.portalUrl ?? '',
|
||||
isFullStalkerPortalPlaylist(playlist),
|
||||
stalkerIdentityFingerprint(playlist),
|
||||
// Credentials are part of the discovery outcome now: a probe that
|
||||
// failed on a wrong login must be retried once the login is
|
||||
// corrected, instead of staying declined until the app restarts.
|
||||
playlist.username ?? '',
|
||||
playlist.password ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -336,7 +341,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
const outcome = await this.discovery.discover(
|
||||
playlist.portalUrl ?? '',
|
||||
playlist.macAddress ?? '',
|
||||
getStalkerPortalIdentityFromPlaylist(playlist)
|
||||
getStalkerPortalIdentityFromPlaylist(playlist),
|
||||
{
|
||||
// A login/password portal answers `get_profile` with status 2
|
||||
// during confirmation. Without the stored credentials the
|
||||
// probe reports `login-required` and such a source could
|
||||
// never be repaired, even though the playlist holds a
|
||||
// working login.
|
||||
credentials: {
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (outcome.status !== 'resolved') {
|
||||
@@ -416,14 +432,22 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
?.set(this.repairSourceFingerprint(playlist), override);
|
||||
|
||||
if (outcome.isFullStalkerPortal && outcome.token) {
|
||||
// The classification handshake already authenticated; reuse its
|
||||
// token so the retry does not immediately handshake again. The
|
||||
// playlist itself is the identity source — a repair never
|
||||
// changes WHO the session belongs to, only WHERE it talks.
|
||||
this.stalkerSession.setCachedToken(
|
||||
// The classification handshake already authenticated; adopt the
|
||||
// whole session so the retry does not handshake again AND the
|
||||
// cadence that profile advertised is applied — caching the token
|
||||
// alone would satisfy the retry and leave the repaired playlist
|
||||
// pinging on the default until restart. The identity source is
|
||||
// the REPAIRED configuration: a repair never changes WHO the
|
||||
// session belongs to, only WHERE it talks, and the session is
|
||||
// bound to that endpoint.
|
||||
this.stalkerSession.adoptDiscoveredSession(
|
||||
playlist._id,
|
||||
outcome.token,
|
||||
playlist
|
||||
toStalkerSessionPlaylist(this.applyOverride(playlist)),
|
||||
{
|
||||
token: outcome.token,
|
||||
watchdogTimeoutSeconds: outcome.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: outcome.timeslotSeconds,
|
||||
}
|
||||
);
|
||||
} else if (!outcome.isFullStalkerPortal) {
|
||||
this.stalkerSession.clearCachedToken(playlist._id);
|
||||
@@ -483,7 +507,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
row.portalUrl === playlist.portalUrl &&
|
||||
isFullStalkerPortalPlaylist(row) === sourceMode &&
|
||||
stalkerIdentityFingerprint(row) ===
|
||||
stalkerIdentityFingerprint(playlist)
|
||||
stalkerIdentityFingerprint(playlist) &&
|
||||
// Credentials too, matching repairSourceFingerprint(): discovery
|
||||
// can run for tens of seconds, and a login saved meanwhile means
|
||||
// the outcome was negotiated for an account the row no longer
|
||||
// belongs to — committing it would adopt the wrong session.
|
||||
(row.username ?? '') === (playlist.username ?? '') &&
|
||||
(row.password ?? '') === (playlist.password ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
isStalkerAuthFailureMessage,
|
||||
isStalkerAuthFailureResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Checks whether a response or thrown error indicates a Stalker authorization
|
||||
* failure.
|
||||
*
|
||||
* Every signal here is structural. `isStalkerAuthFailureResponse` covers the
|
||||
* complete set of portal auth failures — the plain-text bodies, the transport
|
||||
* marker Electron mints from them, and the JSON-envelope forms — and is
|
||||
* shared with endpoint discovery and the lazy repair, so a phrase one layer
|
||||
* classifies as an auth failure cannot be ignored by another: the session
|
||||
* would otherwise keep an expired token and every later request fails.
|
||||
*
|
||||
* What is deliberately gone: the old `JSON.stringify(response)` regex sweep.
|
||||
* It matched the phrase anywhere in an arbitrary payload — including inside
|
||||
* legitimate catalog data — and existed only because the raw body reached the
|
||||
* renderer unclassified. The transports classify now, so the shapes are known.
|
||||
*/
|
||||
export function isStalkerAuthorizationFailure(
|
||||
responseOrError: unknown
|
||||
): boolean {
|
||||
if (!responseOrError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = responseOrError as Record<string, unknown>;
|
||||
const message = response?.['message'];
|
||||
|
||||
if (
|
||||
isStalkerAuthFailureResponse(responseOrError) ||
|
||||
isStalkerAuthFailureMessage(message)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTTP auth codes survive the IPC boundary only as message text
|
||||
// (`HTTP Error 401: …`): the custom `status` property is stripped by
|
||||
// ipcRenderer, so the numeric code must be read from the message.
|
||||
if (typeof message === 'string' && /HTTP Error 40[13]\b/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same code on a response object that never crossed IPC.
|
||||
return response?.['status'] === 401 || response?.['status'] === 403;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import type { PlaylistsService } from '@iptvnator/services';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
|
||||
import { stalkerIdentityFingerprint } from './stalker-identity.utils';
|
||||
import type { StalkerAuthenticationResult } from './stalker-auth.api';
|
||||
|
||||
/**
|
||||
* Everything a Stalker session is bound to: the endpoint it was negotiated
|
||||
* against, the device identity, and the account credentials.
|
||||
*
|
||||
* All three halves matter, and each was a real defect when missing:
|
||||
*
|
||||
* - **Endpoint** — `ensureToken()` re-presents tokens in a handshake, so a
|
||||
* playlist repointed at another portal would disclose the previous one's
|
||||
* bearer token to it. Origin alone is not enough: discovery deliberately
|
||||
* preserves tenant base paths (`/tenant-a/…` vs `/tenant-b/…`), which are
|
||||
* different portals on one host.
|
||||
* - **Identity** — an edited MAC/serial must not inherit the old session.
|
||||
* - **Credentials** — for a status-2 portal the login decides which account
|
||||
* the token represents, so changing it must not keep serving the previous
|
||||
* account's session.
|
||||
*
|
||||
* Used for BOTH the in-run cache and the persisted session: an edit applies
|
||||
* without waiting for a restart.
|
||||
*/
|
||||
export function stalkerSessionFingerprint(
|
||||
playlist: Pick<
|
||||
Playlist,
|
||||
| 'portalUrl'
|
||||
| 'macAddress'
|
||||
| 'username'
|
||||
| 'password'
|
||||
| 'stalkerSerialNumber'
|
||||
| 'stalkerDeviceId1'
|
||||
| 'stalkerDeviceId2'
|
||||
| 'stalkerSignature1'
|
||||
| 'stalkerSignature2'
|
||||
>
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
portalEndpoint(playlist.portalUrl),
|
||||
stalkerIdentityFingerprint(playlist as Playlist),
|
||||
playlist.username ?? '',
|
||||
playlist.password ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin AND path, minus query/fragment — the endpoint a session belongs to.
|
||||
*
|
||||
* No attempt is made to treat different handler paths for the same portal
|
||||
* (`…/portal.php` vs `…/server/load.php`) as equivalent. Both sides of the
|
||||
* comparison read the SAME persisted `portalUrl`, so the same portal always
|
||||
* yields the same key; the value changes only on a real edit or repair, and
|
||||
* both of those re-authenticate anyway. An equivalence rule would add
|
||||
* machinery whose only failure mode is aliasing two genuinely different
|
||||
* endpoints — the exact thing this key exists to prevent.
|
||||
*/
|
||||
function portalEndpoint(portalUrl: string | undefined): string {
|
||||
if (!portalUrl) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(portalUrl);
|
||||
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
||||
} catch {
|
||||
// Unparseable: fall back to the raw string so a change is still a
|
||||
// change — never to a constant, which would alias every endpoint.
|
||||
return portalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session facts persisted with the playlist between app starts.
|
||||
*
|
||||
* `identityFingerprint` is what makes the token safe to re-present: it records
|
||||
* the identity the session was negotiated for, so an edited MAC or serial
|
||||
* cannot inherit the previous session.
|
||||
*/
|
||||
export interface PersistedStalkerSession {
|
||||
token?: string;
|
||||
identityFingerprint?: string;
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and writes the Stalker session persisted on the playlist row.
|
||||
*
|
||||
* Kept out of the session service because it is the only part that needs the
|
||||
* persistence stack, which is resolved lazily: the service is constructed
|
||||
* during bootstrap, and pulling `PlaylistsService` in eagerly would both
|
||||
* widen its dependency graph and risk a DI cycle.
|
||||
*/
|
||||
export class StalkerSessionStore {
|
||||
constructor(
|
||||
private readonly resolvePlaylistsService: () => PlaylistsService,
|
||||
private readonly logger: ReturnType<typeof createLogger>
|
||||
) {}
|
||||
|
||||
/** Reads a playlist row through the lazily-resolved persistence stack. */
|
||||
async readRow(playlistId: string): Promise<Playlist | undefined> {
|
||||
const row = await firstValueFrom(
|
||||
this.resolvePlaylistsService().getPlaylistById(playlistId)
|
||||
);
|
||||
return (row as Playlist) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the session persisted with the playlist: the token, the identity
|
||||
* it was negotiated for, and the watchdog cadence the portal advertised.
|
||||
*
|
||||
* The token is only offered for reuse when its identity still matches —
|
||||
* re-presenting one minted for an edited MAC would pair a new identity
|
||||
* with an old session, exactly what the in-memory cache guards against.
|
||||
*/
|
||||
async read(
|
||||
playlist: Playlist,
|
||||
fingerprint: string
|
||||
): Promise<PersistedStalkerSession> {
|
||||
const fromPlaylist: PersistedStalkerSession = {
|
||||
token: playlist.stalkerToken || undefined,
|
||||
identityFingerprint: playlist.stalkerSessionIdentity,
|
||||
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
|
||||
timeslotSeconds: playlist.stalkerTimeslot,
|
||||
};
|
||||
|
||||
// Shortcut only when the object carries the cadence too. Taking it on
|
||||
// the token alone would silently drop a persisted cadence for any
|
||||
// playlist shape that copies the token without the timing fields.
|
||||
const stored =
|
||||
fromPlaylist.token &&
|
||||
fromPlaylist.watchdogTimeoutSeconds !== undefined
|
||||
? fromPlaylist
|
||||
: await this.readFromRow(playlist, fromPlaylist);
|
||||
|
||||
// A token with NO recorded fingerprint is unverified, not trusted:
|
||||
// playlists written before the fingerprint existed carry one, and
|
||||
// re-presenting it after an endpoint or identity edit is exactly the
|
||||
// disclosure the fingerprint prevents. Such a row has no cadence
|
||||
// either, so it already owes a full profile — refusing the token
|
||||
// costs it nothing, and the write-back then records the fingerprint.
|
||||
if (stored.token && stored.identityFingerprint !== fingerprint) {
|
||||
return { ...stored, token: undefined };
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async readFromRow(
|
||||
playlist: Playlist,
|
||||
fallback: PersistedStalkerSession
|
||||
): Promise<PersistedStalkerSession> {
|
||||
try {
|
||||
const stored = await this.readRow(playlist._id);
|
||||
return {
|
||||
// The row is authoritative, but a meta that carried a token
|
||||
// the row has not seen yet must not lose it.
|
||||
token: stored?.stalkerToken || fallback.token,
|
||||
identityFingerprint:
|
||||
stored?.stalkerSessionIdentity ??
|
||||
fallback.identityFingerprint,
|
||||
watchdogTimeoutSeconds: stored?.stalkerWatchdogTimeout,
|
||||
timeslotSeconds: stored?.stalkerTimeslot,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.debug('Persisted session lookup failed:', error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagates a successful authentication into session-side state: the
|
||||
* watchdog cadence, and the write-back.
|
||||
*
|
||||
* Reusing a stored token skips the `get_profile` that carries the
|
||||
* cadence, so the persisted cadence is applied instead — otherwise a
|
||||
* portal advertising a non-default `watchdog_timeout` would sit on the
|
||||
* 120 s fallback for the whole session.
|
||||
*/
|
||||
applyAuthenticationOutcome(
|
||||
playlistId: string,
|
||||
result: StalkerAuthenticationResult,
|
||||
stored: PersistedStalkerSession,
|
||||
fingerprint: string,
|
||||
watchdog: {
|
||||
applyProfileTiming: (
|
||||
playlistId: string,
|
||||
timing: {
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
): void {
|
||||
if (result.reusedStoredToken) {
|
||||
watchdog.applyProfileTiming(playlistId, {
|
||||
watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: stored.timeslotSeconds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
watchdog.applyProfileTiming(playlistId, {
|
||||
watchdogTimeoutSeconds: result.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: result.timeslotSeconds,
|
||||
});
|
||||
this.write(playlistId, result, stored, fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a renegotiated session back, best effort.
|
||||
*
|
||||
* The EFFECTIVE cadence is stored, not the raw one: a portal that
|
||||
* advertises nothing must still leave a value behind, or "no cadence
|
||||
* stored" would keep meaning "never profiled" and every start would
|
||||
* re-profile it. Stored absence therefore means exactly one thing — this
|
||||
* playlist has never completed a profile.
|
||||
*
|
||||
* Returns the cadence that was applied, so the caller can drive the
|
||||
* watchdog with the same numbers.
|
||||
*/
|
||||
write(
|
||||
playlistId: string,
|
||||
result: StalkerAuthenticationResult,
|
||||
stored: PersistedStalkerSession,
|
||||
fingerprint: string
|
||||
): void {
|
||||
const watchdogTimeout =
|
||||
result.watchdogTimeoutSeconds ??
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
|
||||
const timeslot = result.timeslotSeconds ?? 0;
|
||||
const changed =
|
||||
result.token !== stored.token ||
|
||||
fingerprint !== stored.identityFingerprint ||
|
||||
watchdogTimeout !== stored.watchdogTimeoutSeconds ||
|
||||
timeslot !== stored.timeslotSeconds;
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void firstValueFrom(
|
||||
this.resolvePlaylistsService().updateStalkerSession(
|
||||
playlistId,
|
||||
{
|
||||
stalkerToken: result.token,
|
||||
stalkerSessionIdentity: fingerprint,
|
||||
stalkerWatchdogTimeout: watchdogTimeout,
|
||||
stalkerTimeslot: timeslot,
|
||||
}
|
||||
)
|
||||
).catch((error) => {
|
||||
this.logger.debug('Session write-back failed:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
// Persistence stack unavailable (e.g. isolated tests, or a DI
|
||||
// cycle at this point in bootstrap). The session still works for
|
||||
// this run; only the cross-restart reuse is lost.
|
||||
this.logger.debug('Session write-back unavailable:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalError } from './stalker-portal-error';
|
||||
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
|
||||
import { stalkerSessionFingerprint } from './stalker-session-store';
|
||||
import {
|
||||
STALKER_SERIAL_NUMBER,
|
||||
StalkerProfileResponse,
|
||||
@@ -186,8 +189,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
|
||||
);
|
||||
|
||||
const oldAuth = service.ensureToken(playlistA);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await Promise.resolve();
|
||||
// ensureToken reads the persisted session before the handshake, so
|
||||
// wait for the transport call rather than a fixed microtask count.
|
||||
while (pendingResolvers.length === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
|
||||
const editedIdentity = {
|
||||
@@ -198,10 +203,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
|
||||
|
||||
// Settle the OLD identity's handshake + profile.
|
||||
pendingResolvers[0]({ js: { token: 'TOKEN-OLD', random: 'r' } });
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
while (pendingResolvers.length < 2) {
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
pendingResolvers[1]?.({ js: {} });
|
||||
pendingResolvers[1]({ js: {} });
|
||||
await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' });
|
||||
|
||||
// The edited identity re-enters and negotiates its OWN session.
|
||||
@@ -285,13 +290,19 @@ describe('StalkerSessionService identity-tagged token cache', () => {
|
||||
service.setCachedToken('portal-1', 'DEAD', playlistA);
|
||||
sendIpcEvent.mockResolvedValue('Authorization failed.');
|
||||
|
||||
// The typed refusal replaced the generic "Authorization failed after
|
||||
// retry": callers need the portal's own reason to show the user.
|
||||
await expect(
|
||||
service.makeAuthenticatedRequest(
|
||||
playlistA,
|
||||
{ action: 'get_events' },
|
||||
false
|
||||
)
|
||||
).rejects.toThrow('Authorization failed after retry');
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'auth-failed',
|
||||
failureBody: 'Authorization failed.',
|
||||
});
|
||||
|
||||
// Leaving the dead token cached would hand it to the next caller.
|
||||
expect(service.getCachedToken('portal-1')).toBeNull();
|
||||
@@ -374,6 +385,10 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
|
||||
let service: StalkerSessionService;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let playlistsService: {
|
||||
getPlaylistById: jest.Mock;
|
||||
updateStalkerSession: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
@@ -390,11 +405,16 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
|
||||
};
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn().mockReturnValue(of(undefined)),
|
||||
updateStalkerSession: jest.fn().mockReturnValue(of(null)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerSessionService,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -484,13 +504,19 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
stalkerSignature2: 'SIGNATURE-2',
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {
|
||||
serialNumber: 'CUSTOMSN123',
|
||||
deviceId1: 'DEVICE-ID-1',
|
||||
deviceId2: 'DEVICE-ID-2',
|
||||
signature1: 'SIGNATURE-1',
|
||||
signature2: 'SIGNATURE-2',
|
||||
});
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{
|
||||
serialNumber: 'CUSTOMSN123',
|
||||
deviceId1: 'DEVICE-ID-1',
|
||||
deviceId2: 'DEVICE-ID-2',
|
||||
signature1: 'SIGNATURE-1',
|
||||
signature2: 'SIGNATURE-2',
|
||||
},
|
||||
// No cadence stored for this playlist, so the profile must run.
|
||||
expect.objectContaining({ skipProfileWhenReused: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the legacy default serial number as absent during ensureToken', async () => {
|
||||
@@ -507,7 +533,12 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
} as Playlist);
|
||||
|
||||
expect(result.serialNumber).toBeUndefined();
|
||||
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {});
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ skipProfileWhenReused: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => {
|
||||
@@ -536,8 +567,9 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
const refresh = service.refreshAccountProfile(playlist);
|
||||
|
||||
// Two handshakes must never overlap: on strict portals the second
|
||||
// would invalidate the first one's token.
|
||||
await Promise.resolve();
|
||||
// would invalidate the first one's token. (Macrotask flush: the
|
||||
// persisted-token lookup adds awaits before authenticate fires.)
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseFirst({ token: 'session-token' });
|
||||
@@ -574,7 +606,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
const first = service.refreshAccountProfile(playlist);
|
||||
const second = service.refreshAccountProfile(playlist);
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
releases[0]({ token: 'session-token' });
|
||||
@@ -708,6 +740,581 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
expect(accountInfo).toEqual({ login: 'user-2' });
|
||||
});
|
||||
|
||||
it('re-presents the token persisted on the playlist during ensureToken', async () => {
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'STORED-TOKEN' });
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-7',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
stalkerToken: 'STORED-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress,
|
||||
} as Playlist),
|
||||
// With the cadence present the playlist is self-sufficient, so no
|
||||
// row read is needed.
|
||||
stalkerWatchdogTimeout: 120,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.getPlaylistById).not.toHaveBeenCalled();
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({
|
||||
storedToken: 'STORED-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the stored playlist row for the persisted token', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-8',
|
||||
stalkerToken: 'ROW-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress,
|
||||
} as Playlist),
|
||||
stalkerWatchdogTimeout: 120,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'ROW-TOKEN', reusedStoredToken: true });
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-8',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
|
||||
'playlist-8'
|
||||
);
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: 'ROW-TOKEN' })
|
||||
);
|
||||
// A reused token was not renegotiated — nothing to write back.
|
||||
expect(playlistsService.updateStalkerSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a newly negotiated token back to the playlist', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-9', stalkerToken: 'OLD-TOKEN' } as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'NEW-TOKEN',
|
||||
reusedStoredToken: false,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-9',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-9',
|
||||
expect.objectContaining({ stalkerToken: 'NEW-TOKEN' })
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the persisted watchdog cadence when the token is reused', async () => {
|
||||
// Reuse skips the get_profile that carries the cadence, so without
|
||||
// the persisted values the watchdog would sit on its 120 s default
|
||||
// for the whole session — for a portal that advertised 60 s that is
|
||||
// slower than before this feature existed.
|
||||
const watchdog = (
|
||||
service as unknown as {
|
||||
watchdog: { applyProfileTiming: jest.Mock };
|
||||
}
|
||||
).watchdog;
|
||||
jest.spyOn(watchdog, 'applyProfileTiming');
|
||||
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-12',
|
||||
stalkerToken: 'REUSED',
|
||||
stalkerWatchdogTimeout: 60,
|
||||
stalkerTimeslot: 7,
|
||||
} as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'REUSED',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-12',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
|
||||
'playlist-12',
|
||||
{ watchdogTimeoutSeconds: 60, timeslotSeconds: 7 }
|
||||
);
|
||||
});
|
||||
|
||||
it('profiles a legacy token-only playlist and refuses its unverified token', async () => {
|
||||
// A playlist written before this change has a token but no recorded
|
||||
// fingerprint, so nothing proves which endpoint/identity it belongs
|
||||
// to — re-presenting it after an edit is the disclosure the
|
||||
// fingerprint exists to prevent. It owes a full profile anyway (no
|
||||
// cadence), so refusing the token costs nothing and the write-back
|
||||
// then records the fingerprint.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-16', stalkerToken: 'LEGACY' } as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({
|
||||
token: 'LEGACY',
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 5,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-16',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({
|
||||
storedToken: undefined,
|
||||
skipProfileWhenReused: false,
|
||||
})
|
||||
);
|
||||
// ...and the learned cadence is persisted, so the next start skips.
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-16',
|
||||
expect.objectContaining({
|
||||
stalkerWatchdogTimeout: 60,
|
||||
stalkerTimeslot: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('records the effective cadence when the portal advertises none', async () => {
|
||||
// Otherwise "no cadence stored" would keep meaning "never profiled"
|
||||
// and such a portal would be re-profiled on every single start.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-17', stalkerToken: 'LEGACY' } as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'LEGACY',
|
||||
reusedStoredToken: false,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-17',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-17',
|
||||
expect.objectContaining({
|
||||
stalkerWatchdogTimeout:
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
stalkerTimeslot: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a cached and persisted session after the login changed', async () => {
|
||||
// For a status-2 portal the login decides WHICH account the token
|
||||
// represents, so serving the old session would keep the user on the
|
||||
// previous account indefinitely.
|
||||
const before = {
|
||||
_id: 'playlist-login-change',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
username: 'old-user',
|
||||
password: 'old-pass',
|
||||
} as Playlist;
|
||||
service.setCachedToken(before._id, 'OLD-ACCOUNT-TOKEN', before);
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...before,
|
||||
stalkerToken: 'OLD-ACCOUNT-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(before),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'NEW', reusedStoredToken: false });
|
||||
|
||||
const result = await service.ensureToken({
|
||||
...before,
|
||||
username: 'new-user',
|
||||
password: 'new-pass',
|
||||
} as Playlist);
|
||||
|
||||
// Neither the in-run cache nor the persisted token is reused.
|
||||
expect(result.token).toBe('NEW');
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({
|
||||
storedToken: undefined,
|
||||
credentials: { username: 'new-user', password: 'new-pass' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a persisted token across tenants on the SAME host', async () => {
|
||||
// Discovery deliberately preserves tenant base paths, so two portals
|
||||
// can share an origin. An origin-only key would send tenant A's
|
||||
// bearer to tenant B.
|
||||
const tenantA = {
|
||||
_id: 'playlist-tenant',
|
||||
portalUrl: 'https://panel.example.com/tenant-a/server/load.php',
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...tenantA,
|
||||
stalkerToken: 'TENANT-A-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(tenantA),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
|
||||
|
||||
await service.ensureToken({
|
||||
...tenantA,
|
||||
portalUrl: 'https://panel.example.com/tenant-b/server/load.php',
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'https://panel.example.com/tenant-b/server/load.php',
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the session for the same endpoint spelled with a trailing slash', async () => {
|
||||
// Normalisation must not turn a cosmetic difference into a forced
|
||||
// re-authentication.
|
||||
const portal = {
|
||||
_id: 'playlist-slash',
|
||||
portalUrl: 'https://panel.example.com/tenant-a/server/load.php',
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
expect(
|
||||
stalkerSessionFingerprint({
|
||||
...portal,
|
||||
portalUrl: 'https://panel.example.com/tenant-a/server/load.php/',
|
||||
} as Playlist)
|
||||
).toBe(stalkerSessionFingerprint(portal));
|
||||
});
|
||||
|
||||
it('refuses a persisted token when the playlist was repointed at another host', async () => {
|
||||
// ensureToken re-presents persisted tokens in a handshake, so an
|
||||
// identity-only check would disclose the previous portal's bearer
|
||||
// token to an unrelated server.
|
||||
const original = {
|
||||
_id: 'playlist-moved',
|
||||
portalUrl: 'https://old.example.com/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...original,
|
||||
stalkerToken: 'OLD-HOST-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(original),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
|
||||
|
||||
await service.ensureToken({
|
||||
...original,
|
||||
portalUrl: 'https://new.example.com/stalker_portal/server/load.php',
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'https://new.example.com/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a persisted token minted for a different identity', async () => {
|
||||
// The playlist was edited after the token was issued. Re-presenting
|
||||
// it would pair the new identity with the old session — the same bug
|
||||
// class the in-memory cache guards against, just across a restart.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-identity',
|
||||
stalkerToken: 'OLD-IDENTITY-TOKEN',
|
||||
stalkerSessionIdentity: 'not-the-current-fingerprint',
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-identity',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses a persisted token whose identity still matches', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-identity-ok',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...playlist,
|
||||
stalkerToken: 'SAME-IDENTITY-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(playlist),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({
|
||||
token: 'SAME-IDENTITY-TOKEN',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
|
||||
await service.ensureToken(playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: 'SAME-IDENTITY-TOKEN' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the stored row when a token arrives without its cadence', async () => {
|
||||
// A playlist shape that copies the token but not the timing fields
|
||||
// must not silently downgrade the portal to the 120 s default.
|
||||
const watchdog = (
|
||||
service as unknown as {
|
||||
watchdog: { applyProfileTiming: jest.Mock };
|
||||
}
|
||||
).watchdog;
|
||||
jest.spyOn(watchdog, 'applyProfileTiming');
|
||||
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-15',
|
||||
stalkerToken: 'REUSED',
|
||||
stalkerWatchdogTimeout: 45,
|
||||
stalkerTimeslot: 3,
|
||||
} as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'REUSED',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-15',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
// Token present, cadence absent — the shortcut must not fire.
|
||||
stalkerToken: 'REUSED',
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
|
||||
'playlist-15'
|
||||
);
|
||||
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
|
||||
'playlist-15',
|
||||
{ watchdogTimeoutSeconds: 45, timeslotSeconds: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('persists a freshly decoded watchdog cadence with the token', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-13' } as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'NEW-TOKEN',
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: 90,
|
||||
timeslotSeconds: 12,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-13',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-13',
|
||||
expect.objectContaining({
|
||||
stalkerToken: 'NEW-TOKEN',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 12,
|
||||
// The identity the session was negotiated for, so a later
|
||||
// start can refuse to reuse it after an identity edit.
|
||||
stalkerSessionIdentity: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites the session when only the cadence changed', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-14',
|
||||
stalkerToken: 'SAME',
|
||||
stalkerWatchdogTimeout: 120,
|
||||
} as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'SAME',
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: 45,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-14',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-14',
|
||||
expect.objectContaining({ stalkerWatchdogTimeout: 45 })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the portal failure body when the retry also fails', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-10',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
jest.spyOn(service, 'ensureToken').mockResolvedValue({
|
||||
token: 'token-1',
|
||||
});
|
||||
// The transport marker for a blocked account, on both attempts.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
stalkerAuthFailure: 'Access denied.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.makeAuthenticatedRequest(playlist, {
|
||||
type: 'vod',
|
||||
action: 'get_categories',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'auth-failed',
|
||||
failureBody: 'Access denied.',
|
||||
});
|
||||
// One retry happened before giving up.
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('recognizes the raw PWA plain-text failure body and retries', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-11',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
jest.spyOn(service, 'ensureToken')
|
||||
.mockResolvedValueOnce({ token: 'stale' })
|
||||
.mockResolvedValueOnce({ token: 'fresh' });
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce('Unauthorized request.')
|
||||
.mockResolvedValueOnce({ js: { data: [] } });
|
||||
|
||||
await expect(
|
||||
service.makeAuthenticatedRequest(playlist, {
|
||||
type: 'itv',
|
||||
action: 'get_ordered_list',
|
||||
})
|
||||
).resolves.toEqual({ js: { data: [] } });
|
||||
});
|
||||
|
||||
it('throws StalkerPortalError with the portal text when auth is refused', async () => {
|
||||
// Blocked account: get_profile answers status 1 with msg/block_msg.
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'token-1', random: 'random-1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
status: 1,
|
||||
msg: 'device conflict - device_id mismatch',
|
||||
block_msg: 'Your STB is damaged.<br/> Call the provider.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'blocked',
|
||||
portalText:
|
||||
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps StalkerPortalError recognizable via instanceof', () => {
|
||||
expect(new StalkerPortalError('blocked')).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('passes an explicit serial into the initial handshake request', async () => {
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
export interface StalkerPendingAuth {
|
||||
promise: Promise<{ token: string; serialNumber?: string }>;
|
||||
identityFingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session state for the current app run, keyed by playlist ID and
|
||||
* tagged with the identity fingerprint the session was negotiated for.
|
||||
*
|
||||
* The tagging is the point: a playlist whose endpoint, identity or login was
|
||||
* edited must never inherit the previous session — neither the cached token
|
||||
* nor an authentication still in flight for the old one. The key comes from
|
||||
* `stalkerSessionFingerprint`, so an edit applies without a restart.
|
||||
*/
|
||||
export class StalkerTokenCache {
|
||||
private readonly tokens = new Map<
|
||||
string,
|
||||
{ token: string; identityFingerprint: string }
|
||||
>();
|
||||
private readonly pending = new Map<string, StalkerPendingAuth>();
|
||||
|
||||
/**
|
||||
* The cached token regardless of identity. Playback fast paths that
|
||||
* cannot supply an identity use this; `takeFor` is the checked accessor.
|
||||
*/
|
||||
get(playlistId: string): string | null {
|
||||
return this.tokens.get(playlistId)?.token || null;
|
||||
}
|
||||
|
||||
set(playlistId: string, token: string, sessionFingerprint: string): void {
|
||||
this.tokens.set(playlistId, {
|
||||
token,
|
||||
identityFingerprint: sessionFingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
clear(playlistId: string): void {
|
||||
this.tokens.delete(playlistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached token when it was negotiated for this exact identity. A
|
||||
* mismatch retires the entry and returns null, so the caller
|
||||
* authenticates as the current identity instead of pairing a new one
|
||||
* with an old session.
|
||||
*/
|
||||
takeFor(playlistId: string, fingerprint: string): string | null {
|
||||
const cached = this.tokens.get(playlistId);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (cached.identityFingerprint === fingerprint) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
this.clear(playlistId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached token only while it is still the one that just
|
||||
* failed. A request dispatched with the previous token can see its
|
||||
* authorization failure arrive after a profile refresh has already
|
||||
* cached a fresh token — deleting blindly would kill that fresh token
|
||||
* and trigger a cascade of competing handshakes on strict portals.
|
||||
*/
|
||||
retireFailed(playlistId: string, failedToken: string | null): void {
|
||||
if (failedToken && this.get(playlistId) === failedToken) {
|
||||
this.clear(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
getPending(playlistId: string): StalkerPendingAuth | undefined {
|
||||
return this.pending.get(playlistId);
|
||||
}
|
||||
|
||||
setPending(playlistId: string, entry: StalkerPendingAuth): void {
|
||||
this.pending.set(playlistId, entry);
|
||||
}
|
||||
|
||||
clearPending(playlistId: string): void {
|
||||
this.pending.delete(playlistId);
|
||||
}
|
||||
|
||||
/** Retires the pending slot only when it is still the caller's own. */
|
||||
clearPendingIf(playlistId: string, entry: StalkerPendingAuth): void {
|
||||
if (this.pending.get(playlistId) === entry) {
|
||||
this.pending.delete(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerWatchdogController } from './stalker-watchdog.controller';
|
||||
|
||||
describe('StalkerWatchdogController', () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-1',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
let sendRequest: jest.Mock;
|
||||
let readPersistedPlaylist: jest.Mock;
|
||||
let controller: StalkerWatchdogController;
|
||||
|
||||
const logger = {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as ReturnType<typeof createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
sendRequest = jest.fn().mockResolvedValue({ js: {} });
|
||||
// The row is the source of truth for each ping; specs that care
|
||||
// about mid-session edits override this per test.
|
||||
readPersistedPlaylist = jest.fn().mockResolvedValue(playlist);
|
||||
controller = new StalkerWatchdogController({
|
||||
sendRequest,
|
||||
readPersistedPlaylist,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.setActivePlaylist(null);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** Lets the async sendPing settle between timer advances. */
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
it('pings immediately with init=1, then every 120 s by default', async () => {
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
playlist,
|
||||
expect.objectContaining({
|
||||
action: 'get_events',
|
||||
type: 'watchdog',
|
||||
init: '1',
|
||||
})
|
||||
);
|
||||
|
||||
// The legacy cadence was 25 s — nothing may fire that early.
|
||||
jest.advanceTimersByTime(25_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(95_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2);
|
||||
expect(sendRequest.mock.calls[1][1]).toEqual(
|
||||
expect.objectContaining({ init: '0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reschedules to the profile cadence with the timeslot offset', async () => {
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
controller.applyProfileTiming(playlist._id, {
|
||||
watchdogTimeoutSeconds: 90,
|
||||
timeslotSeconds: 10,
|
||||
});
|
||||
|
||||
// Timeslot offset: nothing during the first 10 s...
|
||||
jest.advanceTimersByTime(9_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
|
||||
// ...then the 90 s cadence starts.
|
||||
jest.advanceTimersByTime(91_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(90_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the stored cadence when the watchdog restarts', async () => {
|
||||
controller.applyProfileTiming(playlist._id, {
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 0,
|
||||
});
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(60_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clamps a garbage watchdog_timeout instead of adopting it', async () => {
|
||||
controller.applyProfileTiming(playlist._id, {
|
||||
watchdogTimeoutSeconds: 1,
|
||||
timeslotSeconds: 0,
|
||||
});
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
// Clamped to the 30 s floor — a 1 s cadence would hammer the portal.
|
||||
jest.advanceTimersByTime(29_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(1_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs and continues when a ping fails — never retries or escalates', async () => {
|
||||
sendRequest.mockRejectedValue(new Error('portal down'));
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No immediate retry: the next attempt is the next scheduled tick.
|
||||
jest.advanceTimersByTime(119_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
jest.advanceTimersByTime(1_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops pinging when the active playlist is cleared', async () => {
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
controller.setActivePlaylist(null);
|
||||
jest.advanceTimersByTime(600_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never pings for a simple (non-full) portal', async () => {
|
||||
controller.setActivePlaylist({
|
||||
...playlist,
|
||||
isFullStalkerPortal: false,
|
||||
} as Playlist);
|
||||
jest.advanceTimersByTime(600_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
type Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
/**
|
||||
* The portal expects `get_events` every `watchdog_timeout` seconds — 120 by
|
||||
* default — not every 25 s as the client historically pinged.
|
||||
*/
|
||||
export const STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS = 120;
|
||||
|
||||
/** Guard rails for a garbage `watchdog_timeout` echoed by a broken panel. */
|
||||
const MIN_PERIOD_SECONDS = 30;
|
||||
const MAX_PERIOD_SECONDS = 3600;
|
||||
|
||||
export interface StalkerWatchdogTiming {
|
||||
periodSeconds: number;
|
||||
/** Per-user jitter: delays the first periodic ping so clients spread out. */
|
||||
timeslotSeconds: number;
|
||||
}
|
||||
|
||||
export interface StalkerWatchdogDeps {
|
||||
/** Sends one authenticated `get_events` ping. */
|
||||
sendRequest: (
|
||||
playlist: Playlist,
|
||||
params: Record<string, string | number>
|
||||
) => Promise<unknown>;
|
||||
/**
|
||||
* Reads the persisted row for a playlist. The row is the source of truth
|
||||
* for the configuration a ping authenticates as; see `resolvePlaylist`.
|
||||
*/
|
||||
readPersistedPlaylist: (playlistId: string) => Promise<Playlist | undefined>;
|
||||
logger: ReturnType<typeof createLogger>;
|
||||
}
|
||||
|
||||
interface WatchdogTimers {
|
||||
timeslotTimeout?: ReturnType<typeof setTimeout>;
|
||||
interval?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the active full portal's session "online" by pinging `get_events`.
|
||||
*
|
||||
* The cadence comes from the profile (`watchdog_timeout` + `timeslot`); until
|
||||
* a profile has been decoded the documented default of 120 s applies. A missed
|
||||
* ping never invalidates authentication — it only affects the portal's
|
||||
* "online" reporting — so failures are logged and never retried or escalated.
|
||||
*/
|
||||
export class StalkerWatchdogController {
|
||||
private readonly timers = new Map<string, WatchdogTimers>();
|
||||
/** Activation-time snapshot; only a fallback when the row cannot be read. */
|
||||
private readonly playlists = new Map<string, Playlist>();
|
||||
private readonly inFlight = new Set<string>();
|
||||
/** Survives stop/start: the cadence is a per-portal fact, not session state. */
|
||||
private readonly timings = new Map<string, StalkerWatchdogTiming>();
|
||||
private activePlaylistId: string | null = null;
|
||||
private playlistDecorator: ((playlist: Playlist) => Playlist) | null = null;
|
||||
|
||||
constructor(private readonly deps: StalkerWatchdogDeps) {}
|
||||
|
||||
/**
|
||||
* Sets which playlist should receive periodic watchdog pings.
|
||||
* This keeps some Ministra/Stalker sessions alive for live playback.
|
||||
*/
|
||||
setActivePlaylist(playlist?: Playlist | null): void {
|
||||
const nextPlaylistId = playlist?._id ?? null;
|
||||
|
||||
if (this.activePlaylistId && this.activePlaylistId !== nextPlaylistId) {
|
||||
this.stop(this.activePlaylistId);
|
||||
}
|
||||
|
||||
this.activePlaylistId = nextPlaylistId;
|
||||
|
||||
if (
|
||||
!playlist ||
|
||||
!isFullStalkerPortalPlaylist(playlist) ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress
|
||||
) {
|
||||
if (nextPlaylistId) {
|
||||
this.stop(nextPlaylistId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.start(playlist);
|
||||
}
|
||||
|
||||
/** Whether this playlist currently owns the watchdog. */
|
||||
isActivePlaylist(playlistId: string): boolean {
|
||||
return this.activePlaylistId === playlistId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the repair layer overlay its in-session override on the row a ping
|
||||
* resolves: the persisted row can still carry a pre-repair configuration
|
||||
* while persistence is pending (or failed), and pinging that would stop
|
||||
* or misdirect the keepalive.
|
||||
*/
|
||||
registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void {
|
||||
this.playlistDecorator = decorator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the cadence decoded from a `get_profile` response. When the
|
||||
* playlist's watchdog is already running on a different cadence, its
|
||||
* periodic schedule restarts (without re-sending the init ping).
|
||||
*/
|
||||
applyProfileTiming(
|
||||
playlistId: string,
|
||||
timing: { watchdogTimeoutSeconds?: number; timeslotSeconds?: number }
|
||||
): void {
|
||||
const next = normalizeTiming(timing);
|
||||
const current = this.timings.get(playlistId);
|
||||
if (
|
||||
current &&
|
||||
current.periodSeconds === next.periodSeconds &&
|
||||
current.timeslotSeconds === next.timeslotSeconds
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timings.set(playlistId, next);
|
||||
|
||||
if (this.timers.has(playlistId)) {
|
||||
this.clearTimers(playlistId);
|
||||
this.timers.set(playlistId, {});
|
||||
this.schedule(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
private start(playlist: Playlist): void {
|
||||
const playlistId = playlist._id;
|
||||
this.playlists.set(playlistId, playlist);
|
||||
|
||||
if (this.timers.has(playlistId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timers.set(playlistId, {});
|
||||
void this.sendPing(playlistId, '1');
|
||||
this.schedule(playlistId);
|
||||
}
|
||||
|
||||
private stop(playlistId: string): void {
|
||||
this.clearTimers(playlistId);
|
||||
this.timers.delete(playlistId);
|
||||
this.playlists.delete(playlistId);
|
||||
this.inFlight.delete(playlistId);
|
||||
}
|
||||
|
||||
private clearTimers(playlistId: string): void {
|
||||
const timers = this.timers.get(playlistId);
|
||||
if (!timers) {
|
||||
return;
|
||||
}
|
||||
if (timers.timeslotTimeout) {
|
||||
clearTimeout(timers.timeslotTimeout);
|
||||
}
|
||||
if (timers.interval) {
|
||||
clearInterval(timers.interval);
|
||||
}
|
||||
}
|
||||
|
||||
private timingFor(playlistId: string): StalkerWatchdogTiming {
|
||||
return (
|
||||
this.timings.get(playlistId) ?? {
|
||||
periodSeconds: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
timeslotSeconds: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private schedule(playlistId: string): void {
|
||||
const timers = this.timers.get(playlistId);
|
||||
if (!timers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { periodSeconds, timeslotSeconds } = this.timingFor(playlistId);
|
||||
const startInterval = () => {
|
||||
const current = this.timers.get(playlistId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.timeslotTimeout = undefined;
|
||||
current.interval = setInterval(() => {
|
||||
void this.sendPing(playlistId, '0');
|
||||
}, periodSeconds * 1000);
|
||||
};
|
||||
|
||||
if (timeslotSeconds > 0) {
|
||||
timers.timeslotTimeout = setTimeout(
|
||||
startInterval,
|
||||
timeslotSeconds * 1000
|
||||
);
|
||||
} else {
|
||||
startInterval();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The playlist a ping authenticates as. The persisted row is the source of
|
||||
* truth: portal metadata (endpoint, mode, MAC, identity) edited or
|
||||
* repaired mid-session must reach the keepalive within one cycle — the
|
||||
* activation-time snapshot is only the fallback when the row cannot be
|
||||
* read.
|
||||
*/
|
||||
private async resolvePlaylist(
|
||||
playlistId: string
|
||||
): Promise<Playlist | undefined> {
|
||||
try {
|
||||
const row = await this.deps.readPersistedPlaylist(playlistId);
|
||||
if (row) {
|
||||
// Keep the fallback snapshot fresh for the next cycle.
|
||||
this.playlists.set(playlistId, row);
|
||||
return this.decorate(row);
|
||||
}
|
||||
} catch {
|
||||
// Store unavailable (e.g. isolated tests): keep the snapshot.
|
||||
}
|
||||
|
||||
const snapshot = this.playlists.get(playlistId);
|
||||
return snapshot ? this.decorate(snapshot) : snapshot;
|
||||
}
|
||||
|
||||
private decorate(playlist: Playlist): Playlist {
|
||||
return this.playlistDecorator
|
||||
? this.playlistDecorator(playlist)
|
||||
: playlist;
|
||||
}
|
||||
|
||||
private async sendPing(playlistId: string, init: '0' | '1'): Promise<void> {
|
||||
if (this.inFlight.has(playlistId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Claimed BEFORE the row read: it awaits, and two overlapping pings
|
||||
// passing the check together would double-fire the keepalive.
|
||||
this.inFlight.add(playlistId);
|
||||
try {
|
||||
const playlist = await this.resolvePlaylist(playlistId);
|
||||
if (
|
||||
!playlist ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress ||
|
||||
!isFullStalkerPortalPlaylist(playlist)
|
||||
) {
|
||||
this.stop(playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.sendRequest(playlist, {
|
||||
type: 'watchdog',
|
||||
action: 'get_events',
|
||||
event_active_id: '0',
|
||||
cur_play_type: '0',
|
||||
init,
|
||||
JsHttpRequest: '1-xml',
|
||||
});
|
||||
} catch (error) {
|
||||
// Keep failures non-fatal; the next tick can recover after a
|
||||
// token refresh. Never retry or escalate: a missed ping does not
|
||||
// invalidate authentication.
|
||||
this.deps.logger.warn('Watchdog ping failed:', error);
|
||||
} finally {
|
||||
this.inFlight.delete(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTiming(timing: {
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}): StalkerWatchdogTiming {
|
||||
const period = clamp(
|
||||
Math.floor(
|
||||
timing.watchdogTimeoutSeconds ??
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS
|
||||
),
|
||||
MIN_PERIOD_SECONDS,
|
||||
MAX_PERIOD_SECONDS
|
||||
);
|
||||
const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1);
|
||||
return { periodSeconds: period, timeslotSeconds: timeslot };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -441,6 +441,115 @@ describe('PlaylistsService', () => {
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
it('carries the Stalker session fields through the SQLite fallback read', async () => {
|
||||
// Without these, every cold Electron session loses the persisted
|
||||
// cadence AND the identity the token was negotiated for — so the
|
||||
// mismatch check cannot run and the watchdog falls back to default.
|
||||
const electron = {
|
||||
dbGetAppPlaylist: jest.fn(async () => ({
|
||||
id: 'stalker-1',
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerSessionIdentity: 'fingerprint-1',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 7,
|
||||
})),
|
||||
dbGetAppPlaylists: jest.fn(async () => []),
|
||||
dbGetAppState: jest.fn(async () => '1'),
|
||||
dbSetAppState: jest.fn(),
|
||||
dbUpsertAppPlaylist: jest.fn(),
|
||||
dbUpsertAppPlaylists: jest.fn(),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
|
||||
const service = createService();
|
||||
const playlist = await firstValueFrom(
|
||||
service.getPlaylistById('stalker-1')
|
||||
);
|
||||
|
||||
expect(playlist).toEqual(
|
||||
expect.objectContaining({
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerSessionIdentity: 'fingerprint-1',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 7,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a stale watchdog cadence when the portal stops advertising one', async () => {
|
||||
// Reusing the token skips the profile that would correct the cadence,
|
||||
// so leaving the old value on the row means the next restart applies
|
||||
// a cadence the portal no longer asks for.
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker',
|
||||
count: 0,
|
||||
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
stalkerToken: 'OLD',
|
||||
stalkerWatchdogTimeout: 45,
|
||||
stalkerTimeslot: 7,
|
||||
} as Playlist;
|
||||
const dbService = {
|
||||
getAll: jest.fn(() => of([])),
|
||||
getByID: jest.fn(() => of(existingPlaylist)),
|
||||
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
||||
of(playlist)
|
||||
),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
|
||||
const service = createService(dbService);
|
||||
|
||||
await firstValueFrom(
|
||||
service.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' })
|
||||
);
|
||||
|
||||
const written = dbService.update.mock.calls[0][1] as Playlist;
|
||||
expect(written.stalkerToken).toBe('NEW');
|
||||
expect(written.stalkerWatchdogTimeout).toBeUndefined();
|
||||
expect(written.stalkerTimeslot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists the watchdog cadence alongside the stalker token', async () => {
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker',
|
||||
count: 0,
|
||||
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
} as Playlist;
|
||||
const dbService = {
|
||||
getAll: jest.fn(() => of([])),
|
||||
getByID: jest.fn(() => of(existingPlaylist)),
|
||||
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
||||
of(playlist)
|
||||
),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
|
||||
const service = createService(dbService);
|
||||
|
||||
await firstValueFrom(
|
||||
service.updateStalkerSession('stalker-1', {
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 12,
|
||||
})
|
||||
);
|
||||
|
||||
expect(dbService.update).toHaveBeenCalledWith(
|
||||
DbStores.Playlists,
|
||||
expect.objectContaining({
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 12,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('persists hiddenGroupTitles in playlist meta updates', async () => {
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'playlist-1',
|
||||
|
||||
@@ -242,6 +242,13 @@ export class PlaylistsService {
|
||||
stalkerSignature2: playlist.stalkerSignature2,
|
||||
isFullStalkerPortal: playlist.isFullStalkerPortal,
|
||||
stalkerToken: playlist.stalkerToken,
|
||||
// Without these the Electron cold read loses the persisted
|
||||
// cadence AND the identity the token was negotiated for, so the
|
||||
// mismatch check cannot run and the watchdog falls back to the
|
||||
// default.
|
||||
stalkerSessionIdentity: playlist.stalkerSessionIdentity,
|
||||
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
|
||||
stalkerTimeslot: playlist.stalkerTimeslot,
|
||||
stalkerAccountInfo: playlist.stalkerAccountInfo,
|
||||
} as Playlist;
|
||||
}
|
||||
@@ -729,6 +736,45 @@ export class PlaylistsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a freshly negotiated Stalker session on the playlist so the
|
||||
* next app start can re-present the token (the portal handshake is
|
||||
* idempotent) and keep the portal's own watchdog cadence — reusing a
|
||||
* token skips the `get_profile` that carries the cadence, so it has to
|
||||
* survive with the token. No-ops when the playlist row does not exist
|
||||
* yet: the import flow saves both with the playlist itself.
|
||||
*/
|
||||
updateStalkerSession(
|
||||
playlistId: string,
|
||||
session: {
|
||||
stalkerToken: string;
|
||||
stalkerSessionIdentity?: string;
|
||||
stalkerWatchdogTimeout?: number;
|
||||
stalkerTimeslot?: number;
|
||||
}
|
||||
) {
|
||||
return this.serializePlaylistWrite(playlistId, async () => {
|
||||
const playlist = await firstValueFrom(
|
||||
this.getPlaylistById(playlistId)
|
||||
);
|
||||
if (!playlist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The cadence is written unconditionally, including as
|
||||
// `undefined`: a portal that stops advertising one must not leave
|
||||
// a stale value behind for the next restart to re-apply, since
|
||||
// reusing the token skips the profile that would correct it.
|
||||
return this.persistPlaylistMutation({
|
||||
...playlist,
|
||||
stalkerToken: session.stalkerToken,
|
||||
stalkerSessionIdentity: session.stalkerSessionIdentity,
|
||||
stalkerWatchdogTimeout: session.stalkerWatchdogTimeout,
|
||||
stalkerTimeslot: session.stalkerTimeslot,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateFavorites(id: string, favorites: string[]) {
|
||||
return this.serializePlaylistWrite(id, async () => {
|
||||
const playlist = await firstValueFrom(this.getPlaylistById(id));
|
||||
|
||||
@@ -58,6 +58,23 @@ export interface Playlist {
|
||||
serverTimezone?: string;
|
||||
/** Session token for full stalker portal authentication - persisted for session */
|
||||
stalkerToken?: string;
|
||||
/**
|
||||
* Identity fingerprint the persisted `stalkerToken` was negotiated for.
|
||||
* Re-presenting a token minted for a DIFFERENT identity (the user edited
|
||||
* the MAC, serial or device ids) would pair a new identity with an old
|
||||
* session — the same class of bug the in-memory cache guards against, so
|
||||
* reuse is refused unless this still matches the playlist.
|
||||
*/
|
||||
stalkerSessionIdentity?: string;
|
||||
/**
|
||||
* Watchdog cadence the portal advertised in `get_profile`, persisted
|
||||
* alongside the token: reusing a stored token skips the profile request
|
||||
* that carries these, so without persistence the keep-alive would fall
|
||||
* back to the 120 s default forever.
|
||||
*/
|
||||
stalkerWatchdogTimeout?: number;
|
||||
/** Per-user watchdog jitter (seconds) from `get_profile`. */
|
||||
stalkerTimeslot?: number;
|
||||
/** Serial number for stalker portal - generated once and stored for consistency */
|
||||
stalkerSerialNumber?: string;
|
||||
/** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
classifyStalkerAuthFailureBody,
|
||||
createStalkerAuthFailureMarker,
|
||||
extractStalkerAuthFailureBody,
|
||||
isStalkerAuthFailureBody,
|
||||
isStalkerAuthFailureMessage,
|
||||
isStalkerAuthFailureResponse,
|
||||
@@ -98,6 +100,58 @@ describe('isStalkerAuthFailureResponse', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('transport marker', () => {
|
||||
it('round-trips through create / detect / extract', () => {
|
||||
const marker = createStalkerAuthFailureMarker('Access denied.');
|
||||
expect(isStalkerAuthFailureResponse(marker)).toBe(true);
|
||||
expect(extractStalkerAuthFailureBody(marker)).toBe('Access denied.');
|
||||
});
|
||||
|
||||
it('is what keeps the repair trigger working once Electron classifies', () => {
|
||||
// The main process replaces the plain-text body with this marker, so
|
||||
// a detector that only knew the raw string would silently stop
|
||||
// triggering portal repair on the desktop app.
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({
|
||||
stalkerAuthFailure: 'Authorization failed.',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not accept arbitrary objects as markers', () => {
|
||||
expect(isStalkerAuthFailureResponse({ stalkerAuthFailure: 'nope' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(extractStalkerAuthFailureBody({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractStalkerAuthFailureBody', () => {
|
||||
it('extracts from the raw PWA payload string', () => {
|
||||
expect(extractStalkerAuthFailureBody('Authorization failed. 12')).toBe(
|
||||
'Authorization failed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts from a {js: body} envelope', () => {
|
||||
expect(
|
||||
extractStalkerAuthFailureBody({ js: 'Authorization failed. 75' })
|
||||
).toBe('Authorization failed.');
|
||||
});
|
||||
|
||||
it('reports no canonical body for the structured panel wording', () => {
|
||||
// Still an auth failure — just not one of the middleware's bodies.
|
||||
const response = { js: { error: 'Invalid token' } };
|
||||
expect(extractStalkerAuthFailureBody(response)).toBeNull();
|
||||
expect(isStalkerAuthFailureResponse(response)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null for ordinary responses', () => {
|
||||
expect(extractStalkerAuthFailureBody({ js: { data: [] } })).toBeNull();
|
||||
expect(extractStalkerAuthFailureBody(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerAuthFailureMessage', () => {
|
||||
it('accepts messages our own auth layer produces', () => {
|
||||
expect(
|
||||
|
||||
@@ -128,17 +128,77 @@ export function isStalkerAuthFailureMessage(message: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a portal response is an authorization failure in EITHER wire
|
||||
* shape: the middleware's plain-text body, or the JSON envelope some panels
|
||||
* answer instead. Classification and the lazy-repair trigger must use this,
|
||||
* not the string-only primitive: a JSON-failing panel would otherwise be
|
||||
* persisted as token-free and never repaired.
|
||||
* Structured shape a transport returns in place of the raw plain-text body.
|
||||
*
|
||||
* Returned, never thrown: `ipcRenderer.invoke` strips custom properties from
|
||||
* rejected values, so a classified body would not survive the trip to the
|
||||
* renderer as an error. The Electron main process mints this because it is
|
||||
* where the body arrives; the PWA proxy still delivers the raw string, and
|
||||
* every consumer goes through the predicates below rather than caring which.
|
||||
*/
|
||||
export interface StalkerAuthFailureMarker {
|
||||
stalkerAuthFailure: StalkerAuthFailureBody;
|
||||
}
|
||||
|
||||
export function createStalkerAuthFailureMarker(
|
||||
body: StalkerAuthFailureBody
|
||||
): StalkerAuthFailureMarker {
|
||||
return { stalkerAuthFailure: body };
|
||||
}
|
||||
|
||||
function readAuthFailureMarker(
|
||||
value: unknown
|
||||
): StalkerAuthFailureBody | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return classifyStalkerAuthFailureBody(
|
||||
(value as StalkerAuthFailureMarker).stalkerAuthFailure
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the canonical failure body from any shape an auth failure reaches
|
||||
* a consumer in: the transport marker (Electron), the raw plain-text payload
|
||||
* (PWA proxy), or a `{js: '<body>'}` envelope. Returns null for everything
|
||||
* else, including the structured `{js: {error|msg}}` form — that one is a
|
||||
* panel's own wording, not one of the middleware's three bodies, so it has
|
||||
* no canonical body to report. Use `isStalkerAuthFailureResponse` when the
|
||||
* question is merely "did authorization fail?".
|
||||
*/
|
||||
export function extractStalkerAuthFailureBody(
|
||||
value: unknown
|
||||
): StalkerAuthFailureBody | null {
|
||||
return (
|
||||
readAuthFailureMarker(value) ??
|
||||
classifyStalkerAuthFailureBody(value) ??
|
||||
classifyStalkerAuthFailureBody(
|
||||
typeof value === 'object' && value !== null && 'js' in value
|
||||
? (value as { js?: unknown }).js
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a portal response is an authorization failure in ANY wire shape:
|
||||
* the middleware's plain-text body, the transport marker minted from it, or
|
||||
* the JSON envelope some panels answer instead. Classification and the
|
||||
* lazy-repair trigger must use this, not the string-only primitive: a
|
||||
* JSON-failing panel would otherwise be persisted as token-free and never
|
||||
* repaired, and an Electron-classified body would stop triggering repair at
|
||||
* all.
|
||||
*/
|
||||
export function isStalkerAuthFailureResponse(response: unknown): boolean {
|
||||
if (isStalkerAuthFailureBody(response)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (readAuthFailureMarker(response) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
response === null ||
|
||||
typeof response !== 'object' ||
|
||||
|
||||
+1
-3
@@ -157,9 +157,7 @@
|
||||
} @else if (isStalkerCategoryFailed()) {
|
||||
<app-workspace-context-error-view
|
||||
[title]="'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR' | translate"
|
||||
[description]="
|
||||
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' | translate
|
||||
"
|
||||
[description]="stalkerCategoryErrorDescription()"
|
||||
[showActionButtons]="false"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
+26
-1
@@ -17,7 +17,10 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
StalkerStore,
|
||||
asStalkerPortalError,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
PortalCategorySortMode,
|
||||
persistPortalCategorySortMode,
|
||||
@@ -152,6 +155,28 @@ export class WorkspaceContextPanelComponent {
|
||||
this.stalkerStore.isCategoryResourceLoading;
|
||||
readonly isStalkerCategoryFailed =
|
||||
this.stalkerStore.isCategoryResourceFailed;
|
||||
/**
|
||||
* When category loading failed because the portal refused the session,
|
||||
* the portal's own explanation (msg/block_msg or the documented
|
||||
* plain-text failure body) replaces the generic hint; a login-required
|
||||
* refusal gets its own actionable text.
|
||||
*/
|
||||
readonly stalkerCategoryErrorDescription = computed(() => {
|
||||
const portalError = asStalkerPortalError(
|
||||
this.isStalkerCategoryFailed()
|
||||
);
|
||||
if (portalError?.portalText) {
|
||||
return portalError.portalText;
|
||||
}
|
||||
if (portalError?.kind === 'login-required') {
|
||||
return this.translate.instant(
|
||||
'PORTALS.ERROR_VIEW.STALKER_LOGIN_REQUIRED'
|
||||
);
|
||||
}
|
||||
return this.translate.instant(
|
||||
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT'
|
||||
);
|
||||
});
|
||||
// Category count badges are only available for Stalker Live TV, where the
|
||||
// full channel list is cached — VOD/series/radio still page lazily, so
|
||||
// their per-category totals are unknown.
|
||||
|
||||
Reference in new issue
Block a user