diff --git a/.changes/stalker-auth-lifecycle.md b/.changes/stalker-auth-lifecycle.md new file mode 100644 index 000000000..3b62dc566 --- /dev/null +++ b/.changes/stalker-auth-lifecycle.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a98df166d..39352c198 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 84b64d985..056cc73a1 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -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 = { diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index fa72541fc..ed222f3ce 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -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` | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | diff --git a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts index 37d551acd..f33f57b58 100644 --- a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts +++ b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts @@ -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, diff --git a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts index 463fb8466..ef5ff3b95 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts @@ -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: { diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 07597d02b..15d236808 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 = { + 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 { - 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.' ); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d384fc23e..d583836f4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "حذف" diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f686a3dab..961d20ad3 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "حذف" diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7e31c2f4f..97630f1e7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Выдаліць" diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 39e022ce4..f4989975e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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" diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a2107398a..0f4ca122b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Διαγραφή" diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 39110d498..a86135fd8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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" diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index dca10e3d1..c936ccc3e 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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" diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 7199e0e6f..9bc0421ef 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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" diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 363518a0f..ee09db2be 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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" diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 802e180a9..5212c6bfe 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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" diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2f3b08d41..49e89011e 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "削除" diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 59b734ff5..999697dee 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "삭제" diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 3927b34cc..0038dcc28 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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" diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2f4d4e800..40670643a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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ń" diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index cbf5b10dc..376dcd436 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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" diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 685bf964c..955ecd3dc 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Удалить" diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 99718c5fb..ea99b075a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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" diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index bf74b355b..d69b1134d 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "删除" diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 866127200..ecd350563 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "刪除" diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index bbc78ac74..bc9b50bd0 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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: '' }` 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 diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html index e7eb3e63f..57beb00fe 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html @@ -101,16 +101,27 @@ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }} - + + {{ + 'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate + }} + diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index d966e25fd..906afa722 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -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. diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 5a0b67222..86888fb92 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -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 = { + '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; diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 5d10d789b..a5451750f 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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; @@ -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) ); - 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; + 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; + 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) + ); + 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) + ); + 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) + ); + 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) ); - 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', diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 2d265e795..eedfd3730 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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 { // 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 { + // 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 diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 1b5f1df69..f76f8d307 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -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'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts new file mode 100644 index 000000000..43b31b6a2 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts @@ -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; + + 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.
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.', + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts new file mode 100644 index 000000000..fa5f6e020 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts @@ -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 { + 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 { + // 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 + ) {} + + async performHandshake( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + storedToken?: string, + signal?: AbortSignal + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const prehash = await generatePrehash(macAddress); + + const params: Record = { + 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( + 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 { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + + // Build metrics JSON matching working app + const metrics: Record = { + 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 = { + 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( + 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 { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const params: Record = { + 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( + 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 { + 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 { + 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; + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts index 38471a5ca..a03d54a1d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts @@ -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(); + } + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts index 0764d747a..009c1c8e5 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts @@ -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(promise: Promise, timeoutMs: number): Promise { +/** + * 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( + promise: Promise, + timeoutMs: number, + onTimeout?: () => void +): Promise { return new Promise((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 { 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((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 }), }; } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts new file mode 100644 index 000000000..f09ea54fd --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts @@ -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.
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(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts new file mode 100644 index 000000000..d457b834d --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts @@ -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.
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; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index fd99719f3..27e066a7f 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -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(); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index f72bff60c..617bddf77 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -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 ?? '') ); } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts new file mode 100644 index 000000000..15b7c2101 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts @@ -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; + 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; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts new file mode 100644 index 000000000..01f311404 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -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 + ) {} + + /** Reads a playlist row through the lazily-resolved persistence stack. */ + async readRow(playlistId: string): Promise { + 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 { + 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 { + 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); + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index bb4540b2a..2b2846b64 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -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.
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({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 746e95a6c..f31a6c613 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -1,6 +1,6 @@ import { Injectable, Injector, inject } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; import { + extractStalkerAuthFailureBody, isFullStalkerPortalPlaylist, isFullStalkerPortalUrl, Playlist, @@ -9,10 +9,6 @@ import { } from '@iptvnator/shared/interfaces'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { - isStalkerAuthFailureMessage, - isStalkerAuthFailureResponse, -} from './stalker-portal-discovery.utils'; import { getStalkerPortalIdentityFromPlaylist, LEGACY_DEFAULT_STALKER_SERIAL, @@ -20,75 +16,39 @@ import { stalkerIdentityFingerprint, type StalkerPortalIdentity, } from './stalker-identity.utils'; +import { + StalkerAuthApi, + type StalkerAuthenticateOptions, + type StalkerAuthenticationResult, + type StalkerHandshakeResponse, + type StalkerPortalCredentials, + type StalkerProfileResponse, +} from './stalker-auth.api'; +import { isStalkerAuthorizationFailure } from './stalker-response-classification'; +import { StalkerPortalError } from './stalker-portal-error'; +import { + stalkerSessionFingerprint, + StalkerSessionStore, + type PersistedStalkerSession, +} from './stalker-session-store'; +import { StalkerTokenCache } from './stalker-token-cache'; +import { StalkerWatchdogController } from './stalker-watchdog.controller'; -export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity }; +export { + getStalkerPortalIdentityFromPlaylist, + normalizeStalkerPortalIdentity, + stalkerIdentityFingerprint, +}; export type { StalkerPortalIdentity }; - -/** - * SHA1 hash using native Web Crypto API - * Produces correct 40-character hex hash matching real Stalker clients - */ -async function sha1(str: string): Promise { - 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 { - // 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; -} +export type { + StalkerAuthenticateOptions, + StalkerAuthenticationResult, + StalkerHandshakeResponse, + StalkerPortalCredentials, + StalkerProfileResponse, +}; export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL; -const STALKER_WATCHDOG_INTERVAL_MS = 25_000; - -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; - account_info?: { - login?: string; - expire_date?: number; - tariff_plan_name?: string; - status?: number; - }; - }; -} - -interface StalkerAuthConfirmationResponse { - js?: boolean; -} /** * Service to manage Stalker portal session tokens. @@ -101,40 +61,25 @@ interface StalkerAuthConfirmationResponse { export class StalkerSessionService { private dataService = inject(DataService); // Lazy: PlaylistsService drags the persistence stack and is only needed - // when a watchdog ping re-resolves its playlist from the stored row. + // when a session is resolved from (or written back to) the stored row. private readonly injector = inject(Injector); private readonly logger = createLogger('StalkerSession'); + private readonly authApi = new StalkerAuthApi(this.dataService, this.logger); + private readonly sessionStore = new StalkerSessionStore( + () => this.injector.get(PlaylistsService), + this.logger + ); + private readonly watchdog = new StalkerWatchdogController({ + sendRequest: (playlist, params) => + this.makeAuthenticatedRequest(playlist, params, false), + readPersistedPlaylist: (playlistId) => + this.sessionStore.readRow(playlistId), + logger: this.logger, + }); - // In-memory token cache for the current session, keyed by playlist ID - // and tagged with the identity fingerprint the session was negotiated - // for — an edited MAC/identity must never inherit the previous token. - private tokenCache = new Map< - string, - { token: string; identityFingerprint: string } - >(); - private watchdogPlaylistDecorator: - | ((playlist: Playlist) => Playlist) - | null = null; - - // Pending authentication promises to prevent race conditions. - // When multiple requests need a token simultaneously, they all wait for - // the same auth — but ONLY when they act as the same identity: a result - // negotiated for a pre-edit identity must not be adopted by requests - // carrying the edited one. - private pendingAuth = new Map< - string, - { - promise: Promise<{ token: string; serialNumber?: string }>; - identityFingerprint: string; - } - >(); - private watchdogIntervals = new Map< - string, - ReturnType - >(); - private watchdogPlaylists = new Map(); - private watchdogInFlight = new Set(); - private activeWatchdogPlaylistId: string | null = null; + // Session state for this run, identity-tagged so an edited playlist can + // inherit neither a cached token nor an in-flight authentication. + private readonly tokens = new StalkerTokenCache(); /** * Checks if a URL looks like a full stalker portal URL (requires @@ -149,10 +94,10 @@ export class StalkerSessionService { /** * Gets the cached token for a playlist, or null if not cached. * Identity validation happens in `ensureToken`; this raw accessor stays - * for playback fast paths that cannot supply an identity (PR 6 scope). + * for playback fast paths that cannot supply an identity. */ getCachedToken(playlistId: string): string | null { - return this.tokenCache.get(playlistId)?.token || null; + return this.tokens.get(playlistId); } /** @@ -164,60 +109,38 @@ export class StalkerSessionService { token: string, identitySource: PlaylistMeta ): void { - this.tokenCache.set(playlistId, { + this.tokens.set( + playlistId, token, - identityFingerprint: stalkerIdentityFingerprint(identitySource), - }); - } - - /** - * Lets the repair layer overlay its in-session override on the row a - * watchdog ping resolves: the persisted row can still carry a - * pre-repair configuration while persistence is pending (or failed), - * and pinging that configuration would stop or misdirect the keepalive. - */ - registerWatchdogPlaylistDecorator( - decorator: (playlist: Playlist) => Playlist - ): void { - this.watchdogPlaylistDecorator = decorator; + stalkerSessionFingerprint(identitySource) + ); } /** * Clears the cached token for a playlist (e.g., on auth failure) */ clearCachedToken(playlistId: string): void { - this.tokenCache.delete(playlistId); + this.tokens.clear(playlistId); + } + + /** + * Lets the repair layer overlay its in-session override on the row a + * watchdog ping resolves. + */ + registerWatchdogPlaylistDecorator( + decorator: (playlist: Playlist) => Playlist + ): void { + this.watchdog.registerPlaylistDecorator(decorator); } /** * Sets which playlist should receive periodic watchdog pings. * This keeps some Ministra/Stalker sessions alive for live playback. + * The cadence comes from the portal's profile (`watchdog_timeout` + + * `timeslot`), defaulting to the documented 120 s. */ setActiveWatchdogPlaylist(playlist?: Playlist | null): void { - const nextPlaylistId = playlist?._id ?? null; - - if ( - this.activeWatchdogPlaylistId && - this.activeWatchdogPlaylistId !== nextPlaylistId - ) { - this.stopWatchdog(this.activeWatchdogPlaylistId); - } - - this.activeWatchdogPlaylistId = nextPlaylistId; - - if ( - !playlist || - !isFullStalkerPortalPlaylist(playlist) || - !playlist.portalUrl || - !playlist.macAddress - ) { - if (nextPlaylistId) { - this.stopWatchdog(nextPlaylistId); - } - return; - } - - this.startWatchdog(playlist); + this.watchdog.setActivePlaylist(playlist); } /** @@ -225,357 +148,146 @@ export class StalkerSessionService { * was just repaired. Only reacts when the playlist IS the active * watchdog target: a simple→full repair starts the required keepalive, * full→simple stops it, and an endpoint change repoints the pings — - * without waiting for the next route activation (the activation-time - * snapshot in `watchdogPlaylists` would otherwise stay stale). + * without waiting for the next route activation. + * + * Delegation is the contract: `setActiveWatchdogPlaylist` owns the + * start/stop/repoint logic, this only feeds it the fresh row. */ refreshActiveWatchdogPlaylist(playlist: Playlist): void { - if (this.activeWatchdogPlaylistId !== playlist._id) { + if (!this.watchdog.isActivePlaylist(playlist._id)) { return; } this.setActiveWatchdogPlaylist(playlist); } - private startWatchdog(playlist: Playlist): void { - const playlistId = playlist._id; - this.watchdogPlaylists.set(playlistId, playlist); - - if (this.watchdogIntervals.has(playlistId)) { - return; - } - - void this.sendWatchdogPing(playlistId, '1'); - - const intervalId = setInterval(() => { - void this.sendWatchdogPing(playlistId, '0'); - }, STALKER_WATCHDOG_INTERVAL_MS); - - this.watchdogIntervals.set(playlistId, intervalId); - } - - private stopWatchdog(playlistId: string): void { - const intervalId = this.watchdogIntervals.get(playlistId); - if (intervalId) { - clearInterval(intervalId); - this.watchdogIntervals.delete(playlistId); - } - this.watchdogPlaylists.delete(playlistId); - this.watchdogInFlight.delete(playlistId); - } - /** - * The playlist a watchdog 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 - * ping cycle — the activation-time snapshot is only the fallback when - * the row cannot be read. + * Adopts a session another layer already negotiated — today the endpoint + * discovery run behind a lazy repair, whose classification handshake and + * `get_profile` produced both a token and the portal's cadence. + * + * Caching the token alone (as the repair used to) leaves the retry + * satisfied and no authentication path ever applies the profile outcome, + * so a freshly repaired playlist would keep pinging on the default + * cadence until the token failed or the app restarted. + * + * `identitySource` must describe the REPAIRED configuration: the session + * belongs to the endpoint it was negotiated against. */ - private async resolveWatchdogPlaylist( - playlistId: string - ): Promise { - try { - const row = await firstValueFrom( - this.injector - .get(PlaylistsService) - .getPlaylistById(playlistId) - ); - if (row) { - const playlist = row as Playlist; - // Keep the fallback snapshot fresh for the next cycle. - this.watchdogPlaylists.set(playlistId, playlist); - return this.decorateWatchdogPlaylist(playlist); - } - } catch { - // Store unavailable (e.g. isolated tests): keep the snapshot. - } - - const snapshot = this.watchdogPlaylists.get(playlistId); - return snapshot ? this.decorateWatchdogPlaylist(snapshot) : snapshot; - } - - /** - * Overlays the repair layer's in-session override (when registered) so - * a ping never authenticates against a configuration a completed repair - * has already proven broken — even before persistence lands. - */ - private decorateWatchdogPlaylist(playlist: Playlist): Playlist { - return this.watchdogPlaylistDecorator - ? this.watchdogPlaylistDecorator(playlist) - : playlist; - } - - private async sendWatchdogPing( + adoptDiscoveredSession( playlistId: string, - init: '0' | '1' - ): Promise { - if (this.watchdogInFlight.has(playlistId)) { - return; - } - - // Claimed BEFORE the row read: it awaits, and two overlapping pings - // passing the check together would double-fire the keepalive. - this.watchdogInFlight.add(playlistId); - try { - const playlist = await this.resolveWatchdogPlaylist(playlistId); - if ( - !playlist || - !playlist.portalUrl || - !playlist.macAddress || - !isFullStalkerPortalPlaylist(playlist) - ) { - this.stopWatchdog(playlistId); - return; - } - - await this.makeAuthenticatedRequest( - playlist, - { - type: 'watchdog', - action: 'get_events', - event_active_id: '0', - cur_play_type: '0', - init, - JsHttpRequest: '1-xml', - }, - false - ); - } catch (error) { - // Keep failures non-fatal; next interval can recover after token refresh. - this.logger.warn('Watchdog ping failed:', error); - } finally { - this.watchdogInFlight.delete(playlistId); + identitySource: Playlist, + session: { + token: string; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; } + ): void { + this.setCachedToken(playlistId, session.token, identitySource); + this.applySessionOutcome( + playlistId, + { + token: session.token, + reusedStoredToken: false, + watchdogTimeoutSeconds: session.watchdogTimeoutSeconds, + timeslotSeconds: session.timeslotSeconds, + }, + { + token: identitySource.stalkerToken, + identityFingerprint: identitySource.stalkerSessionIdentity, + watchdogTimeoutSeconds: identitySource.stalkerWatchdogTimeout, + timeslotSeconds: identitySource.stalkerTimeslot, + }, + stalkerSessionFingerprint(identitySource) + ); } /** - * Performs handshake to get a session token for a full stalker portal - * Returns both the token and the random value for use in subsequent requests + * Performs handshake to get a session token for a full stalker portal. + * An optional persisted token is re-presented: the handshake is + * idempotent, so a still-valid token comes back unchanged. */ - async performHandshake( + performHandshake( portalUrl: string, macAddress: string, - identity: StalkerPortalIdentity = {} - ): Promise<{ token: string; random: string }> { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - const prehash = await generatePrehash(macAddress); - - const params: Record = { - type: 'stb', - action: 'handshake', - token: '', - prehash, - JsHttpRequest: '1-xml', - }; - - try { - const response: StalkerHandshakeResponse = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - ...(normalizedIdentity.serialNumber - ? { serialNumber: normalizedIdentity.serialNumber } - : {}), - } - ); - - if (response?.js?.token) { - return { - token: response.js.token, - random: response.js.random || generateRandom(), - }; - } - - 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; - } + identity: StalkerPortalIdentity = {}, + storedToken?: string + ): Promise<{ token: string; random: string; notValid: boolean }> { + return this.authApi.performHandshake( + portalUrl, + macAddress, + identity, + storedToken + ); } /** - * Gets account profile information to validate the portal and check subscription - * Based on working implementation from stalker-to-m3u repo + * Gets account profile information to validate the portal and check + * subscription. */ - async getProfile( + getProfile( portalUrl: string, macAddress: string, token: string, identity: StalkerPortalIdentity, - handshakeRandom: string + handshakeRandom: string, + options: { authSecondStep?: boolean; notValidToken?: boolean } = {} ): Promise { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - - // Build metrics JSON matching working app - const metrics: Record = { - 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); - - // Profile request matching working StalkerTV app - // auth_second_step=1, includes metrics, prehash, and device_id params - const params: Record = { - type: 'stb', - action: 'get_profile', - hd: '1', - not_valid_token: '0', - video_out: 'hdmi', - auth_second_step: '1', - 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 { - const response: StalkerProfileResponse = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - token, - ...(normalizedIdentity.serialNumber - ? { serialNumber: normalizedIdentity.serialNumber } - : {}), - } - ); - - return response; - } catch (error) { - this.logger.error('Get profile error:', error); - throw error; - } - } - - /** - * Performs do_auth to authenticate the session after handshake - */ - async doAuth( - portalUrl: string, - macAddress: string, - token: string - ): Promise { - const params: Record = { - type: 'stb', - action: 'do_auth', - login: '', - password: '', - JsHttpRequest: '1-xml', - }; - - try { - const response = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - token, - } - ); - - // do_auth returns { js: true } on success - if (response?.js === true) { - return true; - } - - return false; - } catch (error) { - this.logger.error('do_auth error:', error); - throw error; - } - } - - /** - * Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces) - * Returns the token and account info if successful - */ - async authenticate( - portalUrl: string, - macAddress: string, - identity: StalkerPortalIdentity = {} - ): Promise<{ - token: string; - accountInfo?: StalkerProfileResponse['js']['account_info']; - /** Raw envelope so callers can apply their own failure checks. */ - profileResponse?: StalkerProfileResponse; - }> { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - - // Step 1: Handshake to get token and random - const { token, random } = await this.performHandshake( + return this.authApi.getProfile( portalUrl, macAddress, - normalizedIdentity + token, + identity, + handshakeRandom, + options ); - - // Step 2: Get profile to activate token and get account info - // The random from handshake must be used in auth_second_step - try { - const profileResponse = await this.getProfile( - portalUrl, - macAddress, - token, - normalizedIdentity, - random - ); - - // Check for profile-level errors - if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) { - const errorMsg = - profileResponse.js.msg || - profileResponse.js.block_msg || - 'Unknown profile error'; - this.logger.error('Profile error:', errorMsg); - throw new Error(`Profile error: ${errorMsg}`); - } - - return { - token, - accountInfo: profileResponse?.js?.account_info, - profileResponse, - }; - } catch (error) { - // Profile fetch failed - this is a real error, propagate it - this.logger.error('Profile fetch failed:', error); - throw error; - } } /** - * Ensures a valid token exists for a playlist, performing full auth if needed - * IMPORTANT: Based on working traces, each session needs handshake + get_profile - * Returns the token to use for requests, and the serial number to store + * Performs do_auth — the login/password step behind get_profile status 2. + */ + doAuth( + portalUrl: string, + macAddress: string, + token: string, + credentials: StalkerPortalCredentials, + identity: StalkerPortalIdentity = {} + ): Promise { + return this.authApi.doAuth( + portalUrl, + macAddress, + token, + credentials, + identity + ); + } + + /** + * Performs the full authentication flow: handshake → get_profile, driving + * the status-2 `do_auth` login flow when the portal demands credentials. + * Throws `StalkerPortalError` with the portal's own `msg`/`block_msg` + * text when the portal refuses the session. + */ + authenticate( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + options: StalkerAuthenticateOptions = {} + ): Promise { + return this.authApi.authenticate( + portalUrl, + macAddress, + identity, + options + ); + } + + /** + * Ensures a valid token exists for a playlist, performing auth if needed. + * A previously persisted token is re-presented in the handshake first — + * while it is still the MAC's session token the portal returns it + * unchanged and the `get_profile` round trip is skipped entirely. + * Returns the token to use for requests, and the serial number to store. */ async ensureToken( playlist: Playlist @@ -586,33 +298,22 @@ export class StalkerSessionService { } const identity = getStalkerPortalIdentityFromPlaylist(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); - // Check in-memory cache first (valid for current session only). - const cached = this.tokenCache.get(playlist._id); - if (cached) { - if ( - cached.identityFingerprint === - stalkerIdentityFingerprint(playlist) - ) { - return { - token: cached.token, - serialNumber: identity.serialNumber, - }; - } - // The cached session was negotiated for a DIFFERENT identity - // (the playlist was edited): retire it and authenticate as the - // current one instead of pairing new identity with old session. - this.clearCachedToken(playlist._id); + // Only the session negotiated for THIS identity may be reused. + const cachedToken = this.tokens.takeFor(playlist._id, fingerprint); + if (cachedToken) { + return { token: cachedToken, serialNumber: identity.serialNumber }; } // Check if there's already a pending authentication for this playlist // This prevents race conditions when multiple resources request a token simultaneously - const pendingEntry = this.pendingAuth.get(playlist._id); + const pendingEntry = this.tokens.getPending(playlist._id); if (pendingEntry) { - if ( - pendingEntry.identityFingerprint === - stalkerIdentityFingerprint(playlist) - ) { + if (pendingEntry.identityFingerprint === fingerprint) { this.logger.debug('Waiting for pending authentication...'); return pendingEntry.promise; } @@ -629,8 +330,6 @@ export class StalkerSessionService { return this.ensureToken(playlist); } - // No cached token - need to do full authentication (handshake + get_profile) - // Don't trust stored tokens as they may be from a different session if (!playlist.portalUrl || !playlist.macAddress) { this.logger.error('Missing portal URL or MAC address'); throw new Error('Portal URL and MAC address are required'); @@ -642,23 +341,54 @@ export class StalkerSessionService { // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { try { - const { token } = await this.authenticate( + // The PERSISTED session is bound to the endpoint too: a + // playlist repointed at another host must not re-present the + // previous portal's token to it. + const stored = await this.sessionStore.read( + playlist, + fingerprint + ); + const result = await this.authenticate( portalUrl, macAddress, - identity + identity, + { + storedToken: stored.token, + credentials: { + username: playlist.username, + password: playlist.password, + }, + // Only skip the profile once the cadence is known. A + // playlist stored before the cadence was persisted + // has a reusable token and no cadence, and skipping + // would strand it on the 120 s default forever — the + // profile is the only thing that could teach it. One + // request, once; every later start skips. + skipProfileWhenReused: + stored.watchdogTimeoutSeconds !== undefined, + } ); - this.setCachedToken(playlist._id, token, playlist); - return { token, serialNumber: identity.serialNumber }; + this.setCachedToken(playlist._id, result.token, playlist); + this.applySessionOutcome( + playlist._id, + result, + stored, + fingerprint + ); + return { + token: result.token, + serialNumber: identity.serialNumber, + }; } finally { // Clean up pending promise regardless of success/failure - this.pendingAuth.delete(playlist._id); + this.tokens.clearPending(playlist._id); } })(); // Store the pending promise so other concurrent requests can wait on it - this.pendingAuth.set(playlist._id, { + this.tokens.setPending(playlist._id, { promise: authPromise, - identityFingerprint: stalkerIdentityFingerprint(playlist), + identityFingerprint: fingerprint, }); return authPromise; @@ -685,14 +415,18 @@ export class StalkerSessionService { const portalUrl = playlist.portalUrl; const macAddress = playlist.macAddress; const identity = getStalkerPortalIdentityFromPlaylist(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); // Claim the per-playlist slot. Re-check after every await: one // settled promise releases every waiter at once, so a single // pre-check would let them all start competing handshakes. for ( - let inFlight = this.pendingAuth.get(playlist._id); + let inFlight = this.tokens.getPending(playlist._id); inFlight; - inFlight = this.pendingAuth.get(playlist._id) + inFlight = this.tokens.getPending(playlist._id) ) { this.logger.debug('Waiting for pending authentication...'); // A failed pending auth must not abort the refresh; this call @@ -718,11 +452,8 @@ export class StalkerSessionService { // Waiters attach their own handlers; this one only keeps a // rejected slot from surfacing as an unhandled rejection. void slot.catch(() => undefined); - const slotEntry = { - promise: slot, - identityFingerprint: stalkerIdentityFingerprint(playlist), - }; - this.pendingAuth.set(playlist._id, slotEntry); + const slotEntry = { promise: slot, identityFingerprint: fingerprint }; + this.tokens.setPending(playlist._id, slotEntry); // ensureToken() reads tokenCache before pendingAuth, so leaving the // old token there would hand a token this handshake is about to @@ -734,9 +465,29 @@ export class StalkerSessionService { const result = await this.authenticate( portalUrl, macAddress, - identity + identity, + { + credentials: { + username: playlist.username, + password: playlist.password, + }, + } ); this.setCachedToken(playlist._id, result.token, playlist); + // This path always ran a real get_profile, so the decoded cadence + // is authoritative; the previous values are only the write-back + // comparison baseline. + this.applySessionOutcome( + playlist._id, + result, + { + token: playlist.stalkerToken, + identityFingerprint: playlist.stalkerSessionIdentity, + watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, + timeslotSeconds: playlist.stalkerTimeslot, + }, + fingerprint + ); settleSlot({ token: result.token, serialNumber: identity.serialNumber, @@ -748,70 +499,24 @@ export class StalkerSessionService { } finally { // Only retire our own entry: a caller that started a later // authentication owns the map slot from then on. - if (this.pendingAuth.get(playlist._id) === slotEntry) { - this.pendingAuth.delete(playlist._id); - } + this.tokens.clearPendingIf(playlist._id, slotEntry); } } - /** - * 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. - */ - private retireFailedToken( + /** Delegates the watchdog + write-back half of an authentication. */ + private applySessionOutcome( playlistId: string, - failedToken: string | null + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string ): void { - if (failedToken && this.getCachedToken(playlistId) === failedToken) { - this.clearCachedToken(playlistId); - } - } - - /** - * Checks if a response or error indicates an authorization failure - */ - private isAuthorizationError(responseOrError: unknown): boolean { - if (!responseOrError) return false; - - const response = responseOrError as Record; - - // The complete set of portal auth failures — the plain-text bodies - // (`Authorization failed.`, `Access denied.`, `Unauthorized - // request.`) and their JSON-envelope forms. 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. - if ( - isStalkerAuthFailureResponse(responseOrError) || - isStalkerAuthFailureMessage(response?.['message']) - ) { - return true; - } - - // HTTP auth codes surviving 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 response?.['message'] === 'string' && - /HTTP Error 40[13]\b/.test(response['message']) - ) { - return true; - } - - // Everything above is structural. What used to follow was a - // `JSON.stringify(responseOrError)` sweep with an UNANCHORED - // `authorization failed` pattern — the same false positive the body - // detector was just anchored against, reachable through a different - // door: a short proxy/WAF page containing the phrase retired the - // token, retried, and threw `Authorization failed after retry`, - // whose own message then matched the repair trigger and re-probed a - // portal that never refused anything. The shared detector already - // covers every real shape, including the `js.error`/`js.msg` - // envelopes, so the sweep only added the false positive. - return response?.['status'] === 401 || response?.['status'] === 403; + this.sessionStore.applyAuthenticationOutcome( + playlistId, + result, + stored, + fingerprint, + this.watchdog + ); } /** @@ -839,12 +544,15 @@ export class StalkerSessionService { ); // Check for authorization failure in response - if (this.isAuthorizationError(response)) { + if (isStalkerAuthorizationFailure(response)) { // Retire the failed token even when not retrying (e.g. // watchdog pings): leaving it cached would hand a dead // session to the next caller. - this.retireFailedToken(playlist._id, token); - if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) { + this.tokens.retireFailed(playlist._id, token); + if ( + retryOnAuthFailure && + isFullStalkerPortalPlaylist(playlist) + ) { // Retry once with fresh authentication return this.makeAuthenticatedRequest( playlist, @@ -853,16 +561,22 @@ export class StalkerSessionService { ); } - throw new Error('Authorization failed after retry'); + const failureBody = + extractStalkerAuthFailureBody(response) ?? undefined; + throw new StalkerPortalError( + 'auth-failed', + failureBody, + failureBody + ); } return response; } catch (error) { // Check if error indicates auth failure - if (this.isAuthorizationError(error)) { + if (isStalkerAuthorizationFailure(error)) { // Same rule as above: a failed session is retired even on // the no-retry path. - this.retireFailedToken(playlist._id, token); + this.tokens.retireFailed(playlist._id, token); if ( retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts new file mode 100644 index 000000000..64015da2e --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts @@ -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(); + + /** + * 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); + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts new file mode 100644 index 000000000..760dc488f --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts @@ -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; + + 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 { + 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(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts new file mode 100644 index 000000000..3e94a8708 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts @@ -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 + ) => Promise; + /** + * 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; + logger: ReturnType; +} + +interface WatchdogTimers { + timeslotTimeout?: ReturnType; + interval?: ReturnType; +} + +/** + * 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(); + /** Activation-time snapshot; only a fallback when the row cannot be read. */ + private readonly playlists = new Map(); + private readonly inFlight = new Set(); + /** Survives stop/start: the cadence is a per-portal fact, not session state. */ + private readonly timings = new Map(); + 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 { + 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 { + 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); +} diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 144d062a0..dbf621fa1 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -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', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 2b3031066..942f980c8 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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)); diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 2721ce84e..f16e5a709 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -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 */ diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts index 4332d46e7..fd6940178 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts @@ -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( diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts index eb2d44edc..39927e0db 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts @@ -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: ''}` 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' || diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index 39eefd3e7..f8fedcd72 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -157,9 +157,7 @@ } @else if (isStalkerCategoryFailed()) { } @else { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index fc2927f01..102c124d0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -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. diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index bbdd99343..752a99ecd 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -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',