mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Stalker portals that ask for a login and password now work: the import dialog
|
||||
gained username/password fields and the app completes the portal's do_auth
|
||||
step. When a portal refuses access, its own explanation is shown instead of a
|
||||
generic error, saved sessions resume without re-authenticating, and the
|
||||
keep-alive ping follows the portal's cadence.
|
||||
@@ -1111,6 +1111,16 @@ engine` (restart required) or
|
||||
- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog").
|
||||
- Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read.
|
||||
|
||||
**Stalker Session Authentication**:
|
||||
|
||||
- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`.
|
||||
- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
|
||||
- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code.
|
||||
- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections.
|
||||
- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
|
||||
- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting.
|
||||
- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle").
|
||||
|
||||
**Favorites and Recently Viewed**:
|
||||
|
||||
- Per-playlist favorites and global favorites
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
classifyStalkerAuthFailureBody,
|
||||
createStalkerAuthFailureMarker,
|
||||
PortalDebugEvent,
|
||||
STALKER_REQUEST,
|
||||
buildStalkerIdentityRequestContext,
|
||||
@@ -111,6 +113,18 @@ ipcMain.handle(
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
// The portal answers auth failures with HTTP 200 and a plain
|
||||
// text/html body ("Authorization failed.", "Access denied.",
|
||||
// "Unauthorized request."). Classify them here so the renderer
|
||||
// receives a structured marker instead of pattern-matching raw
|
||||
// response text.
|
||||
const authFailureBody = classifyStalkerAuthFailureBody(
|
||||
response.data
|
||||
);
|
||||
const responseData = authFailureBody
|
||||
? createStalkerAuthFailureMarker(authFailureBody)
|
||||
: response.data;
|
||||
|
||||
// Return the response data
|
||||
if (
|
||||
params.action === 'create_link' &&
|
||||
@@ -136,12 +150,12 @@ ipcMain.handle(
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: 'success',
|
||||
request: debugRequest,
|
||||
response: response.data,
|
||||
response: responseData,
|
||||
};
|
||||
emitPortalDebugEvent(debugEvent);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
if (payload.requestId) {
|
||||
const debugEvent: PortalDebugEvent = {
|
||||
|
||||
@@ -82,7 +82,7 @@ actually get wrong:
|
||||
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
|
||||
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
||||
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
|
||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||
|
||||
@@ -11,10 +11,9 @@ import { handleHandshake } from './handshake.handler';
|
||||
/**
|
||||
* Handler-level coverage for the authentication actions.
|
||||
*
|
||||
* The e2e suite drives the app against this server, but the login-required
|
||||
* flow cannot be reached from the client yet (its `do_auth` path is dormant
|
||||
* and sends empty credentials), so the scenario is pinned down here rather
|
||||
* than only asserted in prose.
|
||||
* The e2e suite drives the app through this flow end to end; these handler
|
||||
* tests pin the SERVER half of the contract independently, so a client-side
|
||||
* regression and a mock-side one cannot mask each other.
|
||||
*
|
||||
* Handlers are called directly instead of through the dispatcher: the
|
||||
* dispatcher pulls in every content handler and therefore the faker-based
|
||||
@@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => {
|
||||
})['token'] as string;
|
||||
expect(token).toMatch(/^[0-9A-F]{32}$/);
|
||||
|
||||
// The app sends auth_second_step=1 on its very first profile request,
|
||||
// so that parameter alone must not satisfy the scenario.
|
||||
// A client could mislabel its first profile as the second auth step;
|
||||
// that parameter alone must not satisfy the scenario.
|
||||
expect(
|
||||
invoke(
|
||||
handleGetProfile,
|
||||
|
||||
@@ -33,10 +33,11 @@ export function handleGetProfile(
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate on the actual do_auth completion, not on auth_second_step: the app
|
||||
// sends auth_second_step=1 on its very first get_profile, so a parameter
|
||||
// check would let the login-required flow be bypassed without ever
|
||||
// exercising status 2 -> do_auth -> profile retry.
|
||||
// Gate on the actual do_auth completion, not on auth_second_step. The
|
||||
// client now sends `auth_second_step=0` first and `1` only on the retry
|
||||
// after do_auth, so a parameter check would happen to work — but it would
|
||||
// also silently pass for any client that mislabels its first profile,
|
||||
// which is exactly the protocol mistake this scenario exists to catch.
|
||||
if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) {
|
||||
res.json({
|
||||
js: {
|
||||
|
||||
+243
-21
@@ -48,6 +48,11 @@ import {
|
||||
* `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local
|
||||
* all-project run shows a lone auth failure that passes on rerun, this is why;
|
||||
* `--project=chromium` reproduces CI exactly.
|
||||
*
|
||||
* Most tests here tolerate that reset anyway — they re-authenticate, or assert
|
||||
* that a NEW token appears. The token-reuse test cannot: its assertion IS that
|
||||
* the portal session survives, so it uses per-project MACs held outside
|
||||
* `OWNED_MACS`. See `AUTH_REUSE_MACS`.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -82,6 +87,13 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||||
*/
|
||||
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
|
||||
|
||||
/**
|
||||
* Login-required scenario MAC — get_profile answers status 2 until do_auth
|
||||
* completes with non-empty credentials (empty credentials return {js:false},
|
||||
* as the operator billing script would).
|
||||
*/
|
||||
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
|
||||
|
||||
/**
|
||||
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
|
||||
* by MAC, so keeping these distinct from the content scenarios above means an
|
||||
@@ -90,6 +102,25 @@ const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
|
||||
*/
|
||||
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
|
||||
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
|
||||
|
||||
/**
|
||||
* The token-reuse test needs the portal session to SURVIVE untouched between
|
||||
* its import and its reload — that persistence is the whole assertion. Every
|
||||
* other test here is reset-tolerant (they either re-authenticate or assert a
|
||||
* new token), so they can share `OWNED_MACS`, which each `beforeEach` clears.
|
||||
*
|
||||
* `mode: 'serial'` only serializes within one project; the browser projects
|
||||
* still run concurrently, so a sibling project's reset would rotate this
|
||||
* session mid-test. Hence one MAC per project, and deliberately NOT in
|
||||
* `OWNED_MACS`: nothing may reset them. Leftover state from an earlier run is
|
||||
* harmless — the import negotiates and adopts its own token first.
|
||||
*/
|
||||
const AUTH_REUSE_MACS: Record<string, string> = {
|
||||
chromium: '00:1A:79:AD:01:01',
|
||||
firefox: '00:1A:79:AD:01:02',
|
||||
webkit: '00:1A:79:AD:01:03',
|
||||
};
|
||||
const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04';
|
||||
/**
|
||||
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
|
||||
* it, so no token is ever adopted and content requests fail permanently.
|
||||
@@ -138,6 +169,7 @@ const OWNED_MACS = [
|
||||
EMBEDDED_SERIES_MAC,
|
||||
LEGACY_PAGINATION_MAC,
|
||||
STATIC_CMD_MAC,
|
||||
LOGIN_REQUIRED_MAC,
|
||||
AUTH_FLOW_MAC,
|
||||
AUTH_REAUTH_MAC,
|
||||
AUTH_REJECTED_MAC,
|
||||
@@ -212,9 +244,21 @@ async function addStalkerPortal(
|
||||
*/
|
||||
async function addFullStalkerPortal(
|
||||
page: Page,
|
||||
options: { name?: string; mac: string; expectContent?: boolean }
|
||||
options: {
|
||||
name?: string;
|
||||
mac: string;
|
||||
expectContent?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { name = 'Full Stalker Portal', mac, expectContent = true } = options;
|
||||
const {
|
||||
name = 'Full Stalker Portal',
|
||||
mac,
|
||||
expectContent = true,
|
||||
username,
|
||||
password,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
@@ -224,6 +268,12 @@ async function addFullStalkerPortal(
|
||||
await setInputValue(dialog.locator('input#title'), name);
|
||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||
if (username !== undefined) {
|
||||
await setInputValue(dialog.locator('input#username'), username);
|
||||
}
|
||||
if (password !== undefined) {
|
||||
await setInputValue(dialog.locator('input#password'), password);
|
||||
}
|
||||
|
||||
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
@@ -991,12 +1041,171 @@ test.describe('@stalker full portal authentication', () => {
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('never surfaces the portal plain-text auth failure as content', async ({
|
||||
test('completes the documented login flow behind get_profile status 2', async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
// The second auth step must only be claimed on the retry AFTER
|
||||
// do_auth — a client that always sends auth_second_step=1 works
|
||||
// against the mock but violates the documented protocol.
|
||||
const profileSteps: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.endsWith('/stalker')) {
|
||||
return;
|
||||
}
|
||||
if (url.searchParams.get('action') !== 'get_profile') {
|
||||
return;
|
||||
}
|
||||
profileSteps.push(url.searchParams.get('auth_second_step') ?? '');
|
||||
});
|
||||
|
||||
await addFullStalkerPortal(page, {
|
||||
mac: LOGIN_REQUIRED_MAC,
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
// The mock only adopts the token (and answers content) after do_auth
|
||||
// completed with non-empty credentials, so rendered categories prove
|
||||
// the whole status 2 -> do_auth -> profile retry chain ran.
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const actions = requests.map((entry) => entry.action);
|
||||
expect(actions).toContain('do_auth');
|
||||
expect(actions.indexOf('get_profile')).toBeLessThan(
|
||||
actions.indexOf('do_auth')
|
||||
);
|
||||
expect(actions.lastIndexOf('get_profile')).toBeGreaterThan(
|
||||
actions.indexOf('do_auth')
|
||||
);
|
||||
|
||||
expect(profileSteps[0]).toBe('0');
|
||||
expect(profileSteps).toContain('1');
|
||||
|
||||
// Content only flows once the token is adopted, which the mock does
|
||||
// only after do_auth completed. Look AFTER the last get_profile:
|
||||
// discovery's classification probe is itself a token-less
|
||||
// `get_genres`, so the first CONTENT_ACTIONS hit is that probe.
|
||||
const contentRequest = requests
|
||||
.slice(actions.lastIndexOf('get_profile') + 1)
|
||||
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
|
||||
expect(contentRequest).toBeDefined();
|
||||
expect(contentRequest?.token).toBeTruthy();
|
||||
});
|
||||
|
||||
test('explains a login-required portal and recovers once credentials are entered', async ({
|
||||
page,
|
||||
}) => {
|
||||
// First attempt without credentials: the import must fail with the
|
||||
// portal's actual reason, not a generic "check URL and MAC" error.
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('input#title'), 'Login Portal');
|
||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||
await setInputValue(
|
||||
dialog.locator('input#macAddress'),
|
||||
LOGIN_REQUIRED_MAC
|
||||
);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/requires a login and password/i)
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
// The dialog stays open so the user can add the credentials.
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Second attempt with credentials in the same dialog succeeds.
|
||||
await setInputValue(dialog.locator('input#username'), 'user');
|
||||
await setInputValue(dialog.locator('input#password'), 'secret');
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
await expect(dialog).toBeHidden({ timeout: 30_000 });
|
||||
|
||||
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('reuses the persisted token after a reload instead of re-running get_profile', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
const mac =
|
||||
AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC;
|
||||
|
||||
await addFullStalkerPortal(page, { mac });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests.some(
|
||||
(entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action) &&
|
||||
entry.token
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const sessionToken = requests.find(
|
||||
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
|
||||
)?.token;
|
||||
expect(sessionToken).toBeTruthy();
|
||||
|
||||
const requestCountBeforeReload = requests.length;
|
||||
await page.reload();
|
||||
|
||||
// Wait on the UI rather than the request log: a reload restores the
|
||||
// route, boots the app and re-authenticates before the first content
|
||||
// request goes out, and rendered categories prove that whole chain
|
||||
// finished (the portal only answers content for an adopted token).
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const afterReload = requests.slice(requestCountBeforeReload);
|
||||
|
||||
// Content came back under the SAME token, negotiated by re-presenting
|
||||
// the persisted one in the handshake.
|
||||
expect(
|
||||
afterReload.filter(
|
||||
(entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action) &&
|
||||
entry.token === sessionToken
|
||||
).length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// The handshake re-presented the persisted token...
|
||||
const reloadHandshake = afterReload.find(
|
||||
(entry) => entry.action === 'handshake'
|
||||
);
|
||||
expect(reloadHandshake?.token).toBe(sessionToken);
|
||||
|
||||
// ...and because the portal returned it unchanged (idempotent
|
||||
// handshake, still-adopted session), the profile round trip was
|
||||
// skipped entirely.
|
||||
expect(
|
||||
afterReload.filter((entry) => entry.action === 'get_profile')
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('refuses a rejected portal at import and never renders its plain-text failure', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
|
||||
// A MAC outside the Infomir OUI makes the strict endpoint answer
|
||||
// get_profile with a bare {status:1}, so no token is ever adopted and
|
||||
// every content request keeps returning the plain-text failure. Unlike
|
||||
@@ -1014,25 +1223,38 @@ test.describe('@stalker full portal authentication', () => {
|
||||
).json();
|
||||
expect(failureBody.payload).toBe('Authorization failed.');
|
||||
|
||||
await addFullStalkerPortal(page, {
|
||||
mac: AUTH_REJECTED_MAC,
|
||||
expectContent: false,
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('input#title'), 'Rejected Portal');
|
||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||
await setInputValue(
|
||||
dialog.locator('input#macAddress'),
|
||||
AUTH_REJECTED_MAC
|
||||
);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
|
||||
// The app must have actually hit the failing portal...
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests.filter((entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action)
|
||||
).length,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
// `status: 1` means the portal refused this device, so the import must
|
||||
// stop there instead of persisting a portal that can never load. (It
|
||||
// used to be treated as success whenever the portal sent no msg text,
|
||||
// which imported a dead source.)
|
||||
await expect(page.getByText(/refused access/i)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/stalker/);
|
||||
|
||||
// ...and must never render the raw portal response as content. A
|
||||
// portal answers auth failures with HTTP 200 + plain text, so an app
|
||||
// that trusts the status code would happily paint these strings.
|
||||
// And the raw portal response is never painted as UI. A portal answers
|
||||
// auth failures with HTTP 200 + plain text, so an app that trusts the
|
||||
// status code would happily render these strings.
|
||||
await expect(page.locator('body')).not.toContainText(
|
||||
'Authorization failed.'
|
||||
);
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "رابط الخادم",
|
||||
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)",
|
||||
"ADD": "إضافة",
|
||||
"VALIDATING": "جارٍ التحقق من البوابة..."
|
||||
"VALIDATING": "جارٍ التحقق من البوابة...",
|
||||
"CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور",
|
||||
"AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.",
|
||||
"LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.",
|
||||
"LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.",
|
||||
"PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.",
|
||||
"PORTAL_MESSAGE": "أفادت البوابة: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "التصفية حسب الاسم",
|
||||
"FILTER_AND_SORT": "تصفية وفرز",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "لم يتم تحديد فئة",
|
||||
"DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "تتطلب البوابة اسم مستخدم وكلمة مرور. أعد استيراد البوابة واملأ حقلي اسم المستخدم وكلمة المرور.",
|
||||
"PLAYLIST_SETTINGS": "إعدادات القائمة",
|
||||
"HOME": "الرئيسية",
|
||||
"DELETE": "حذف"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "رابط الخادم",
|
||||
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)",
|
||||
"ADD": "زيد",
|
||||
"VALIDATING": "جاري التحقق من البوابة..."
|
||||
"VALIDATING": "جاري التحقق من البوابة...",
|
||||
"CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور",
|
||||
"AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.",
|
||||
"LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.",
|
||||
"LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.",
|
||||
"PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.",
|
||||
"PORTAL_MESSAGE": "البوابة قالت: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "فلتر بالاسم",
|
||||
"FILTER_AND_SORT": "فلتر وترتيب",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "ما كاين حتى فئة متختارة",
|
||||
"DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "البوابة كتطلب اسم المستخدم وكلمة المرور. عاود استيراد البوابة وعمّر خانات اسم المستخدم وكلمة المرور.",
|
||||
"PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل",
|
||||
"HOME": "الرئيسية",
|
||||
"DELETE": "حذف"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL сервера",
|
||||
"URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).",
|
||||
"ADD": "Дадаць",
|
||||
"VALIDATING": "Праверка партала..."
|
||||
"VALIDATING": "Праверка партала...",
|
||||
"CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль",
|
||||
"AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.",
|
||||
"LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.",
|
||||
"LOGIN_REJECTED": "Партал адхіліў лагін і пароль.",
|
||||
"PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.",
|
||||
"PORTAL_MESSAGE": "Партал паведаміў: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Фільтраваць па імені",
|
||||
"FILTER_AND_SORT": "Фільтр і сартаванне",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Катэгорыя не выбрана",
|
||||
"DESCRIPTION": "Калі ласка, выберыце катэгорыю, каб убачыць кантэнт"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Партал патрабуе лагін і пароль. Імпартуйце партал яшчэ раз і запоўніце палі імя карыстальніка і пароля.",
|
||||
"PLAYLIST_SETTINGS": "Налады плэйліста",
|
||||
"HOME": "На галоўную",
|
||||
"DELETE": "Выдаліць"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Server-URL",
|
||||
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)",
|
||||
"ADD": "Hinzufügen",
|
||||
"VALIDATING": "Portal wird überprüft …"
|
||||
"VALIDATING": "Portal wird überprüft …",
|
||||
"CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt",
|
||||
"AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.",
|
||||
"LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.",
|
||||
"LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.",
|
||||
"PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.",
|
||||
"PORTAL_MESSAGE": "Das Portal meldet: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Nach Namen filtern",
|
||||
"FILTER_AND_SORT": "Filtern & Sortieren",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Keine Kategorie ausgewählt",
|
||||
"DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Das Portal erfordert Benutzername und Passwort. Importieren Sie das Portal erneut und füllen Sie die Felder Benutzername und Passwort aus.",
|
||||
"PLAYLIST_SETTINGS": "Playlist-Einstellungen",
|
||||
"HOME": "Startseite",
|
||||
"DELETE": "Löschen"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Διεύθηνση URL σέρβερ",
|
||||
"URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)",
|
||||
"ADD": "Προσθήκη",
|
||||
"VALIDATING": "Επικύρωση πύλης..."
|
||||
"VALIDATING": "Επικύρωση πύλης...",
|
||||
"CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης",
|
||||
"AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.",
|
||||
"LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.",
|
||||
"LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.",
|
||||
"PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
|
||||
"PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
|
||||
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Δεν έχει επιλεγεί κατηγορία",
|
||||
"DESCRIPTION": "Επιλέξτε μια κατηγορία για να δείτε το περιεχόμενο"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Εισαγάγετε ξανά την πύλη και συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης.",
|
||||
"PLAYLIST_SETTINGS": "Ρυθμίσεις λίστας αναπαραγωγής",
|
||||
"HOME": "Αρχικό",
|
||||
"DELETE": "Διαγραφή"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Server URL",
|
||||
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)",
|
||||
"ADD": "Add",
|
||||
"VALIDATING": "Validating portal..."
|
||||
"VALIDATING": "Validating portal...",
|
||||
"CREDENTIALS_HINT": "Only needed when the portal asks for a login and password",
|
||||
"AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.",
|
||||
"LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.",
|
||||
"LOGIN_REJECTED": "The portal rejected the login and password.",
|
||||
"PORTAL_REFUSED": "The portal refused access for this device.",
|
||||
"PORTAL_MESSAGE": "The portal reported: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter by name",
|
||||
"FILTER_AND_SORT": "Filter & Sort",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "No category selected",
|
||||
"DESCRIPTION": "Please select a category to see the content"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "The portal requires a login and password. Import the portal again and fill in the username and password fields.",
|
||||
"PLAYLIST_SETTINGS": "Playlist Settings",
|
||||
"HOME": "Home",
|
||||
"DELETE": "Delete"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL del servidor",
|
||||
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).",
|
||||
"ADD": "Agregar",
|
||||
"VALIDATING": "Validando portal…"
|
||||
"VALIDATING": "Validando portal…",
|
||||
"CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña",
|
||||
"AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.",
|
||||
"LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.",
|
||||
"LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.",
|
||||
"PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.",
|
||||
"PORTAL_MESSAGE": "El portal informó: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nombre",
|
||||
"FILTER_AND_SORT": "Filtrar y ordenar",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Ninguna categoría seleccionada",
|
||||
"DESCRIPTION": "Selecciona una categoría para ver el contenido"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "El portal requiere usuario y contraseña. Vuelve a importar el portal y completa los campos de nombre de usuario y contraseña.",
|
||||
"PLAYLIST_SETTINGS": "Configuración de lista de reproducción",
|
||||
"HOME": "Hogar",
|
||||
"DELETE": "Borrar"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL du serveur",
|
||||
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)",
|
||||
"ADD": "Ajouter",
|
||||
"VALIDATING": "Validation du portail…"
|
||||
"VALIDATING": "Validation du portail…",
|
||||
"CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe",
|
||||
"AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.",
|
||||
"LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.",
|
||||
"LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.",
|
||||
"PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.",
|
||||
"PORTAL_MESSAGE": "Le portail a signalé : {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrer par nom",
|
||||
"FILTER_AND_SORT": "Filtrer et trier",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Aucune catégorie sélectionnée",
|
||||
"DESCRIPTION": "Veuillez sélectionner une catégorie pour voir le contenu"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Le portail nécessite un identifiant et un mot de passe. Importez à nouveau le portail et renseignez les champs nom d'utilisateur et mot de passe.",
|
||||
"PLAYLIST_SETTINGS": "Paramètres de la liste",
|
||||
"HOME": "Accueil",
|
||||
"DELETE": "Supprimer"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Kiszolgáló URL-címe",
|
||||
"URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)",
|
||||
"ADD": "Hozzáadás",
|
||||
"VALIDATING": "Portál ellenőrzése…"
|
||||
"VALIDATING": "Portál ellenőrzése…",
|
||||
"CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér",
|
||||
"AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.",
|
||||
"LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.",
|
||||
"LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.",
|
||||
"PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.",
|
||||
"PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Szűrés név alapján",
|
||||
"FILTER_AND_SORT": "Szűrés és rendezés",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nincs kiválasztott kategória",
|
||||
"DESCRIPTION": "A tartalom megjelenítéséhez válasszon egy kategóriát."
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "A portál felhasználónevet és jelszót igényel. Importálja újra a portált, és töltse ki a felhasználónév és a jelszó mezőt.",
|
||||
"PLAYLIST_SETTINGS": "Lejátszási lista beállításai",
|
||||
"HOME": "Kezdőlap",
|
||||
"DELETE": "Törlés"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL server",
|
||||
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)",
|
||||
"ADD": "Aggiungi",
|
||||
"VALIDATING": "Validazione del portale..."
|
||||
"VALIDATING": "Validazione del portale...",
|
||||
"CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password",
|
||||
"AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.",
|
||||
"LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.",
|
||||
"LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.",
|
||||
"PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.",
|
||||
"PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtra per nome",
|
||||
"FILTER_AND_SORT": "Filtra e ordina",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nessuna categoria selezionata",
|
||||
"DESCRIPTION": "Seleziona una categoria per vedere i contenuti"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Il portale richiede nome utente e password. Importa di nuovo il portale e compila i campi nome utente e password.",
|
||||
"PLAYLIST_SETTINGS": "Impostazioni Playlist",
|
||||
"HOME": "Home",
|
||||
"DELETE": "Elimina"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "サーバーURL",
|
||||
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)",
|
||||
"ADD": "追加",
|
||||
"VALIDATING": "ポータルを検証中..."
|
||||
"VALIDATING": "ポータルを検証中...",
|
||||
"CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要",
|
||||
"AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。",
|
||||
"LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。",
|
||||
"LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。",
|
||||
"PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。",
|
||||
"PORTAL_MESSAGE": "ポータルからの報告:{{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "名前でフィルター",
|
||||
"FILTER_AND_SORT": "フィルターと並び替え",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "カテゴリーが選択されていません",
|
||||
"DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "ポータルにはユーザー名とパスワードが必要です。ポータルを再インポートして、ユーザー名とパスワードの欄を入力してください。",
|
||||
"PLAYLIST_SETTINGS": "プレイリスト設定",
|
||||
"HOME": "ホーム",
|
||||
"DELETE": "削除"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "서버 URL",
|
||||
"URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).",
|
||||
"ADD": "추가하다",
|
||||
"VALIDATING": "포털을 검증하는 중..."
|
||||
"VALIDATING": "포털을 검증하는 중...",
|
||||
"CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요",
|
||||
"AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.",
|
||||
"LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.",
|
||||
"LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.",
|
||||
"PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.",
|
||||
"PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "이름으로 필터",
|
||||
"FILTER_AND_SORT": "필터 및 정렬",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "선택된 카테고리가 없습니다",
|
||||
"DESCRIPTION": "콘텐츠를 보려면 카테고리를 선택하세요"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "포털에 사용자 이름과 비밀번호가 필요합니다. 포털을 다시 가져온 후 사용자 이름과 비밀번호 필드를 입력하세요.",
|
||||
"PLAYLIST_SETTINGS": "재생목록 설정",
|
||||
"HOME": "홈",
|
||||
"DELETE": "삭제"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Server URL",
|
||||
"URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)",
|
||||
"ADD": "Toevoegen",
|
||||
"VALIDATING": "Portaal valideren..."
|
||||
"VALIDATING": "Portaal valideren...",
|
||||
"CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt",
|
||||
"AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.",
|
||||
"LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.",
|
||||
"LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.",
|
||||
"PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.",
|
||||
"PORTAL_MESSAGE": "Het portaal meldde: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter op naam",
|
||||
"FILTER_AND_SORT": "Filteren & sorteren",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Geen categorie geselecteerd",
|
||||
"DESCRIPTION": "Selecteer een categorie om de inhoud te bekijken"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Het portaal vereist een gebruikersnaam en wachtwoord. Importeer het portaal opnieuw en vul de velden gebruikersnaam en wachtwoord in.",
|
||||
"PLAYLIST_SETTINGS": "Afspeellijst instellingen",
|
||||
"HOME": "Home",
|
||||
"DELETE": "Verwijderen"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL serwera",
|
||||
"URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)",
|
||||
"ADD": "Dodaj",
|
||||
"VALIDATING": "Walidacja portalu..."
|
||||
"VALIDATING": "Walidacja portalu...",
|
||||
"CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła",
|
||||
"AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.",
|
||||
"LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.",
|
||||
"LOGIN_REJECTED": "Portal odrzucił login i hasło.",
|
||||
"PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.",
|
||||
"PORTAL_MESSAGE": "Portal zgłosił: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtruj według nazwy",
|
||||
"FILTER_AND_SORT": "Filtruj i sortuj",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nie wybrano kategorii",
|
||||
"DESCRIPTION": "Wybierz kategorię, aby zobaczyć treści"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Portal wymaga loginu i hasła. Zaimportuj portal ponownie i wypełnij pola nazwy użytkownika i hasła.",
|
||||
"PLAYLIST_SETTINGS": "Ustawienia listy odtwarzania",
|
||||
"HOME": "Strona główna",
|
||||
"DELETE": "Usuń"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL do servidor",
|
||||
"URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)",
|
||||
"ADD": "Adicionar",
|
||||
"VALIDATING": "Validando portal..."
|
||||
"VALIDATING": "Validando portal...",
|
||||
"CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha",
|
||||
"AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.",
|
||||
"LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.",
|
||||
"LOGIN_REJECTED": "O portal rejeitou o login e a senha.",
|
||||
"PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.",
|
||||
"PORTAL_MESSAGE": "O portal informou: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nome",
|
||||
"FILTER_AND_SORT": "Filtrar e ordenar",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nenhuma categoria selecionada",
|
||||
"DESCRIPTION": "Selecione uma categoria para ver o conteúdo"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "O portal exige login e senha. Importe o portal novamente e preencha os campos de nome de usuário e senha.",
|
||||
"PLAYLIST_SETTINGS": "Configurações da playlist",
|
||||
"HOME": "Início",
|
||||
"DELETE": "Excluir"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL сервера",
|
||||
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).",
|
||||
"ADD": "Добавить",
|
||||
"VALIDATING": "Проверка портала…"
|
||||
"VALIDATING": "Проверка портала…",
|
||||
"CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль",
|
||||
"AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.",
|
||||
"LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.",
|
||||
"LOGIN_REJECTED": "Портал отклонил логин и пароль.",
|
||||
"PORTAL_REFUSED": "Портал отказал в доступе этому устройству.",
|
||||
"PORTAL_MESSAGE": "Портал сообщил: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Фильтровать по имени",
|
||||
"FILTER_AND_SORT": "Фильтр и сортировка",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Категория не выбрана",
|
||||
"DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Портал требует логин и пароль. Импортируйте портал заново и заполните поля имени пользователя и пароля.",
|
||||
"PLAYLIST_SETTINGS": "Настройки плейлиста",
|
||||
"HOME": "На главную",
|
||||
"DELETE": "Удалить"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Sunucu URL'si",
|
||||
"URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)",
|
||||
"ADD": "Ekle",
|
||||
"VALIDATING": "Portal doğrulanıyor..."
|
||||
"VALIDATING": "Portal doğrulanıyor...",
|
||||
"CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir",
|
||||
"AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.",
|
||||
"LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.",
|
||||
"LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.",
|
||||
"PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.",
|
||||
"PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "İsime göre filtrele",
|
||||
"FILTER_AND_SORT": "Filtrele ve Sırala",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Kategori seçilmedi",
|
||||
"DESCRIPTION": "İçeriği görmek için lütfen bir kategori seçin"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Portal, kullanıcı adı ve parola gerektiriyor. Portalı yeniden içe aktarın ve kullanıcı adı ile parola alanlarını doldurun.",
|
||||
"PLAYLIST_SETTINGS": "Oynatma Listesi Ayarları",
|
||||
"HOME": "Ana Sayfa",
|
||||
"DELETE": "Sil"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "服务器 URL",
|
||||
"URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
|
||||
"ADD": "添加",
|
||||
"VALIDATING": "正在验证门户…"
|
||||
"VALIDATING": "正在验证门户…",
|
||||
"CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写",
|
||||
"AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。",
|
||||
"LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。",
|
||||
"LOGIN_REJECTED": "门户拒绝了该登录名和密码。",
|
||||
"PORTAL_REFUSED": "门户拒绝了此设备的访问。",
|
||||
"PORTAL_MESSAGE": "门户返回信息:{{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "按名称筛选",
|
||||
"FILTER_AND_SORT": "筛选与排序",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "未选择分类",
|
||||
"DESCRIPTION": "请选择一个分类以查看内容"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "门户需要登录名和密码。请重新导入该门户并填写用户名和密码字段。",
|
||||
"PLAYLIST_SETTINGS": "播放列表设置",
|
||||
"HOME": "首页",
|
||||
"DELETE": "删除"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "伺服器網址",
|
||||
"URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
|
||||
"ADD": "新增",
|
||||
"VALIDATING": "正在驗證入口網站..."
|
||||
"VALIDATING": "正在驗證入口網站...",
|
||||
"CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫",
|
||||
"AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。",
|
||||
"LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。",
|
||||
"LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。",
|
||||
"PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。",
|
||||
"PORTAL_MESSAGE": "入口網站回報:{{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "依名稱篩選",
|
||||
"FILTER_AND_SORT": "篩選與排序",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "未選擇類別",
|
||||
"DESCRIPTION": "請選擇一個類別以查看內容"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "入口網站需要登入名稱與密碼。請重新匯入入口網站並填寫使用者名稱與密碼欄位。",
|
||||
"PLAYLIST_SETTINGS": "播放清單設定",
|
||||
"HOME": "首頁",
|
||||
"DELETE": "刪除"
|
||||
|
||||
@@ -83,9 +83,9 @@ Two portal modes exist, persisted per playlist as
|
||||
the wider phrase set (`Invalid token`, `Auth failed`, bare `unauthorized`),
|
||||
since a panel fills those in deliberately. While a full portal is the
|
||||
active playlist, `StalkerSessionService` keeps a **watchdog** running —
|
||||
periodic authenticated `watchdog/get_events` pings (currently every 25 s;
|
||||
the protocol default expects 120 s, tracked for a later PR) whose failures
|
||||
are non-fatal.
|
||||
periodic authenticated `watchdog/get_events` pings at the cadence the
|
||||
portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog"
|
||||
below) whose failures are non-fatal.
|
||||
- **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle
|
||||
at all — requests carry only the `mac=` cookie.
|
||||
|
||||
@@ -235,6 +235,181 @@ blank fields are not generated or forwarded to `get_profile`.
|
||||
metadata is independent from M3U playlist EPG metadata and must not depend on
|
||||
M3U-specific EPG fields.
|
||||
|
||||
## Session Authentication Lifecycle
|
||||
|
||||
Full portals authenticate through `StalkerSessionService`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which
|
||||
is a facade over three focused modules:
|
||||
|
||||
- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth`
|
||||
requests and the `authenticate()` orchestration.
|
||||
- `stalker-watchdog.controller.ts` — the periodic `get_events` keep-alive.
|
||||
- `stalker-token-cache.ts` — the in-run token and pending-auth state, tagged
|
||||
with the identity fingerprint each session was negotiated for.
|
||||
- `stalker-session-store.ts` — the session persisted on the playlist row.
|
||||
- `stalker-response-classification.ts` + `stalker-portal-error.ts` — failure
|
||||
detection and the typed `StalkerPortalError` the UI layers render.
|
||||
|
||||
### Handshake and token persistence
|
||||
|
||||
The handshake token is **idempotent** (Stalker 4.9.35 `stb.class.php`):
|
||||
re-presenting the MAC's current session token returns it unchanged, and tokens
|
||||
have no TTL — they are only invalidated when another device runs `get_profile`
|
||||
on the same MAC. `ensureToken()` exploits this: when the in-memory cache is
|
||||
cold it re-presents the persisted `Playlist.stalkerToken` (from the playlist
|
||||
object, falling back to the stored row via `PlaylistsService`, since store
|
||||
metas do not carry payload fields). A token that comes back unchanged is an
|
||||
already-adopted session, so the `get_profile` round trip is skipped entirely —
|
||||
unless the handshake also set `not_valid`, which vetoes the shortcut: adopting
|
||||
a token the portal just called dead would be unrecoverable, since the reuse
|
||||
path writes no replacement back and every later start would re-present it.
|
||||
A renegotiated session is written back best-effort
|
||||
(`PlaylistsService.updateStalkerSession`) so the next app start can reuse it.
|
||||
The handshake's `not_valid` flag is propagated into the follow-up
|
||||
`get_profile` as `not_valid_token`.
|
||||
|
||||
Reuse is gated on a session fingerprint (`stalkerSessionFingerprint`) covering
|
||||
the **portal endpoint (origin AND path), the device identity and the account
|
||||
credentials**, stored
|
||||
next to the token as `Playlist.stalkerSessionIdentity` and used for the
|
||||
in-run cache as well, so an edit applies without a restart. All three halves
|
||||
are load-bearing: `ensureToken()` re-presents tokens in a handshake, so an
|
||||
endpoint edit would otherwise disclose the previous portal's bearer token to
|
||||
another portal — and origin alone is not enough, since discovery deliberately
|
||||
preserves tenant base paths, so `/tenant-a/…` and `/tenant-b/…` on one host
|
||||
are different portals; an identity edit must not inherit the old session; and for a
|
||||
status-2 portal the login decides which account the token represents. A token
|
||||
with no recorded fingerprint (written before this existed) counts as
|
||||
unverified and is never re-presented — such a row owes a full profile anyway,
|
||||
and the write-back then records the fingerprint.
|
||||
|
||||
Because that reuse skips the only response carrying the watchdog cadence, the
|
||||
cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` /
|
||||
`stalkerTimeslot`, payload fields like `stalkerToken` itself — no schema
|
||||
change) and re-applied on the reuse path. The import dialog persists what its
|
||||
own `get_profile` advertised, which for a portal whose token never goes stale
|
||||
is the only profile the app ever sees.
|
||||
|
||||
The skip is therefore conditional on the cadence being **known**: a playlist
|
||||
imported before the cadence was persisted has a reusable token and no
|
||||
cadence, and skipping would strand it on the 120 s default permanently, since
|
||||
the profile is the only thing that could teach it. Such a playlist runs one
|
||||
profile, persists what it learns, and skips from then on. What gets persisted
|
||||
is the *effective* cadence (the 120 s default when the portal advertises
|
||||
none), so stored absence keeps meaning exactly one thing — never profiled —
|
||||
rather than sending a portal that advertises nothing back through a profile on
|
||||
every start.
|
||||
|
||||
### `get_profile` status decoding
|
||||
|
||||
`authenticate()` decodes `js.status` the way the stock middleware means it:
|
||||
|
||||
- full profile / `status: 0` — OK; `watchdog_timeout` and `timeslot` are read
|
||||
for the watchdog cadence.
|
||||
- `status: 1` — blocked (device conflict, malformed MAC, disabled account).
|
||||
`msg`/`block_msg` carry the portal's own explanation; they are
|
||||
markup-stripped, combined, and thrown as `StalkerPortalError('blocked')`.
|
||||
- `status: 2` — login/password required. The client runs `do_auth`
|
||||
(`login`, `password`, plus `device_id`/`device_id2` when configured) and
|
||||
retries `get_profile` with `auth_second_step=1`. Only that retry claims the
|
||||
second auth step — the initial request sends `auth_second_step=0`. Missing
|
||||
credentials throw `StalkerPortalError('login-required')`; a `{js: false}`
|
||||
verdict (the operator billing script refused) throws `'login-rejected'`.
|
||||
|
||||
Credentials come from the import dialog's username/password fields and are
|
||||
persisted on the playlist, so runtime re-authentication can repeat `do_auth`
|
||||
after the portal drops the session.
|
||||
|
||||
### Plain-text failure bodies
|
||||
|
||||
Auth failures are **HTTP 200 + a text/html body**, never a 401/403. The three
|
||||
exact bodies (`Authorization failed.` — stale/missing token, optionally with a
|
||||
numeric debug suffix; `Access denied.` — blocked account;
|
||||
`Unauthorized request.` — missing mac cookie) are classified at the transport
|
||||
boundary: the Electron main process
|
||||
(`apps/electron-backend/src/app/events/stalker.events.ts`) converts them into
|
||||
a structured `{ stalkerAuthFailure }` marker
|
||||
(`libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`) — returned,
|
||||
not thrown, because `ipcRenderer.invoke` strips custom properties from
|
||||
rejections. The PWA proxy path still delivers the raw string; the renderer
|
||||
classifier accepts both shapes plus the legacy `{ js: '<body>' }` envelope.
|
||||
`makeAuthenticatedRequest` retries once with fresh authentication and
|
||||
otherwise throws `StalkerPortalError('auth-failed')` carrying the body.
|
||||
|
||||
### Error surfacing
|
||||
|
||||
`StalkerPortalError.portalText` holds the portal's own words. The import
|
||||
dialog shows them in its failure snackbar (with kind-specific i18n headlines,
|
||||
`HOME.STALKER_PORTAL.*`); the workspace context panel replaces the generic
|
||||
"could not load categories" hint with the portal text (or the login-required
|
||||
guidance) when category loading failed with a portal refusal
|
||||
(`stalkerCategoryErrorDescription` in `workspace-context-panel.component.ts`).
|
||||
|
||||
Both renderers are kind-agnostic — they append `portalText` whenever it is
|
||||
present — so the obligation sits entirely on the throw sites: **every** exit
|
||||
out of the status-2 branch carries the text, not just the terminal `blocked`
|
||||
one. A login refusal is precisely where the portal says something actionable
|
||||
("wrong password", "subscription expired"), and dropping it leaves the user
|
||||
with a generic line while the useful sentence sits unread in the payload.
|
||||
Each exit reads the response IN HAND: after the `auth_second_step=1` retry the
|
||||
text is the retry's, since quoting the first profile back would describe a
|
||||
request that already succeeded. `do_auth` itself answers a bare `{js: false}`,
|
||||
so a rejection there keeps the profile's text — the one that asked for the
|
||||
login.
|
||||
|
||||
### Abandoning an authentication
|
||||
|
||||
`authenticate()` takes an optional `AbortSignal` and checks it before every
|
||||
portal call. Endpoint discovery gives each confirmation attempt its own
|
||||
controller and aborts it when the attempt exceeds its budget, before moving to
|
||||
the next candidate.
|
||||
|
||||
This matters because `get_profile` — not the handshake — is what adopts a
|
||||
token for the MAC portal-side. Without the check, a timed-out attempt could
|
||||
still send its `get_profile` after a later candidate had authenticated,
|
||||
invalidating that healthy candidate's token and making discovery report a
|
||||
working portal as refused.
|
||||
|
||||
Cancellation is deliberately cooperative rather than a socket-level abort
|
||||
threaded to axios: once a request is on the wire the server processes it
|
||||
regardless of what the client does, so tearing the socket down would not
|
||||
prevent the adoption. Only not sending the request does, which is exactly what
|
||||
the between-calls check guarantees.
|
||||
|
||||
That leaves the window where the timer fires while a `get_profile` is already
|
||||
dispatched — which the check cannot cover, but sequencing can: discovery
|
||||
**drains** the abandoned attempt (bounded by one request budget) before probing
|
||||
the next candidate, instead of racing it. The request cannot be un-sent, but
|
||||
nothing forces us to have a competing session in flight while it lands.
|
||||
|
||||
**A drain that times out stops discovery.** Draining is bounded, and the bound
|
||||
has to mean something: an attempt still unsettled after its own 65 s budget
|
||||
plus the 15 s drain is one no transport can recall — the PWA `fetch()` takes no
|
||||
signal at all, and the Electron main process runs its HTTP request to
|
||||
completion. Advancing anyway would stake the next candidate's freshly issued
|
||||
session on that request never landing. So the rejection carries
|
||||
`abandonedInFlight` and the candidate loop returns instead of probing on,
|
||||
preferring an honest "could not confirm this portal" the user can retry over a
|
||||
session that looks established and dies later. It costs nothing in the normal
|
||||
case: an aborted attempt settles as soon as its in-flight request errors out,
|
||||
so the drain returns at once and the loop continues.
|
||||
|
||||
The budget itself covers the longest real flow: a status-2 portal costs four
|
||||
sequential requests (handshake, profile, `do_auth`, profile retry) and the
|
||||
Electron transport allows each 15 s, so a two-request budget would have failed
|
||||
valid but slow login portals.
|
||||
|
||||
### Watchdog
|
||||
|
||||
The portal expects `get_events` every `watchdog_timeout` seconds — **120 by
|
||||
default**, echoed in the profile together with a per-user `timeslot` jitter
|
||||
that offsets the first periodic ping. `StalkerWatchdogController` starts with
|
||||
an immediate `init=1` ping on activation, applies the profile cadence when a
|
||||
profile is decoded (clamped to 30–3600 s against garbage), and otherwise uses
|
||||
the documented 120 s default. Failing to ping never invalidates the session —
|
||||
it only affects the portal's admin-panel "online" reporting — so ping failures
|
||||
are logged and never retried or escalated.
|
||||
|
||||
## Request Transport and `cmd` Encoding
|
||||
|
||||
A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw
|
||||
@@ -390,9 +565,15 @@ Every static return therefore warms first, through one primitive —
|
||||
|
||||
`ensureToken` performs handshake + `get_profile` with no link minted, and
|
||||
validates the identity the cached token was negotiated for — which the raw
|
||||
`getCachedToken()` the header builders use cannot. It is cheap where it is not
|
||||
needed: a simple portal returns immediately and a warm cache with a matching
|
||||
fingerprint resolves without a request.
|
||||
`getCachedToken()` cannot. It is cheap where it is not needed: a simple portal
|
||||
returns immediately and a warm cache with a matching fingerprint resolves
|
||||
without a request.
|
||||
|
||||
The store's player feature reads `getCachedToken()` for its header set, which
|
||||
is safe there because it runs immediately after the warm above populated the
|
||||
cache for that same playlist. `StreamResolverService` cannot make that
|
||||
assumption — a direct-URL favorite reaches it with nothing warmed — so it goes
|
||||
through `ensureToken` instead; see "Playback Header Contract" below.
|
||||
|
||||
The classification happens BEFORE the handshake, not after: a **foreign-host**
|
||||
static URL never needs the session at all, and warming it anyway would stall
|
||||
@@ -563,6 +744,30 @@ Two stream profiles exist, selected by one shared predicate:
|
||||
never reach a third-party host; direct stream URLs carry their access token
|
||||
in the URL minted by `create_link`.
|
||||
|
||||
**The token is bound to the endpoint the headers claim.** Both header inputs —
|
||||
the portal coordinates and the Bearer token — must describe the same portal, or
|
||||
a session negotiated for one host is presented to another. In
|
||||
`StreamResolverService` that is structural: the token is resolved from the same
|
||||
`headerPlaylist` object the headers are built from, never from the row it was
|
||||
derived from. The live case is a completed lazy repair, which moves the
|
||||
endpoint in the override but not in the stored row. Resolving from the override
|
||||
also keys the session cache the way `ensureStalkerSession()` and
|
||||
`executeStalkerRequest()` already do, so the collection route reuses their
|
||||
session instead of handshaking again for a second fingerprint.
|
||||
|
||||
For the same reason `headerPlaylist` is built by applying the override to the
|
||||
row rather than folding in the two resolved coordinates: a repair rewrites the
|
||||
portal **mode** as well as the URL, and the token resolver is mode-aware. A
|
||||
playlist repaired from simple to full would otherwise keep its stale
|
||||
`isFullStalkerPortal: false` here — the `create_link` request having already
|
||||
run under the repaired mode and adopted a token — and the same-host gated
|
||||
stream would go out with no Bearer header on the very playback the repair
|
||||
existed to rescue.
|
||||
|
||||
That resolution is skipped for a foreign host, whose profile carries no token
|
||||
anyway — obtaining one would only stall playback behind a handshake, exactly
|
||||
the trade the static branch avoids by classifying first.
|
||||
|
||||
The Electron main process keeps a fallback header context per resolved
|
||||
`create_link` URL (`stalker-playback-context.service.ts`) for external-player
|
||||
launches that arrive without renderer headers. It classifies streams with the
|
||||
|
||||
+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.
|
||||
|
||||
@@ -38,7 +38,6 @@ export const maxLinesBaseline = [
|
||||
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts',
|
||||
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts',
|
||||
'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',
|
||||
|
||||
Reference in new issue
Block a user