diff --git a/.changes/stalker-endpoint-discovery.md b/.changes/stalker-endpoint-discovery.md new file mode 100644 index 000000000..ed93eb173 --- /dev/null +++ b/.changes/stalker-endpoint-discovery.md @@ -0,0 +1,11 @@ +--- +type: fix +area: stalker +issues: [850, 686, 755] +--- + +Stalker portals are no longer classified by their URL shape: importing probes +the real API endpoint (`portal.php` vs `server/load.php`) and checks whether +the portal actually requires authentication. Canonical Ministra URLs finally +load content, `…/c` addresses resolve correctly, and misclassified existing +portals repair themselves on first failure — keeping favorites and history. diff --git a/CLAUDE.md b/CLAUDE.md index 491e6ce4a..9143e8159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1026,7 +1026,7 @@ engine` (restart required) or - Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in `libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`; `WorkspaceShellHeaderService.openAccountInfoFor()` picks the dialog by playlist type. - Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live. -- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes (full portals: handshake+`get_profile`; `portal.php`: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields). Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog"). +- 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. **Favorites and Recently Viewed**: diff --git a/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts b/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts new file mode 100644 index 000000000..6578220e3 --- /dev/null +++ b/apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts @@ -0,0 +1,257 @@ +import type { Page } from '@playwright/test'; +import { + addStalkerPortal, + closeElectronApp, + expect, + launchElectronApp, + openSources, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + stalkerMockServer, + test, + waitForStalkerCatalog, +} from './electron-test-fixtures'; + +/** + * Endpoint discovery + behavior-based portal mode (issues #850, #686, #755). + * + * One persisted field — `isFullStalkerPortal` — decides whether the app + * authenticates at all. It used to be a URL-shape guess frozen at import: + * canonical `…/server/load.php` portals were persisted as token-free (every + * request answered the plain-text `Authorization failed.`), and `…/c` URLs + * were rewritten to a `portal.php` official Ministra never serves (404). + * + * The mock mirrors both worlds: `/portal.php` is a tolerant reseller panel, + * `/server/load.php` enforces the Bearer token like real middleware, and the + * `/ministra/*` prefix serves ONLY `server/load.php` — a genuine Ministra + * host where `portal.php` 404s. + */ + +// Non-scenario MACs: every MAC gets deterministic auto-generated catalog +// data, and per-MAC state cannot collide with the scenario MACs other spec +// files rely on. +const CANONICAL_IMPORT_MAC = '00:1A:79:00:00:21'; +const MINISTRA_IMPORT_MAC = '00:1A:79:00:00:22'; +const RESELLER_IMPORT_MAC = '00:1A:79:00:00:23'; +const REPAIR_FLAG_MAC = '00:1A:79:00:00:24'; +const REPAIR_ENDPOINT_MAC = '00:1A:79:00:00:25'; +const HEALTHY_RESELLER_MAC = '00:1A:79:00:00:26'; + +interface StoredPortalConfig { + portalUrl: unknown; + isFullStalkerPortal: unknown; +} + +type PlaylistStorageWindow = Window & { + electron: { + dbGetAppPlaylists: () => Promise[]>; + dbUpsertAppPlaylist: ( + playlist: Record + ) => Promise; + }; +}; + +async function readStoredPortalConfig( + page: Page, + title: string +): Promise { + return page.evaluate(async (playlistTitle) => { + const electron = (window as unknown as PlaylistStorageWindow).electron; + const playlists = await electron.dbGetAppPlaylists(); + const row = playlists.find( + (playlist) => playlist['title'] === playlistTitle + ); + return row + ? { + portalUrl: row['portalUrl'], + isFullStalkerPortal: row['isFullStalkerPortal'], + } + : null; + }, title); +} + +async function seedStalkerPlaylist( + page: Page, + row: { + id: string; + title: string; + macAddress: string; + portalUrl: string; + isFullStalkerPortal: boolean; + } +): Promise { + const nowIso = new Date().toISOString(); + await page.evaluate(async (playlist) => { + const electron = (window as unknown as PlaylistStorageWindow).electron; + await electron.dbUpsertAppPlaylist(playlist); + }, { + _id: row.id, + title: row.title, + macAddress: row.macAddress, + portalUrl: row.portalUrl, + isFullStalkerPortal: row.isFullStalkerPortal, + count: 0, + autoRefresh: false, + importDate: nowIso, + lastUsage: nowIso, + favorites: [], + recentlyViewed: [], + }); +} + +async function openSeededPortal(page: Page, title: string): Promise { + await openSources(page); + await sourceRowByTitle(page, title).first().click(); + await waitForStalkerCatalog(page); +} + +test('@electron @stalker import discovery resolves canonical, ministra-/c and reseller URLs', async ({ + dataDir, + request, +}) => { + test.setTimeout(240_000); + await resetMockServers(request, ['stalker']); + + const app = await launchElectronApp(dataDir); + + try { + // 1) Canonical Ministra endpoint pasted directly (#850): the old + // import predicate missed `/server/load.php`, persisted the portal + // as token-free and every content request answered the plain-text + // "Authorization failed." — portal added, no content. + await addStalkerPortal(app.mainWindow, { + name: 'Canonical Load PHP', + macAddress: CANONICAL_IMPORT_MAC, + portalUrl: `${stalkerMockServer}/server/load.php`, + }); + await waitForStalkerCatalog(app.mainWindow); + expect( + await readStoredPortalConfig(app.mainWindow, 'Canonical Load PHP') + ).toEqual({ + portalUrl: `${stalkerMockServer}/server/load.php`, + isFullStalkerPortal: true, + }); + + // 2) The `…/c` browser URL on a genuine Ministra host (#686/#755): + // the old rewrite produced `…/portal.php`, which 404s there. The + // probe must fall through the 404 to `server/load.php` and persist + // full-portal mode. + await openSources(app.mainWindow); + await addStalkerPortal(app.mainWindow, { + name: 'Ministra Slash C', + macAddress: MINISTRA_IMPORT_MAC, + portalUrl: `${stalkerMockServer}/ministra/c`, + }); + await waitForStalkerCatalog(app.mainWindow); + expect( + await readStoredPortalConfig(app.mainWindow, 'Ministra Slash C') + ).toEqual({ + portalUrl: `${stalkerMockServer}/ministra/server/load.php`, + isFullStalkerPortal: true, + }); + + // 3) Reseller `…/c` URL: portal.php answers without a token, so the + // pre-discovery behavior (portal.php endpoint, simple mode, no + // handshake) must be preserved exactly. + await openSources(app.mainWindow); + await addStalkerPortal(app.mainWindow, { + name: 'Reseller Panel', + macAddress: RESELLER_IMPORT_MAC, + portalUrl: `${stalkerMockServer}/c`, + }); + await waitForStalkerCatalog(app.mainWindow); + expect( + await readStoredPortalConfig(app.mainWindow, 'Reseller Panel') + ).toEqual({ + portalUrl: `${stalkerMockServer}/portal.php`, + isFullStalkerPortal: false, + }); + } finally { + await closeElectronApp(app); + } +}); + +test('@electron @stalker lazy repair fixes misclassified stored portals and never touches working ones', async ({ + dataDir, + request, +}) => { + test.setTimeout(240_000); + await resetMockServers(request, ['stalker']); + + let app = await launchElectronApp(dataDir); + + try { + // Rows exactly as the pre-discovery code persisted them. + await seedStalkerPlaylist(app.mainWindow, { + id: 'repair-flag-only', + title: 'Misclassified Canonical', + macAddress: REPAIR_FLAG_MAC, + portalUrl: `${stalkerMockServer}/server/load.php`, + // The old import predicate persisted false for this URL. + isFullStalkerPortal: false, + }); + await seedStalkerPlaylist(app.mainWindow, { + id: 'repair-endpoint', + title: 'Broken Portal PHP Rewrite', + macAddress: REPAIR_ENDPOINT_MAC, + // The old `…/c` rewrite on a genuine Ministra host: 404 forever. + portalUrl: `${stalkerMockServer}/ministra/portal.php`, + isFullStalkerPortal: false, + }); + await seedStalkerPlaylist(app.mainWindow, { + id: 'healthy-reseller', + title: 'Healthy Reseller', + macAddress: HEALTHY_RESELLER_MAC, + portalUrl: `${stalkerMockServer}/portal.php`, + isFullStalkerPortal: false, + }); + + // Fresh session so the seeded rows load like any long-existing + // playlist (the repair must work for users, not for this test). + const restarted = await restartElectronApp(app, dataDir); + app = restarted; + + // Misclassified canonical portal: the first content request answers + // the plain-text auth failure, repair re-probes, proves the endpoint + // enforces the token, flips ONLY the flag and retries — the catalog + // must render in the same session. + await openSeededPortal(app.mainWindow, 'Misclassified Canonical'); + await expect + .poll(() => + readStoredPortalConfig(app.mainWindow, 'Misclassified Canonical') + ) + .toEqual({ + portalUrl: `${stalkerMockServer}/server/load.php`, + isFullStalkerPortal: true, + }); + + // Dead portal.php rewrite: the 404 triggers the repair, which lands + // on the canonical endpoint in full mode. + await openSeededPortal(app.mainWindow, 'Broken Portal PHP Rewrite'); + await expect + .poll(() => + readStoredPortalConfig( + app.mainWindow, + 'Broken Portal PHP Rewrite' + ) + ) + .toEqual({ + portalUrl: `${stalkerMockServer}/ministra/server/load.php`, + isFullStalkerPortal: true, + }); + + // Working reseller panel: browsing succeeds without any auth, so the + // repair never runs and the stored row stays byte-identical — the + // conservative core of the migration story. + await openSeededPortal(app.mainWindow, 'Healthy Reseller'); + expect( + await readStoredPortalConfig(app.mainWindow, 'Healthy Reseller') + ).toEqual({ + portalUrl: `${stalkerMockServer}/portal.php`, + isFullStalkerPortal: false, + }); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 2dc383567..84b64d985 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -100,10 +100,15 @@ ipcMain.handle( response.status, response.statusText ); - throw { - message: `HTTP Error: ${response.statusText}`, - status: response.status, - }; + // The numeric code must live in the MESSAGE: ipcRenderer + // strips custom properties from rejected values, and the + // renderer's endpoint discovery needs to tell a 404 (probe + // next candidate) from a network failure (stop probing). + const httpError = new Error( + `HTTP Error ${response.status}: ${response.statusText}` + ) as Error & { status: number }; + httpError.status = response.status; + throw httpError; } // Return the response data @@ -163,16 +168,26 @@ ipcMain.handle( ); // Format error response - if (axios.isAxiosError(error)) { - const errorResponse = { - type: 'ERROR', - message: - error.response?.data?.message || - error.message || - 'Failed to fetch data from Stalker portal', - status: error.response?.status || 500, - }; - throw errorResponse; + if (axios.isAxiosError(error) && error.response) { + // A real HTTP response (5xx lands here via validateStatus). + // Same parseable message shape as the 4xx branch: only the + // message crosses ipcRenderer.invoke, and the renderer's + // endpoint discovery must tell "this endpoint answered 5xx — + // try the next candidate" from a host-level failure. + const httpError = new Error( + `HTTP Error ${error.response.status}: ${error.response.statusText ?? ''}` + ) as Error & { status: number }; + httpError.status = error.response.status; + throw httpError; + } else if (axios.isAxiosError(error)) { + // A real Error, not a plain object: Electron serializes + // handler rejections via toString(), so a plain object + // reaches the renderer as "[object Object]" and its + // timeout-vs-connection classification is lost — discovery + // would stop probing as if the whole host were unreachable. + throw new Error( + error.message || 'Failed to fetch data from Stalker portal' + ); } else if ( error && typeof error === 'object' && diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 6bd9fd898..cc4138ba6 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -27,30 +27,26 @@ Then in IPTVnator, add a new Stalker portal: - **Portal URL**: `http://localhost:3210/portal.php` (tolerant panel-style endpoint) or `http://localhost:3210/stalker_portal/server/load.php` (canonical Ministra - endpoint — see [Two endpoints](#two-endpoints-tolerant-vs-strict) below) + endpoint — see [Endpoints](#endpoints-tolerant-strict-and-the-ministra-host) below) - **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data) -## Two endpoints: tolerant vs strict +## Endpoints: tolerant, strict, and the /ministra host -The same actions are served at two paths with deliberately different strictness, -because the app treats them differently: a URL containing `/stalker_portal` is -imported as a **full portal** (handshake + token + watchdog), anything else as a -**simple portal** (no authentication at all). +The same actions are served at several paths with deliberately different +strictness. Since endpoint discovery landed, the app no longer guesses the +portal mode from the URL shape: at import it probes `portal.php` → +`server/load.php` → `stalker_portal/server/load.php` and classifies each +endpoint by observed behavior (token-less `get_genres` answering data ⇒ +token-free panel; the plain-text auth failure ⇒ full portal, confirmed by a +real handshake + `get_profile`). The mock's split makes every branch of that +classification exercisable: | Path | Behaviour | |---|---| -| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. | -| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. | -| `/server/load.php` | Strict. The second full-portal URL shape the app recognizes; enforced identically. | - -> **Known app inconsistency:** `StalkerSessionService.isFullStalkerPortal` -> classifies `/server/load.php` as a full portal, but the import dialog's -> `isFullStalkerPortalUrl` checks only for `/stalker_portal`, so importing a bare -> `…/server/load.php` URL persists `isFullStalkerPortal: false` and the app skips -> the handshake. The mock is deliberately faithful to a **real** portal here -> (that path enforces auth), which makes it the right fixture to drive the -> upcoming fix that unifies those two predicates. Until then, import full -> portals through a `/stalker_portal/...` URL. +| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild — discovery classifies it as a token-free simple portal. | +| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware — discovery classifies it as a full portal. | +| `/server/load.php` | Strict, enforced identically. Importing this bare canonical URL now authenticates (the historical import/runtime predicate divergence that skipped the handshake here is fixed). | +| `/ministra/server/load.php` | Strict. The `/ministra/*` prefix simulates a **genuine Ministra host**: `/ministra/portal.php` 404s like a real installation (portal.php is a reseller alias official Stalker never ships), so `http://localhost:3210/ministra/c` exercises the probe's 404 fallthrough end to end. | The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can actually get wrong: diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 27ddea6ea..f6798b37a 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -96,6 +96,14 @@ app.use('/portal.php', portalRouter); app.use('/stalker_portal/server/load.php', createPortalRouter(true)); app.use('/server/load.php', createPortalRouter(true)); +// Genuine-Ministra host simulation: everything under /ministra serves ONLY +// the canonical `server/load.php` endpoint — `/ministra/portal.php` 404s like +// a real Stalker/Ministra installation (portal.php is a reseller-panel alias +// the official middleware never ships). This is what lets e2e prove the +// endpoint-discovery fallthrough: `http://host/ministra/c` must probe +// portal.php, hit the 404, and land on server/load.php in full-portal mode. +app.use('/ministra/server/load.php', createPortalRouter(true)); + /** * Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks * `/stalker_portal/` or `/server/load.php`; import-time normalization checks diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index bdda49a59..7b810053d 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -217,32 +217,6 @@ async function addFullStalkerPortal( } } -/** Portal actions the app sent, in order, with the token each carried. */ -function recordPortalActions(page: Page): { - actions: string[]; - tokensByAction: Map; -} { - const actions: string[] = []; - const tokensByAction = new Map(); - - page.on('request', (request) => { - const url = new URL(request.url()); - if (!url.pathname.endsWith('/stalker')) { - return; - } - const action = url.searchParams.get('action'); - if (!action) { - return; - } - actions.push(action); - if (!tokensByAction.has(action)) { - tokensByAction.set(action, url.searchParams.get('token')); - } - }); - - return { actions, tokensByAction }; -} - const CONTENT_ACTIONS = [ 'get_categories', 'get_genres', @@ -888,7 +862,8 @@ test.describe('@stalker full portal authentication', () => { test('handshakes and authenticates before loading content', async ({ page, }) => { - const { actions, tokensByAction } = recordPortalActions(page); + const requests = recordPortalRequests(page); + const actionsInOrder = () => requests.map((entry) => entry.action); await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC }); @@ -898,32 +873,48 @@ test.describe('@stalker full portal authentication', () => { timeout: 30_000, }); + // Endpoint discovery classifies the portal with a token-less + // get_genres probe BEFORE any authentication: the plain-text + // "Authorization failed." answer is what proves this endpoint + // enforces the token, so the probe must precede the handshake. + const probeIndex = requests.findIndex( + (entry) => entry.action === 'get_genres' + ); + expect(probeIndex).toBeGreaterThanOrEqual(0); + expect(requests[probeIndex].token).toBeFalsy(); + + const actions = actionsInOrder(); expect(actions).toContain('handshake'); expect(actions).toContain('get_profile'); + expect(probeIndex).toBeLessThan(actions.indexOf('handshake')); expect(actions.indexOf('handshake')).toBeLessThan( actions.indexOf('get_profile') ); - const contentAction = actions.find((action) => - ['get_categories', 'get_genres'].includes(action) - ); - expect(contentAction).toBeDefined(); - expect(actions.indexOf('get_profile')).toBeLessThan( - actions.indexOf(contentAction as string) - ); - - // Content requests must carry the token; the handshake must not. - expect(tokensByAction.get('handshake')).toBeFalsy(); - expect(tokensByAction.get(contentAction as string)).toBeTruthy(); + // The first authenticated content request comes after get_profile + // and must carry the adopted token; the handshake must not. + const contentEntry = requests + .slice(actions.indexOf('get_profile') + 1) + .find((entry) => CONTENT_ACTIONS.includes(entry.action)); + expect(contentEntry).toBeDefined(); + expect(contentEntry?.token).toBeTruthy(); + expect( + requests.find((entry) => entry.action === 'handshake')?.token + ).toBeFalsy(); // The full-portal workflow must also keep the watchdog alive — an // authenticated get_events fires immediately (init=1) on activation. // Without this assertion the suite would stay green if the watchdog // wiring silently died, because its failures are swallowed by design. await expect - .poll(() => actions.includes('get_events'), { timeout: 30_000 }) + .poll( + () => + requests.some( + (entry) => entry.action === 'get_events' && entry.token + ), + { timeout: 30_000 } + ) .toBe(true); - expect(tokensByAction.get('get_events')).toBeTruthy(); }); test('never surfaces the portal plain-text auth failure as content', async ({ diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index cef656806..4bef0c784 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -277,6 +277,8 @@ export class ElectronService extends DataService { requestId?: string; token?: string; serialNumber?: string; + /** Endpoint-discovery probes expect failures; no error snackbar. */ + silent?: boolean; }) { const context = createPortalDebugRequestContext({ provider: 'stalker', @@ -295,13 +297,15 @@ export class ElectronService extends DataService { } catch (err: unknown) { const errorInfo = this.getErrorDetails(err); this.logger.error('Stalker request error:', err); - this.snackBar.open( - `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, - 'Close', - { - duration: 5000, - } - ); + if (!payload.silent) { + this.snackBar.open( + `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, + 'Close', + { + duration: 5000, + } + ); + } throw err; } } diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index af92ca677..3052800b0 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -11,6 +11,7 @@ import { EMPTY } from 'rxjs'; import { PLAYLIST_PARSE_BY_URL, PLAYLIST_UPDATE, + STALKER_REQUEST, } from '@iptvnator/shared/interfaces'; import { PwaService } from './pwa.service'; @@ -78,6 +79,47 @@ describe('PwaService', () => { expect(http.match(() => true)).toHaveLength(0); }); + it('surfaces the stalker proxy error envelope as an HTTP error instead of undefined', async () => { + // The web-backend converts an upstream 404 into HTTP 200 with a + // `{ message, status }` body and NO `payload` key. Unwrapping + // `payload` silently returned undefined, so endpoint discovery and + // the lazy portal repair could never classify a dead endpoint. + // JSDOM ships no global fetch — install one for the call under test. + const originalFetch = globalThis.fetch; + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Not Found', status: 404 }), + } as unknown as Response) as unknown as typeof fetch; + + const request = service.sendIpcEvent(STALKER_REQUEST, { + url: 'http://portal.example/portal.php', + macAddress: '00:1A:79:AA:BB:CC', + params: { action: 'get_genres' }, + silent: true, + }) as Promise; + const outcome = (request as Promise).then( + () => { + throw new Error('expected rejection'); + }, + (error: unknown) => error + ); + + // Provider-target registration goes through HttpClient first. + await Promise.resolve(); + const registration = http.expectOne((candidate) => + candidate.url.endsWith('/provider-targets') + ); + registration.flush({ targetId: 'target-1' }); + + const error = (await outcome) as Error & { status?: number }; + expect(error.message).toBe('HTTP Error 404: Not Found'); + expect(error.status).toBe(404); + // silent flag: discovery probes expect failures — no snackbar. + expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + + globalThis.fetch = originalFetch; + }); + it('sends Stalker credentials as /stalker control params, including the serial', async () => { // JSDOM ships no fetch; install one for the proxy call. const fetchMock = jest.fn().mockResolvedValue({ diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 16158549b..6f9b9ba8d 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -139,6 +139,7 @@ export class PwaService extends DataService { params: Record; token?: string; serialNumber?: string; + silent?: boolean; } ) as T; } @@ -481,6 +482,8 @@ export class PwaService extends DataService { macAddress: string; token?: string; serialNumber?: string; + /** Endpoint-discovery probes expect failures; no error snackbar. */ + silent?: boolean; }) { let context = createPortalDebugRequestContext({ provider: 'stalker', @@ -534,6 +537,29 @@ export class PwaService extends DataService { // Parse and return the JSON response const responseBody = await response.json(); + + // The proxy converts upstream provider failures (404 on an + // absent endpoint, 5xx) into an HTTP 200 `{ message, status }` + // body WITHOUT a `payload` key. Surface those as errors carrying + // the status so endpoint discovery and the lazy portal repair + // can classify them — unwrapping `payload` here silently + // returned `undefined`, making a dead endpoint look like an + // empty answer and unreachable to the repair. + if ( + responseBody && + typeof responseBody === 'object' && + !('payload' in responseBody) && + typeof responseBody.status === 'number' + ) { + const proxyError = new Error( + `HTTP Error ${responseBody.status}: ${ + responseBody.message ?? '' + }` + ) as Error & { status: number }; + proxyError.status = responseBody.status; + throw proxyError; + } + logPortalDebugEvent( createPortalDebugSuccessEvent(context, responseBody) ); @@ -543,13 +569,15 @@ export class PwaService extends DataService { logPortalDebugEvent(createPortalDebugErrorEvent(context, err)); this.logger.error('Stalker request error:', err); - this.snackBar.open( - `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, - 'Close', - { - duration: 5000, - } - ); + if (!payload.silent) { + this.snackBar.open( + `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, + 'Close', + { + duration: 5000, + } + ); + } throw err; } } diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 0e58ebfc1..ae4bcf58c 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -39,19 +39,22 @@ Stalker portals use MAC address as the primary credential. The mock server follo No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs. -### Two Endpoints With Different Strictness +### Endpoints With Different Strictness -The app decides how to talk to a portal from the shape of its URL: a URL -containing `/stalker_portal` is imported as a **full portal** (handshake, -`Authorization: Bearer`, watchdog), anything else as a **simple portal** with no -authentication at all. The mock therefore serves the same action set at two -paths: +The app classifies a portal by observed behavior, not by URL shape: endpoint +discovery (see `docs/architecture/stalker-portal.md`, "Portal Mode and +Endpoint Discovery") probes candidates at import and on lazy repair, treating +a token-less content request that returns data as a token-free panel and the +middleware's plain-text auth failure as a token-enforcing full portal. The +mock serves the same action set at several paths so every classification +branch is exercisable: | Path | Router | Behaviour | |---|---|---| | `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels | | `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware | -| `/server/load.php` | `createPortalRouter(true)` | Strict: the second URL shape `isFullStalkerPortal` recognizes | +| `/server/load.php` | `createPortalRouter(true)` | Strict: the bare canonical Ministra shape, enforced identically | +| `/ministra/server/load.php` | `createPortalRouter(true)` | Strict; the `/ministra/*` prefix has **no portal.php** (404s like genuine Ministra), so `/ministra/c` proves the probe's 404 fallthrough | The `/stalker` proxy route applies the same rule through `isFullPortalUrlShape()` — every URL the client would authenticate against is diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index ba5228c29..0ddde12fc 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -48,10 +48,97 @@ Primary route tree lives in 1. Angular Stalker screens call methods/resources in `StalkerStore`. 2. `StalkerStore` builds request params based on selected content type and current view state. -3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth). +3. Every portal API call funnels through `executeStalkerRequest()` + (`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts`), + the single choke point that decides the transport per portal mode: + full portals go through `StalkerSessionService` (handshake + Bearer token + + retry), token-free panels call + `DataService.sendIpcEvent(STALKER_REQUEST, ...)` directly. It also hooks + the lazy portal repair (see "Portal Mode and Endpoint Discovery"). 4. Electron main process handles `STALKER_REQUEST` in `apps/electron-backend/src/app/events/stalker.events.ts`. -5. Axios calls Stalker `load.php` API with required headers/cookies and returns the raw `response.data` to the renderer; normalization happens in the store feature slices. +5. Axios calls the portal's persisted API endpoint (`portal.php` on + reseller panels, `server/load.php` on canonical Stalker/Ministra) with + required headers/cookies and returns the raw `response.data` to the + renderer; normalization happens in the store feature slices. + +## Portal Mode and Endpoint Discovery + +Two portal modes exist, persisted per playlist as +`Playlist.isFullStalkerPortal`: + +- **Full portal** (canonical Stalker/Ministra middleware): every request + except `handshake`, `get_profile`, `get_localization`, and `do_auth` + requires `Authorization: Bearer `; auth failures are HTTP 200 with a + plain-text body (`Authorization failed.`, `Access denied.`, + `Unauthorized request.`), never a 401/403. 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. +- **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle + at all — requests carry only the `mac=` cookie. + +The single predicate lives in `@iptvnator/shared/interfaces` +(`stalker-portal-mode.util.ts`): `isFullStalkerPortalPlaylist()` treats the +persisted flag as authoritative and falls back to the URL shape +(`isFullStalkerPortalUrl()`: `/stalker_portal` or `/server/load.php`) only +for legacy rows where the flag is undefined. Historically three diverging +copies of this rule existed (import, session service, legacy-flag migration) +and their drift shipped broken configurations (#850, #686, #755); no new +consumer may re-implement the rule. + +**Endpoint discovery (import).** `portal.php` does not exist in official +Stalker/Ministra — it is a reseller-panel alias; the canonical endpoint +derived from a `…/c` URL is `/server/load.php`. Instead of guessing +from the URL shape, `StalkerPortalDiscoveryService` +(`libs/portal/stalker/data-access`) probes candidates in order — the pasted +URL itself when it already names a `.php` endpoint, then `/portal.php` +→ `/server/load.php` → `/stalker_portal/server/load.php` — and +classifies each endpoint by observed behavior: a token-less +`itv/get_genres` that returns data proves a token-free panel; the plain-text +auth failure proves the endpoint enforces the token, which is confirmed by +running the real handshake + `get_profile`. The import dialog persists the +proven endpoint and mode. When no candidate answers at all, panel-style URLs +fall back to the pre-discovery behavior (legacy `…/c` → `portal.php` rewrite, +simple mode, import succeeds with a warning) so temporarily offline panels +can still be added; canonical-shaped URLs abort like the old mandatory +handshake did (both classifications run on the normalized +`origin + pathname` form). Probe failure sequencing: ANY resolvable HTTP +status moves to the next candidate — 4xx means the endpoint is absent, a +5xx can be one broken handler beside a healthy sibling — and 401/403 +specifically classify as auth-required (the handshake is attempted, for +middlewares that answer HTTP auth codes instead of the stock 200 + +plain-text body). Status-less TIMEOUTS also continue (a single handler can +hang); only connection-level failures (refused, unresolvable host) abort +discovery, since every candidate shares the host. + +**Lazy repair (existing playlists).** The flag is frozen in the DB, so +records persisted by the old guess stay broken without repair — but a large +share of users are on working reseller panels, and only probing can tell the +two apart, so there is deliberately **no eager one-shot migration**. Instead +`StalkerPortalRepairService` re-probes a portal only after a request +actually failed with a shape that a wrong endpoint/mode produces (the +plain-text auth bodies AND their JSON envelopes (`js.error`/`js.msg`), +HTTP 404 (endpoint absent), HTTP 401/403 (endpoint behind an HTTP auth +gate), and terminal handshake/profile errors — never timeouts or other +network failures), at most once per SOURCE CONFIGURATION (endpoint + mode + MAC + +identity fingerprint) per playlist per session — an edited configuration +may probe when it fails, while every already-probed one stays latched for +the session — and persists only a configuration discovery has proven to +answer, and only when it differs from the failing one. A repaired configuration is applied immediately via an +in-session override inside `executeStalkerRequest()` (stale store snapshots +keep working) and persisted through `PlaylistsService.transformPlaylistMeta` +— the verification and the patch run in ONE slot of the per-playlist write +queue, so a user edit that is queued but not yet committed wins over the +repair instead of being overwritten; the transform patches the freshly read +row (`portalUrl` + `isFullStalkerPortal` only, so user state can never be +clobbered) and returns null to abort. Deletion runs through the same queue, +so a repair can never resurrect a playlist deleted mid-probe. Portals +that work are never probed, let alone rewritten. E2E coverage: +`apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts` against the +mock's tolerant `/portal.php`, strict `/server/load.php`, and +`portal.php`-less `/ministra/*` hosts. ## Main UI Components @@ -606,14 +693,19 @@ counter, MAC/phone, and portal details. Data flow (two sources, cached-first): - Cached: `Playlist.stalkerAccountInfo`, captured from `get_profile` at - import time for full `/stalker_portal/` installations. The dialog loads it - by playlist id (the meta row does not carry it) and renders instantly with - a "Saved data" badge. + import time for portals discovery classified as FULL (endpoint discovery + decides this by behavior, so a token-enforcing `portal.php` panel is a + full portal too). The dialog loads it by playlist id (the meta row does + not carry it) and renders instantly with a "Saved data" badge. - Fresh: `StalkerAccountInfoService` (`libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts`). - Full portals re-run handshake + `get_profile`; `portal.php` panels are - queried with `account_info/get_main_info`, whose field set varies between - panels and is mapped best-effort (absent fields render nothing). A failed + Routing follows the observed MODE, never the endpoint shape: full-mode + portals re-run handshake + `get_profile`, simple-mode panels are queried + with `account_info/get_main_info`, whose field set varies between panels + and is mapped best-effort (absent fields render nothing). Both directions + re-route after a lazy repair changes the mode mid-request, so a portal + repaired from simple to full switches to the profile flow and vice + versa. A failed refresh keeps the cached snapshot and flags it. The two no-data outcomes differ: a portal that answers but publishes no account facts (and no cached snapshot exists) renders the ready-state "No account details" diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md index b062fd7ca..e1ad8b994 100644 --- a/docs/architecture/stalker-store-api-baseline.md +++ b/docs/architecture/stalker-store-api-baseline.md @@ -59,7 +59,16 @@ These are currently reachable on the store object and used internally by compute - `getContentResource` (resource) - `serialSeasonsResource` (resource) - `vodSeriesSeasonsResource` (resource) -- `makeStalkerRequest(...)` + +Removed: + +- `makeStalkerRequest(...)` — deleted with the endpoint-discovery work. It + was production-dead (no caller outside its own spec) and carried a fourth + private copy of the portal-mode branch. Every Stalker request goes through + `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`), which + owns mode routing plus the lazy portal repair; no facade alias is provided + because reinstating one would reintroduce the drift the shared predicate + exists to prevent. During refactor: 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 dd9034684..d966e25fd 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,17 +2,23 @@ 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 { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access'; import { StalkerPortalImportComponent } from './stalker-portal-import.component'; describe('StalkerPortalImportComponent identity handling', () => { let component: StalkerPortalImportComponent; - let stalkerSession: { authenticate: jest.Mock }; + let portalDiscovery: { discover: jest.Mock }; let store: { dispatch: jest.Mock }; beforeEach(() => { - stalkerSession = { - authenticate: jest.fn().mockResolvedValue({ token: 'token-1' }), + portalDiscovery = { + discover: jest.fn().mockResolvedValue({ + status: 'resolved', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + }), }; store = { dispatch: jest.fn(), @@ -20,7 +26,10 @@ describe('StalkerPortalImportComponent identity handling', () => { TestBed.configureTestingModule({ providers: [ - { provide: StalkerSessionService, useValue: stalkerSession }, + { + provide: StalkerPortalDiscoveryService, + useValue: portalDiscovery, + }, { provide: Store, useValue: store }, { provide: MatSnackBar, @@ -54,8 +63,8 @@ describe('StalkerPortalImportComponent identity handling', () => { await component.addPlaylist(); - expect(stalkerSession.authenticate).toHaveBeenCalledWith( - 'https://portal.example.com/stalker_portal/server/load.php', + expect(portalDiscovery.discover).toHaveBeenCalledWith( + 'https://portal.example.com/stalker_portal/c', '00:1A:79:AA:BB:CC', { serialNumber: 'CUSTOMSN123', @@ -69,6 +78,10 @@ describe('StalkerPortalImportComponent identity handling', () => { const playlist = store.dispatch.mock.calls[0][0].playlist; expect(playlist).toEqual( expect.objectContaining({ + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + stalkerToken: 'token-1', stalkerSerialNumber: 'CUSTOMSN123', stalkerDeviceId1: 'DEVICE-ID-1', stalkerDeviceId2: 'DEVICE-ID-2', @@ -99,8 +112,8 @@ describe('StalkerPortalImportComponent identity handling', () => { await component.addPlaylist(); - expect(stalkerSession.authenticate).toHaveBeenCalledWith( - 'https://portal.example.com/stalker_portal/server/load.php', + expect(portalDiscovery.discover).toHaveBeenCalledWith( + 'https://portal.example.com/stalker_portal/c', '00:1A:79:AA:BB:CC', {} ); @@ -117,4 +130,52 @@ describe('StalkerPortalImportComponent identity handling', () => { expect(playlist.signature1).toBeUndefined(); expect(playlist.signature2).toBeUndefined(); }); + + 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. + portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' }); + component.form.patchValue({ + _id: 'playlist-3', + title: 'Query Panel', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://panel.example.com/c?redirect=/server/load.php', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist).toEqual( + expect.objectContaining({ + portalUrl: 'https://panel.example.com/portal.php', + isFullStalkerPortal: false, + }) + ); + }); + + it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => { + // Offline panel: discovery finds nothing, the legacy guess imports + // anyway — but the suffix rewrite must run on the PATH, or + // `/c?key=value` would persist the browser page instead of + // portal.php (and a 200 HTML answer is not a repair trigger later). + portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' }); + component.form.patchValue({ + _id: 'playlist-2', + title: 'Offline Panel', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://panel.example.com/c?key=value', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist).toEqual( + expect.objectContaining({ + portalUrl: 'https://panel.example.com/portal.php', + isFullStalkerPortal: false, + }) + ); + }); }); 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 0345965ad..5a0b67222 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,17 @@ import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { + legacyTransformStalkerPortalUrl, + normalizeStalkerPortalInputUrl, + StalkerPortalDiscoveryService, StalkerPortalIdentity, - StalkerSessionService, normalizeStalkerPortalIdentity, } from '@iptvnator/portal/stalker/data-access'; -import { createRandomId, Playlist } from '@iptvnator/shared/interfaces'; +import { + createRandomId, + isFullStalkerPortalUrl, + Playlist, +} from '@iptvnator/shared/interfaces'; @Component({ imports: [ @@ -72,7 +78,7 @@ export class StalkerPortalImportComponent { userAgent: new FormControl(''), }); - private readonly stalkerSessionService = inject(StalkerSessionService); + private readonly portalDiscovery = inject(StalkerPortalDiscoveryService); private readonly store = inject(Store); private readonly snackBar = inject(MatSnackBar); readonly translate = inject(TranslateService); @@ -107,9 +113,6 @@ export class StalkerPortalImportComponent { try { const formValue = this.form.getRawValue(); const originalUrl = formValue.portalUrl ?? ''; - const transformedUrl = this.transformPortalUrl(originalUrl); - const isFullStalkerPortal = - this.isFullStalkerPortalUrl(originalUrl); const stalkerIdentity = normalizeStalkerPortalIdentity({ serialNumber: formValue.serialNumber ?? undefined, deviceId1: formValue.deviceId1 ?? undefined, @@ -118,55 +121,88 @@ export class StalkerPortalImportComponent { signature2: formValue.signature2 ?? undefined, }); + // Probe candidate endpoints and classify the portal by observed + // behavior (does it enforce the handshake token?) instead of + // guessing from the URL shape — the guess persisted broken + // configurations for canonical `…/server/load.php` portals and + // rewrote `…/c` to a `portal.php` official Ministra never serves. + const discovery = await this.portalDiscovery.discover( + originalUrl, + formValue.macAddress ?? '', + stalkerIdentity + ); + + let portalUrl: string; + let isFullStalkerPortal: boolean; let stalkerToken: string | undefined; let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined; - // For full stalker portal URLs, perform handshake and get profile - if (isFullStalkerPortal) { - try { - const authResult = - await this.stalkerSessionService.authenticate( - transformedUrl, - formValue.macAddress ?? '', - stalkerIdentity - ); + if (discovery.status === 'resolved') { + portalUrl = discovery.portalUrl; + isFullStalkerPortal = discovery.isFullStalkerPortal; + stalkerToken = discovery.token; - stalkerToken = authResult.token; + if (discovery.accountInfo) { + stalkerAccountInfo = { + login: discovery.accountInfo.login, + expireDate: discovery.accountInfo.expire_date, + tariffPlanName: + discovery.accountInfo.tariff_plan_name, + status: discovery.accountInfo.status, + }; + } - if (authResult.accountInfo) { - stalkerAccountInfo = { - login: authResult.accountInfo.login, - expireDate: authResult.accountInfo.expire_date, - tariffPlanName: - authResult.accountInfo.tariff_plan_name, - status: authResult.accountInfo.status, - }; - } - - // Show success notification with account info if available - if (stalkerAccountInfo?.expireDate) { - const expireDate = new Date( - stalkerAccountInfo.expireDate * 1000 - ); - this.snackBar.open( - `Portal validated. Expires: ${expireDate.toLocaleDateString()}`, - undefined, - { duration: 3000 } - ); - } - } catch (error) { - console.error( - '[StalkerImport] Authentication failed:', - error + if (stalkerAccountInfo?.expireDate) { + const expireDate = new Date( + stalkerAccountInfo.expireDate * 1000 ); this.snackBar.open( - 'Failed to authenticate with portal. Please check URL and MAC address.', + `Portal validated. Expires: ${expireDate.toLocaleDateString()}`, undefined, - { duration: 5000 } + { duration: 3000 } ); - this.isLoading.set(false); - return; } + } else if (discovery.status === 'auth-rejected') { + console.error( + '[StalkerImport] Authentication failed:', + discovery.error + ); + this.snackBar.open( + 'Failed to authenticate with portal. Please check URL and MAC address.', + undefined, + { duration: 5000 } + ); + return; + } else if ( + isFullStalkerPortalUrl( + normalizeStalkerPortalInputUrl(originalUrl) ?? originalUrl + ) + ) { + // Unreachable host on a canonical-portal URL shape: the old + // flow aborted here too (its mandatory handshake could not + // succeed either). + this.snackBar.open( + 'Failed to authenticate with portal. Please check URL and MAC address.', + undefined, + { duration: 5000 } + ); + return; + } else { + // Unreachable host on a panel-style URL: import with the + // legacy guess exactly like before discovery existed, so a + // temporarily offline panel can still be added. The lazy + // portal repair re-probes on the first real failure. + // Normalized first: the legacy suffix rewrites run on the + // path, so a query/fragment must not hide a trailing `/c`. + portalUrl = legacyTransformStalkerPortalUrl( + normalizeStalkerPortalInputUrl(originalUrl) ?? originalUrl + ); + isFullStalkerPortal = false; + this.snackBar.open( + 'Portal did not respond; added without validation.', + undefined, + { duration: 5000 } + ); } const { @@ -180,7 +216,7 @@ export class StalkerPortalImportComponent { const playlist: Playlist = { ...playlistFormValue, - portalUrl: transformedUrl, + portalUrl, isFullStalkerPortal, stalkerToken, stalkerAccountInfo, @@ -194,14 +230,6 @@ export class StalkerPortalImportComponent { } } - /** - * Checks if the URL is a full stalker portal URL that requires handshake authentication - * Pattern: example.com/stalker_portal/c or example.com/stalker_portal/... - */ - isFullStalkerPortalUrl(url: string): boolean { - return url.includes('/stalker_portal'); - } - private toPlaylistIdentityFields(identity: StalkerPortalIdentity): { stalkerSerialNumber?: string; stalkerDeviceId1?: string; @@ -228,48 +256,4 @@ export class StalkerPortalImportComponent { }; } - /** - * Transforms the portal URL to the correct API endpoint - * - Simple URL (example.com/c) -> example.com/portal.php - * - Full stalker portal (example.com/stalker_portal/c) -> example.com/stalker_portal/server/load.php - */ - transformPortalUrl(url: string): string { - // Remove trailing slashes - url = url.replace(/\/+$/, ''); - - // Case 1: Simple URL ending with /c -> convert to /portal.php - if (url.endsWith('/c')) { - // Check if it's a full stalker portal URL - if (url.includes('/stalker_portal')) { - // example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php - return url.replace( - /\/stalker_portal\/c$/, - '/stalker_portal/server/load.php' - ); - } - // Simple URL: example.com/c -> example.com/portal.php - return url.replace(/\/c$/, '/portal.php'); - } - - // Case 2: Full stalker portal URL without /c at the end - if ( - url.includes('/stalker_portal') && - !url.includes('/server/load.php') - ) { - // example.com/stalker_portal -> example.com/stalker_portal/server/load.php - if (url.endsWith('/stalker_portal')) { - return url + '/server/load.php'; - } - // If it has other path segments after /stalker_portal, append server/load.php - if (!url.endsWith('/load.php')) { - return url.replace( - /\/stalker_portal(\/.*)?$/, - '/stalker_portal/server/load.php' - ); - } - } - - // Otherwise keep the provided url - return url; - } } 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 839f40001..c1d726560 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 @@ -19,10 +19,12 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { buildStalkerExternalPlaybackHeaders, + executeStalkerRequest, getStalkerPortalOrigin, isCrossOriginStalkerStream, normalizeStalkerPlaybackCommand, resolveStalkerPlaybackUrl, + StalkerPortalRepairService, StalkerSessionService, } from '@iptvnator/portal/stalker/data-access'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; @@ -71,6 +73,7 @@ export class StreamResolverService { private readonly dataService = inject(DataService); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly stalkerSession = inject(StalkerSessionService); + private readonly portalRepair = inject(StalkerPortalRepairService); private readonly m3uEpgTimeoutMs = 3000; private readonly portalEpgTimeoutMs = 10000; private readonly xtreamEpgCache = new Map(); @@ -352,8 +355,18 @@ export class StreamResolverService { }; let response: StalkerCreateLinkResponse | undefined; - if (playlist?.isFullStalkerPortal && playlist) { - response = await this.stalkerSession.makeAuthenticatedRequest( + // Items opened from global collections can carry their own portal + // coordinates with no playlist row; only a playlist-backed request + // can go through the shared mode routing + lazy portal repair. When + // the row exists it wins over the item's snapshot of the portal URL + // (a repaired endpoint must beat a stale favorite). + if (playlist) { + response = await executeStalkerRequest( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, playlist, params ); @@ -367,14 +380,23 @@ export class StreamResolverService { const rawCmd = response?.js?.cmd ?? ''; + // Re-read the override AFTER the request: a lazy repair may have + // moved the endpoint during this very call, and both the relative + // `js.cmd` resolution and the playback header origin must follow + // the endpoint that actually answered. + const effectivePortalUrl = playlist + ? (this.portalRepair.applyOverride(playlist).portalUrl ?? + portalUrl) + : portalUrl; + return this.buildStalkerPlayback(item, playlist, { macAddress, - portalUrl, + portalUrl: effectivePortalUrl, // Shared normalizer from the Stalker store: strips the solution // prefix and resolves relative `/media/...` or `?...` responses // against the portal base instead of returning them verbatim. streamUrl: resolveStalkerPlaybackUrl( - portalUrl, + effectivePortalUrl, item.stalkerCmd ?? '', rawCmd ), @@ -1005,19 +1027,16 @@ export class StreamResolverService { size: String(size), }; - let response: StalkerEpgResponse; - if (playlist.isFullStalkerPortal) { - response = await this.stalkerSession.makeAuthenticatedRequest( + const response: StalkerEpgResponse = + await executeStalkerRequest( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, playlist, params ); - } else { - response = await this.dataService.sendIpcEvent(STALKER_REQUEST, { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params, - }); - } const epgData = Array.isArray(response?.js) ? response.js diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 6f84e52cc..1b5f1df69 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -4,6 +4,9 @@ export * from './lib/stalker-account-info.service'; export * from './lib/stalker-content-types'; export * from './lib/stalker-itv-cache.service'; export * from './lib/stalker-live-playback.utils'; +export * from './lib/stalker-portal-discovery.service'; +export * from './lib/stalker-portal-discovery.utils'; +export * from './lib/stalker-portal-repair.service'; export * from './lib/stalker-series.adapters'; export * from './lib/stalker-session.service'; export * from './lib/stalker-vod.utils'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts index dbd4417f1..22bf0de47 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts @@ -6,6 +6,7 @@ import { parseStalkerDate, StalkerAccountInfoService, } from './stalker-account-info.service'; +import { StalkerPortalRepairService } from './stalker-portal-repair.service'; import { StalkerSessionService } from './stalker-session.service'; describe('StalkerAccountInfoService', () => { @@ -15,6 +16,11 @@ describe('StalkerAccountInfoService', () => { refreshAccountProfile: jest.Mock; makeAuthenticatedRequest: jest.Mock; }; + let portalRepair: { + applyOverride: jest.Mock; + shouldAttemptRepair: jest.Mock; + repairPortal: jest.Mock; + }; const portalPlaylist = { _id: 'stalker-1', @@ -39,11 +45,20 @@ describe('StalkerAccountInfoService', () => { refreshAccountProfile: jest.fn(), makeAuthenticatedRequest: jest.fn(), }; + portalRepair = { + applyOverride: jest.fn((playlist) => playlist), + shouldAttemptRepair: jest.fn().mockReturnValue(false), + repairPortal: jest.fn().mockResolvedValue(null), + }; TestBed.configureTestingModule({ providers: [ { provide: DataService, useValue: dataService }, { provide: StalkerSessionService, useValue: stalkerSession }, + { + provide: StalkerPortalRepairService, + useValue: portalRepair, + }, ], }); @@ -86,6 +101,185 @@ describe('StalkerAccountInfoService', () => { }); }); + it('repairs the portal and retries when the profile request hits a repair trigger', async () => { + // The full-portal profile path bypasses executeStalkerRequest, so + // opening the dialog on a playlist with a stale endpoint must be + // able to repair it instead of just failing. + const notFound = new Error('HTTP Error 404: Not Found'); + stalkerSession.refreshAccountProfile + .mockRejectedValueOnce(notFound) + .mockResolvedValueOnce({ login: 'user-1' }); + const repaired = { + ...fullPortalPlaylist, + portalUrl: 'http://portal.example/stalker_portal/server/load.php', + } as PlaylistMeta; + portalRepair.shouldAttemptRepair.mockReturnValue(true); + portalRepair.repairPortal.mockResolvedValue(repaired); + + const snapshot = await service.fetchAccountInfo(fullPortalPlaylist); + + expect(portalRepair.repairPortal).toHaveBeenCalledWith( + fullPortalPlaylist + ); + expect( + stalkerSession.refreshAccountProfile + ).toHaveBeenLastCalledWith( + expect.objectContaining({ portalUrl: repaired.portalUrl }) + ); + expect(snapshot).toMatchObject({ login: 'user-1' }); + }); + + it('re-routes to get_main_info when the repair proves the portal is simple', async () => { + // The playlist was wrongly marked full; discovery proves it is a + // token-free panel, so retrying the handshake profile would fail + // identically — the retry must use the simple-portal path. + stalkerSession.refreshAccountProfile.mockRejectedValue( + new Error('HTTP Error 404: Not Found') + ); + dataService.sendIpcEvent.mockResolvedValue({ + js: { login: 'panel-user' }, + }); + portalRepair.shouldAttemptRepair.mockReturnValue(true); + portalRepair.repairPortal.mockResolvedValue({ + ...fullPortalPlaylist, + portalUrl: 'http://portal.example/portal.php', + isFullStalkerPortal: false, + } as PlaylistMeta); + + const snapshot = await service.fetchAccountInfo(fullPortalPlaylist); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + STALKER_REQUEST, + expect.objectContaining({ + params: expect.objectContaining({ action: 'get_main_info' }), + }) + ); + expect(snapshot).toMatchObject({ login: 'panel-user' }); + }); + + it('re-routes to the profile flow when a repair proves the portal is full', async () => { + // Legacy row marked simple: get_main_info goes through + // executeStalkerRequest, whose repair flips the mode to full and + // retries the same (wrong) action. The dialog must then switch to + // handshake + get_profile instead of showing nothing. + dataService.sendIpcEvent.mockResolvedValue({ js: null }); + const repaired = { + ...portalPlaylist, + portalUrl: 'http://portal.example/server/load.php', + isFullStalkerPortal: true, + } as PlaylistMeta; + portalRepair.applyOverride + .mockImplementationOnce((value: PlaylistMeta) => value) + .mockImplementation(() => repaired); + stalkerSession.refreshAccountProfile.mockResolvedValue({ + login: 'full-user', + }); + + const snapshot = await service.fetchAccountInfo(portalPlaylist); + + expect(stalkerSession.refreshAccountProfile).toHaveBeenCalledWith( + expect.objectContaining({ isFullStalkerPortal: true }) + ); + expect(snapshot).toMatchObject({ login: 'full-user' }); + }); + + it('prefers the profile flow over a PARTIAL main-info answer after a mode repair', async () => { + // A bare login from get_main_info must not win over the profile + // flow once the repair proved the portal is full — expiry and + // tariff live only behind handshake + get_profile. + dataService.sendIpcEvent.mockResolvedValue({ + js: { login: 'partial-user' }, + }); + const repaired = { + ...portalPlaylist, + portalUrl: 'http://portal.example/server/load.php', + isFullStalkerPortal: true, + } as PlaylistMeta; + portalRepair.applyOverride + .mockImplementationOnce((value: PlaylistMeta) => value) + .mockImplementationOnce((value: PlaylistMeta) => value) + .mockImplementation(() => repaired); + stalkerSession.refreshAccountProfile.mockResolvedValue({ + login: 'full-user', + expire_date: '1795000000', + tariff_plan_name: 'Premium', + }); + + const snapshot = await service.fetchAccountInfo(portalPlaylist); + + expect(snapshot).toMatchObject({ + login: 'full-user', + tariffPlanName: 'Premium', + }); + }); + + it('keeps the partial main-info facts when the profile flow publishes nothing', async () => { + dataService.sendIpcEvent.mockResolvedValue({ + js: { login: 'partial-user' }, + }); + const repaired = { + ...portalPlaylist, + portalUrl: 'http://portal.example/server/load.php', + isFullStalkerPortal: true, + } as PlaylistMeta; + // applyOverride runs twice before the repair lands (routing, then + // inside executeStalkerRequest); only afterwards does it report the + // repaired playlist. + portalRepair.applyOverride + .mockImplementationOnce((value: PlaylistMeta) => value) + .mockImplementationOnce((value: PlaylistMeta) => value) + .mockImplementation(() => repaired); + stalkerSession.refreshAccountProfile.mockResolvedValue(undefined); + + const snapshot = await service.fetchAccountInfo(portalPlaylist); + + expect(snapshot).toMatchObject({ login: 'partial-user' }); + }); + + it('re-routes to the profile flow when the post-repair main-info retry rejects', async () => { + // A full installation that does not implement get_main_info answers + // the internal retry with 404 — the rejection must reach the + // repaired-mode check instead of failing the dialog. + dataService.sendIpcEvent.mockRejectedValue( + new Error('HTTP Error 404: Not Found') + ); + const repaired = { + ...portalPlaylist, + portalUrl: 'http://portal.example/server/load.php', + isFullStalkerPortal: true, + } as PlaylistMeta; + portalRepair.applyOverride + .mockImplementationOnce((value: PlaylistMeta) => value) + .mockImplementationOnce((value: PlaylistMeta) => value) + .mockImplementation(() => repaired); + stalkerSession.refreshAccountProfile.mockResolvedValue({ + login: 'full-user', + }); + + const snapshot = await service.fetchAccountInfo(portalPlaylist); + + expect(snapshot).toMatchObject({ login: 'full-user' }); + }); + + it('rethrows a main-info failure when no repair changed the mode', async () => { + const boom = new Error('HTTP Error 404: Not Found'); + dataService.sendIpcEvent.mockRejectedValue(boom); + + await expect(service.fetchAccountInfo(portalPlaylist)).rejects.toBe( + boom + ); + }); + + it('rethrows profile failures the repair declines to act on', async () => { + const boom = new Error('timeout of 15000ms exceeded'); + stalkerSession.refreshAccountProfile.mockRejectedValue(boom); + + await expect( + service.fetchAccountInfo(fullPortalPlaylist) + ).rejects.toBe(boom); + expect(portalRepair.repairPortal).not.toHaveBeenCalled(); + }); + it('returns null when the full-portal profile has no account block', async () => { stalkerSession.refreshAccountProfile.mockResolvedValue(undefined); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts index aa6afb855..215cd8171 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts @@ -1,6 +1,10 @@ import { inject, Injectable } from '@angular/core'; import { DataService } from '@iptvnator/services'; -import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + isFullStalkerPortalPlaylist, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; +import { StalkerPortalRepairService } from './stalker-portal-repair.service'; import { StalkerSessionService } from './stalker-session.service'; import { executeStalkerRequest, @@ -63,6 +67,7 @@ interface StalkerMainInfoResponse { export class StalkerAccountInfoService { private readonly dataService = inject(DataService); private readonly stalkerSession = inject(StalkerSessionService); + private readonly portalRepair = inject(StalkerPortalRepairService); async fetchAccountInfo( playlist: PlaylistMeta @@ -71,15 +76,82 @@ export class StalkerAccountInfoService { return null; } - if (isFullStalkerPortalPlaylist(playlist)) { - return this.fetchViaProfile(playlist); + const effectivePlaylist = this.portalRepair.applyOverride(playlist); + if (isFullStalkerPortalPlaylist(effectivePlaylist)) { + return this.fetchViaProfile(effectivePlaylist); } - return this.fetchViaMainInfo(playlist); + // A REJECTED main-info call must reach the same repaired-mode check + // as an empty or partial one: a repair can flip the portal to full + // mid-request, and a full installation that does not implement + // `get_main_info` answers the internal retry with 404. + let snapshot: StalkerAccountSnapshot | null = null; + let mainInfoError: unknown; + try { + snapshot = await this.fetchViaMainInfo(effectivePlaylist); + } catch (error) { + mainInfoError = error; + } + + // `fetchViaMainInfo` runs through executeStalkerRequest, whose lazy + // repair retries the SAME action internally. If that repair proved + // the portal is actually a full one, `get_main_info` was the wrong + // call: canonical installations publish subscription details only + // through handshake + get_profile, so even a PARTIAL main-info + // answer (a bare login) must not win over the profile flow. Checked + // before accepting the snapshot, symmetric with the full→simple + // re-route in `fetchViaProfile`. + const repairedPlaylist = this.portalRepair.applyOverride(playlist); + if ( + isFullStalkerPortalPlaylist(repairedPlaylist) && + !isFullStalkerPortalPlaylist(effectivePlaylist) + ) { + const profileSnapshot = + await this.fetchViaProfile(repairedPlaylist); + // Keep the partial main-info facts if the profile path itself + // publishes nothing — losing data to the re-route would be + // worse than the incomplete answer. + return profileSnapshot ?? snapshot; + } + + // No mode change: a main-info failure is the caller's failure. + if (mainInfoError !== undefined) { + throw mainInfoError; + } + + return snapshot; } private async fetchViaProfile( playlist: PlaylistMeta + ): Promise { + try { + return await this.requestProfileSnapshot(playlist); + } catch (error) { + // The profile path does not go through executeStalkerRequest, + // so wire the same lazy repair here: opening the account dialog + // on a playlist with a stale endpoint must be able to fix it + // instead of waiting for an unrelated catalog request. + if (!this.portalRepair.shouldAttemptRepair(playlist, error)) { + throw error; + } + + const repaired = await this.portalRepair.repairPortal(playlist); + if (!repaired) { + throw error; + } + + // Re-enter the MODE routing: a repair can prove the portal is a + // token-free panel, and retrying the handshake-based profile + // against it would fail exactly the same way. + return isFullStalkerPortalPlaylist(repaired) + ? this.requestProfileSnapshot(repaired) + : this.fetchViaMainInfo(repaired); + } + } + + private async requestProfileSnapshot( + playlist: PlaylistMeta ): Promise { // Goes through the session service rather than calling // authenticate() directly: it serializes with any in-flight @@ -109,6 +181,7 @@ export class StalkerAccountInfoService { { dataService: this.dataService, stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, }, playlist, { @@ -146,26 +219,10 @@ export class StalkerAccountInfoService { } } -/** - * Whether a playlist should use the full `/stalker_portal/` flow. - * - * The persisted flag is authoritative when present, but a playlist - * restored from an older backup can carry `undefined` after the one-shot - * metadata migration has already run — fall back to the same URL rule - * that migration uses (`withExplicitLegacyStalkerPortalFlag` in - * PlaylistsService) rather than mislabelling it as a legacy panel. - */ -export function isFullStalkerPortalPlaylist(playlist: PlaylistMeta): boolean { - if (playlist.isFullStalkerPortal !== undefined) { - return Boolean(playlist.isFullStalkerPortal); - } - - const portalUrl = playlist.portalUrl ?? playlist.url ?? ''; - return ( - portalUrl.includes('/stalker_portal') || - portalUrl.includes('/server/load.php') - ); -} +// The portal-mode predicate moved to `@iptvnator/shared/interfaces` +// (stalker-portal-mode.util) so every consumer shares one rule; re-exported +// here for existing importers. +export { isFullStalkerPortalPlaylist }; function normalizeSnapshot( snapshot: StalkerAccountSnapshot diff --git a/libs/portal/stalker/data-access/src/lib/stalker-identity.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-identity.utils.ts index 99b35c0c1..a619466fa 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-identity.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-identity.utils.ts @@ -1,4 +1,5 @@ import { + normalizeStalkerIdentityValue, normalizeStalkerPortalIdentity as normalizeSharedStalkerPortalIdentity, type Playlist, type StalkerPortalIdentity, @@ -13,6 +14,37 @@ export { type StalkerPortalIdentity, } from '@iptvnator/shared/interfaces'; +/** + * Canonical fingerprint of WHO a portal session belongs to: the MAC plus + * every Stalker identity field, trim-normalized so blank and absent values + * are equivalent. The repair override, the once-per-config probe latch and + * the session token cache are all keyed/validated with this — a session + * negotiated for one fingerprint must never serve another. + */ +export function stalkerIdentityFingerprint( + playlist: Pick< + Playlist, + | 'macAddress' + | 'stalkerSerialNumber' + | 'stalkerDeviceId1' + | 'stalkerDeviceId2' + | 'stalkerSignature1' + | 'stalkerSignature2' + > +): string { + // JSON-encoded, not delimiter-joined: identity values are unrestricted + // strings, and an unescaped separator would let distinct tuples alias + // each other and bypass the identity invalidation. + return JSON.stringify([ + normalizeStalkerIdentityValue(playlist.macAddress) ?? '', + normalizeStalkerIdentityValue(playlist.stalkerSerialNumber) ?? '', + normalizeStalkerIdentityValue(playlist.stalkerDeviceId1) ?? '', + normalizeStalkerIdentityValue(playlist.stalkerDeviceId2) ?? '', + normalizeStalkerIdentityValue(playlist.stalkerSignature1) ?? '', + normalizeStalkerIdentityValue(playlist.stalkerSignature2) ?? '', + ]); +} + export function getStalkerPortalIdentityFromPlaylist( playlist: Pick< Playlist, diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts index ef647483a..ad042ba62 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts @@ -52,7 +52,9 @@ function pageOf(items: unknown[], totalItems: number, pageSize = 14) { const UNSUPPORTED_ACTION = { js: { error: 'Unknown action: get_all_channels' } }; -async function flushMicrotasks(times = 5): Promise { +// Depth 10: executeStalkerRequest routes through an extra async hop for +// the portal-repair pipeline, so page transitions settle a tick later. +async function flushMicrotasks(times = 10): Promise { for (let index = 0; index < times; index += 1) { await Promise.resolve(); } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts index f16bcbee5..3400e8038 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts @@ -7,6 +7,7 @@ import { StalkerItvLoadProgress, loadFullItvChannelList, } from './stalker-itv-channel-loader'; +import { StalkerPortalRepairService } from './stalker-portal-repair.service'; import { StalkerSessionService } from './stalker-session.service'; import { StalkerRequestDeps } from './stores/utils'; @@ -35,6 +36,7 @@ export class StalkerItvCacheService { private readonly requestDeps: StalkerRequestDeps = { dataService: inject(DataService), stalkerSession: inject(StalkerSessionService), + portalRepair: inject(StalkerPortalRepairService), }; /** Portal keys whose full channel list is loaded. */ 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 new file mode 100644 index 000000000..38471a5ca --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts @@ -0,0 +1,346 @@ +import { TestBed } from '@angular/core/testing'; +import { DataService } from '@iptvnator/services'; +import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service'; +import { StalkerSessionService } from './stalker-session.service'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const MAC = '00:1A:79:AA:BB:CC'; + +describe('StalkerPortalDiscoveryService', () => { + let service: StalkerPortalDiscoveryService; + let sendIpcEvent: jest.Mock; + let authenticate: jest.Mock; + + /** Maps probed endpoint URL → resolved value or rejection. */ + function mockProbes( + handlers: Record + ): void { + sendIpcEvent.mockImplementation((_event, payload) => { + const { url } = payload as { url: string }; + const handler = handlers[url]; + if (!handler) { + return Promise.reject( + new Error(`unexpected probe for ${url}`) + ); + } + if ('reject' in handler) { + return Promise.reject(handler.reject); + } + return Promise.resolve(handler.resolve); + }); + } + + beforeEach(() => { + sendIpcEvent = jest.fn(); + authenticate = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { provide: DataService, useValue: { sendIpcEvent } }, + { provide: StalkerSessionService, useValue: { authenticate } }, + ], + }); + + service = TestBed.inject(StalkerPortalDiscoveryService); + }); + + it('resolves a tolerant portal.php panel as a simple portal without authenticating', async () => { + mockProbes({ + 'http://panel.example/portal.php': { + resolve: { js: [{ id: '1', title: 'News' }] }, + }, + }); + + const outcome = await service.discover('http://panel.example/c', MAC); + + expect(outcome).toEqual({ + status: 'resolved', + portalUrl: 'http://panel.example/portal.php', + isFullStalkerPortal: false, + }); + expect(authenticate).not.toHaveBeenCalled(); + // The winning candidate ends discovery — no further probes. + expect(sendIpcEvent).toHaveBeenCalledTimes(1); + }); + + it('falls through a 404 portal.php to server/load.php and classifies by handshake', async () => { + mockProbes({ + 'http://ministra.example/portal.php': { + reject: { message: 'HTTP Error: Not Found', status: 404 }, + }, + 'http://ministra.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + authenticate.mockResolvedValue({ + token: 'TOKEN1', + accountInfo: { login: 'user-1' }, + }); + + const outcome = await service.discover( + 'http://ministra.example/c', + MAC, + { serialNumber: 'SN1' } + ); + + expect(outcome).toEqual({ + status: 'resolved', + portalUrl: 'http://ministra.example/server/load.php', + isFullStalkerPortal: true, + token: 'TOKEN1', + accountInfo: { login: 'user-1' }, + }); + expect(authenticate).toHaveBeenCalledWith( + 'http://ministra.example/server/load.php', + MAC, + { serialNumber: 'SN1' } + ); + }); + + it('resolves a pasted canonical URL in full mode without probing portal.php first', async () => { + mockProbes({ + 'http://ministra.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + authenticate.mockResolvedValue({ token: 'TOKEN2' }); + + const outcome = await service.discover( + 'http://ministra.example/server/load.php', + MAC + ); + + expect(outcome).toMatchObject({ + status: 'resolved', + portalUrl: 'http://ministra.example/server/load.php', + isFullStalkerPortal: true, + }); + expect(sendIpcEvent).toHaveBeenCalledTimes(1); + }); + + it('classifies a token-enforcing portal.php panel as a full portal', async () => { + // Strict reseller panels exist; behavior beats the URL shape. + mockProbes({ + 'http://strict.example/portal.php': { + resolve: 'Authorization failed.', + }, + }); + authenticate.mockResolvedValue({ token: 'TOKEN3' }); + + const outcome = await service.discover('http://strict.example/c', MAC); + + expect(outcome).toMatchObject({ + status: 'resolved', + portalUrl: 'http://strict.example/portal.php', + isFullStalkerPortal: true, + }); + }); + + it('reports auth-rejected when an endpoint demands auth we cannot complete', async () => { + mockProbes({ + 'http://ministra.example/portal.php': { + reject: { message: 'HTTP Error: Not Found', status: 404 }, + }, + 'http://ministra.example/server/load.php': { + resolve: 'Authorization failed.', + }, + 'http://ministra.example/stalker_portal/server/load.php': { + reject: { message: 'HTTP Error: Not Found', status: 404 }, + }, + }); + authenticate.mockRejectedValue(new Error('Profile error: blocked')); + + const outcome = await service.discover( + 'http://ministra.example/c', + MAC + ); + + expect(outcome).toMatchObject({ + status: 'auth-rejected', + portalUrl: 'http://ministra.example/server/load.php', + }); + }); + + it('treats an HTTP 401/403 probe answer as auth-required, not endpoint-absent', async () => { + // Non-standard middlewares answer 401 where the stock server sends + // HTTP 200 + plain text; skipping the candidate would abort imports + // for portals that previously authenticated directly. + mockProbes({ + 'http://gated.example/portal.php': { + reject: { message: 'HTTP Error 401: Unauthorized', status: 401 }, + }, + }); + authenticate.mockResolvedValue({ token: 'TOKEN4' }); + + const outcome = await service.discover('http://gated.example/c', MAC); + + expect(outcome).toMatchObject({ + status: 'resolved', + portalUrl: 'http://gated.example/portal.php', + isFullStalkerPortal: true, + }); + expect(authenticate).toHaveBeenCalledWith( + 'http://gated.example/portal.php', + MAC, + {} + ); + }); + + it('records a 401 candidate as auth-rejected when the handshake is refused', async () => { + mockProbes({ + 'http://gated.example/portal.php': { + reject: { message: 'HTTP Error 403: Forbidden', status: 403 }, + }, + 'http://gated.example/server/load.php': { + reject: { message: 'HTTP Error 404: Not Found', status: 404 }, + }, + 'http://gated.example/stalker_portal/server/load.php': { + reject: { message: 'HTTP Error 404: Not Found', status: 404 }, + }, + }); + authenticate.mockRejectedValue(new Error('Handshake failed: No token received')); + + const outcome = await service.discover('http://gated.example/c', MAC); + + expect(outcome).toMatchObject({ + status: 'auth-rejected', + portalUrl: 'http://gated.example/portal.php', + }); + }); + + it('rejects a candidate whose get_profile answers a structured denial', async () => { + // The handshake can hand out a token whose profile call still + // denies; accepting it would persist an unusable endpoint and skip + // the healthy sibling. + mockProbes({ + 'http://mixed.example/portal.php': { + resolve: 'Authorization failed.', + }, + 'http://mixed.example/server/load.php': { + resolve: { js: [{ id: '1' }] }, + }, + }); + authenticate.mockResolvedValue({ + token: 'TOKEN-BAD', + profileResponse: { js: { error: 'Invalid token' } }, + }); + + const outcome = await service.discover('http://mixed.example/c', MAC); + + // Discovery moved on and resolved the healthy sibling instead. + expect(outcome).toEqual({ + status: 'resolved', + portalUrl: 'http://mixed.example/server/load.php', + isFullStalkerPortal: false, + }); + }); + + it('reports unreachable when every candidate 404s', async () => { + mockProbes({ + 'http://empty.example/portal.php': { + reject: { message: 'HTTP Error: Not Found', status: 404 }, + }, + 'http://empty.example/server/load.php': { + reject: { message: 'HTTP Error: Not Found', status: 404 }, + }, + 'http://empty.example/stalker_portal/server/load.php': { + reject: { message: 'HTTP Error: Not Found', status: 404 }, + }, + }); + + const outcome = await service.discover('http://empty.example/c', MAC); + + expect(outcome).toEqual({ status: 'unreachable' }); + }); + + it('stops probing after a network-level failure — all candidates share the host', async () => { + // Post-IPC, a network failure carries no resolvable HTTP status: + // ipcRenderer strips the object shape and the message has no + // "HTTP Error NNN" marker. + mockProbes({ + 'http://down.example/portal.php': { + reject: new Error( + "Error invoking remote method 'STALKER_REQUEST': connect ECONNREFUSED" + ), + }, + }); + + const outcome = await service.discover('http://down.example/c', MAC); + + expect(outcome).toEqual({ status: 'unreachable' }); + expect(sendIpcEvent).toHaveBeenCalledTimes(1); + }); + + it('keeps probing past a candidate timeout — one handler can hang while siblings work', async () => { + mockProbes({ + 'http://slow.example/portal.php': { + reject: new Error( + "Error invoking remote method 'STALKER_REQUEST': timeout of 15000ms exceeded" + ), + }, + 'http://slow.example/server/load.php': { + resolve: { js: [] }, + }, + }); + + const outcome = await service.discover('http://slow.example/c', MAC); + + expect(outcome).toEqual({ + status: 'resolved', + portalUrl: 'http://slow.example/server/load.php', + isFullStalkerPortal: false, + }); + }); + + it('keeps probing past an endpoint-specific 5xx — the host answered', async () => { + // One broken handler (a dead portal.php returning 500) must not + // hide a healthy sibling endpoint on the same host. + mockProbes({ + 'http://flaky.example/portal.php': { + reject: { + message: 'HTTP Error 500: Internal Server Error', + status: 500, + }, + }, + 'http://flaky.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + authenticate.mockResolvedValue({ token: 'TOKEN5' }); + + const outcome = await service.discover('http://flaky.example/c', MAC); + + expect(outcome).toMatchObject({ + status: 'resolved', + portalUrl: 'http://flaky.example/server/load.php', + isFullStalkerPortal: true, + }); + }); + + it('skips endpoints that answer with something that is not a portal', async () => { + mockProbes({ + 'http://mixed.example/portal.php': { + resolve: 'It works!', + }, + 'http://mixed.example/server/load.php': { + resolve: { js: [] }, + }, + }); + + const outcome = await service.discover('http://mixed.example/c', MAC); + + expect(outcome).toEqual({ + status: 'resolved', + portalUrl: 'http://mixed.example/server/load.php', + isFullStalkerPortal: false, + }); + }); +}); 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 new file mode 100644 index 000000000..0764d747a --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts @@ -0,0 +1,252 @@ +import { Injectable, inject } from '@angular/core'; +import { DataService } from '@iptvnator/services'; +import { STALKER_REQUEST } from '@iptvnator/shared/interfaces'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { + StalkerProfileResponse, + StalkerSessionService, +} from './stalker-session.service'; +import { type StalkerPortalIdentity } from './stalker-identity.utils'; +import { + buildStalkerEndpointCandidates, + classifyStalkerProbeResponse, + getStalkerRequestErrorStatus, + isStalkerAuthFailureResponse, + isStalkerProbeTimeout, +} from './stalker-portal-discovery.utils'; + +/** A candidate endpoint answered and its auth behavior was observed. */ +export interface StalkerPortalEndpointResolution { + status: 'resolved'; + /** The endpoint that actually answered content requests. */ + portalUrl: string; + /** Observed behavior: true when the endpoint enforces the Bearer token. */ + isFullStalkerPortal: boolean; + /** Session token from the classification handshake (full portals only). */ + token?: string; + /** Account block from the classification `get_profile` (full portals only). */ + accountInfo?: StalkerProfileResponse['js']['account_info']; +} + +/** + * An endpoint exists and demands authentication, but the handshake/profile + * flow was refused — wrong MAC, blocked account, or a panel we cannot + * authenticate against. Nothing may be persisted from this outcome. + */ +export interface StalkerPortalDiscoveryRejection { + status: 'auth-rejected'; + portalUrl: string; + error?: unknown; +} + +/** No candidate answered like a Stalker portal (host down or not a portal). */ +export interface StalkerPortalDiscoveryUnreachable { + status: 'unreachable'; +} + +export type StalkerPortalDiscoveryOutcome = + | StalkerPortalEndpointResolution + | StalkerPortalDiscoveryRejection + | StalkerPortalDiscoveryUnreachable; + +/** 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; + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Stalker portal probe timed out')), + timeoutMs + ); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +/** + * Resolves which API endpoint a Stalker portal actually answers on and + * whether it enforces the full auth lifecycle — by probing, not by URL + * shape. Used at import time and by the lazy repair of previously + * misclassified playlists. + */ +@Injectable({ providedIn: 'root' }) +export class StalkerPortalDiscoveryService { + private readonly dataService = inject(DataService); + private readonly stalkerSession = inject(StalkerSessionService); + private readonly logger = createLogger('StalkerPortalDiscovery'); + + /** + * Probes candidate endpoints in order and classifies the first one that + * answers. Per candidate: a token-less content request that returns real + * data proves a token-free panel; the middleware's plain-text auth + * failure proves the endpoint exists and enforces the token, which is + * then confirmed by attempting the real handshake + `get_profile` flow. + */ + async discover( + rawUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {} + ): Promise { + const candidates = buildStalkerEndpointCandidates(rawUrl); + let authRejection: StalkerPortalDiscoveryRejection | null = null; + + for (const candidate of candidates) { + let probeResponse: unknown; + try { + probeResponse = await this.probeContent(candidate, macAddress); + } catch (error) { + const status = getStalkerRequestErrorStatus(error); + if (status === 401 || status === 403) { + // The endpoint exists but sits behind an HTTP auth gate — + // non-standard middlewares answer 401/403 where the stock + // server answers 200 + plain text. Attempt the real + // handshake instead of skipping a valid candidate. + const outcome = await this.confirmFullPortal( + candidate, + macAddress, + identity + ); + if (outcome.status === 'resolved') { + return outcome; + } + authRejection = authRejection ?? outcome; + continue; + } + if (status !== undefined) { + // Any resolvable HTTP status proves the HOST answered: + // 4xx means this endpoint is absent, and a 5xx here can + // be one broken handler (a dead /portal.php) while a + // sibling candidate works — keep probing either way. + continue; + } + if (isStalkerProbeTimeout(error)) { + // A timeout can also be one hanging handler with healthy + // siblings; each further candidate stays bounded by its + // own probe budget. + this.logger.warn( + 'Stalker portal probe timed out; trying the next candidate' + ); + continue; + } + // Connection-level failure (refused, unresolvable host): + // every candidate lives on the same host, so further probing + // cannot succeed either. + this.logger.warn( + 'Stalker portal probe failed at network level; stopping discovery' + ); + return authRejection ?? { status: 'unreachable' }; + } + + switch (classifyStalkerProbeResponse(probeResponse)) { + case 'data': + return { + status: 'resolved', + portalUrl: candidate, + isFullStalkerPortal: false, + }; + case 'auth-required': { + const outcome = await this.confirmFullPortal( + candidate, + macAddress, + identity + ); + if (outcome.status === 'resolved') { + return outcome; + } + // The endpoint is real but refused our credentials; + // remember the first such endpoint in case no later + // candidate resolves. + authRejection = authRejection ?? outcome; + continue; + } + case 'not-a-portal': + continue; + } + } + + return authRejection ?? { status: 'unreachable' }; + } + + /** + * Confirms a token-enforcing endpoint by running the real handshake + + * `get_profile` flow against it. + */ + private async confirmFullPortal( + candidate: string, + macAddress: string, + identity: StalkerPortalIdentity + ): Promise< + StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection + > { + try { + const auth = await withTimeout( + this.stalkerSession.authenticate( + candidate, + macAddress, + identity + ), + AUTH_TIMEOUT_MS + ); + // A handshake can hand out a token whose `get_profile` still + // answers a structured denial (`{js:{error:'Invalid token'}}`); + // `authenticate()` only inspects `msg`/`block_msg`, so reporting + // `resolved` here would persist an unusable endpoint and stop + // before a healthy sibling is probed. + if (isStalkerAuthFailureResponse(auth.profileResponse)) { + return { + status: 'auth-rejected', + portalUrl: candidate, + error: auth.profileResponse, + }; + } + return { + status: 'resolved', + portalUrl: candidate, + isFullStalkerPortal: true, + token: auth.token, + accountInfo: auth.accountInfo, + }; + } catch (error) { + return { + status: 'auth-rejected', + portalUrl: candidate, + error, + }; + } + } + + /** + * Token-less, read-only content request (`itv/get_genres`) — the + * cheapest action every Stalker-compatible panel implements and the + * canonical middleware gates behind the Bearer token. + */ + private probeContent(url: string, macAddress: string): Promise { + return withTimeout( + Promise.resolve( + this.dataService.sendIpcEvent(STALKER_REQUEST, { + url, + macAddress, + params: { + type: 'itv', + action: 'get_genres', + JsHttpRequest: '1-xml', + }, + // Probing absent endpoints fails BY DESIGN — the + // transport services skip their error snackbar for us. + silent: true, + }) + ), + PROBE_TIMEOUT_MS + ); + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts new file mode 100644 index 000000000..8b99a9e2d --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts @@ -0,0 +1,385 @@ +import { + buildStalkerEndpointCandidates, + classifyStalkerProbeResponse, + getStalkerRequestErrorStatus, + isStalkerAuthFailureBody, + isStalkerAuthFailureMessage, + isStalkerAuthFailureResponse, + legacyTransformStalkerPortalUrl, + normalizeStalkerPortalInputUrl, +} from './stalker-portal-discovery.utils'; + +describe('buildStalkerEndpointCandidates', () => { + it('probes the standard order for a /c URL users copy from the browser', () => { + expect( + buildStalkerEndpointCandidates('http://portal.example/c') + ).toEqual([ + 'http://portal.example/portal.php', + 'http://portal.example/server/load.php', + 'http://portal.example/stalker_portal/server/load.php', + ]); + }); + + it('probes the same order for a bare host', () => { + expect(buildStalkerEndpointCandidates('http://portal.example')).toEqual( + [ + 'http://portal.example/portal.php', + 'http://portal.example/server/load.php', + 'http://portal.example/stalker_portal/server/load.php', + ] + ); + }); + + it('strips trailing slashes before deriving candidates', () => { + expect( + buildStalkerEndpointCandidates('http://portal.example/c///') + ).toEqual([ + 'http://portal.example/portal.php', + 'http://portal.example/server/load.php', + 'http://portal.example/stalker_portal/server/load.php', + ]); + }); + + it('gives an explicitly pasted .php endpoint the first shot', () => { + expect( + buildStalkerEndpointCandidates( + 'http://portal.example/server/load.php' + ) + ).toEqual([ + 'http://portal.example/server/load.php', + 'http://portal.example/portal.php', + 'http://portal.example/stalker_portal/server/load.php', + ]); + }); + + it('keeps a nonstandard pasted endpoint as the first candidate', () => { + expect( + buildStalkerEndpointCandidates('http://portal.example/cp/portal.php') + ).toEqual([ + 'http://portal.example/cp/portal.php', + 'http://portal.example/cp/server/load.php', + 'http://portal.example/cp/stalker_portal/server/load.php', + ]); + }); + + it('derives standard fallbacks from a nonstandard endpoint\'s directory', () => { + // The pasted endpoint keeps the first shot, but recovery candidates + // must be its SIBLINGS — not paths appended to the file itself. + expect( + buildStalkerEndpointCandidates('http://portal.example/cp/api.php') + ).toEqual([ + 'http://portal.example/cp/api.php', + 'http://portal.example/cp/portal.php', + 'http://portal.example/cp/server/load.php', + 'http://portal.example/cp/stalker_portal/server/load.php', + ]); + }); + + it('keeps a real installation directory named c', () => { + // `/tenant/c/portal.php` means the installation lives in `/tenant/c` + // — the `/c` landing-page rewrite must not strip it, or the + // siblings would be probed one level too high. + expect( + buildStalkerEndpointCandidates( + 'http://portal.example/tenant/c/portal.php' + ) + ).toEqual([ + 'http://portal.example/tenant/c/portal.php', + 'http://portal.example/tenant/c/server/load.php', + 'http://portal.example/tenant/c/stalker_portal/server/load.php', + ]); + }); + + it('never nests stalker_portal twice for a /stalker_portal/c URL', () => { + expect( + buildStalkerEndpointCandidates( + 'http://portal.example/stalker_portal/c' + ) + ).toEqual([ + 'http://portal.example/stalker_portal/portal.php', + 'http://portal.example/stalker_portal/server/load.php', + ]); + }); + + it('deduplicates the pasted canonical stalker_portal endpoint', () => { + expect( + buildStalkerEndpointCandidates( + 'http://portal.example/stalker_portal/server/load.php' + ) + ).toEqual([ + 'http://portal.example/stalker_portal/server/load.php', + 'http://portal.example/stalker_portal/portal.php', + ]); + }); + + it('derives candidates from the pathname when the URL carries a query or fragment', () => { + // Suffix matching on the raw string would keep `/c` and append the + // endpoints inside the query — every probe would still hit /c. + expect( + buildStalkerEndpointCandidates('http://portal.example/c?key=value') + ).toEqual([ + 'http://portal.example/portal.php', + 'http://portal.example/server/load.php', + 'http://portal.example/stalker_portal/server/load.php', + ]); + expect( + buildStalkerEndpointCandidates( + 'http://portal.example:8080/portal.php?sn=1#frag' + ) + ).toEqual([ + 'http://portal.example:8080/portal.php', + 'http://portal.example:8080/server/load.php', + 'http://portal.example:8080/stalker_portal/server/load.php', + ]); + }); + + it('returns no candidates for empty or unparseable URLs', () => { + expect(buildStalkerEndpointCandidates(' ')).toEqual([]); + expect(buildStalkerEndpointCandidates('not-a-url')).toEqual([]); + }); +}); + +describe('normalizeStalkerPortalInputUrl', () => { + it('reduces a URL to origin + pathname', () => { + expect( + normalizeStalkerPortalInputUrl('http://host.example/c?key=value#f') + ).toBe('http://host.example/c'); + expect( + normalizeStalkerPortalInputUrl(' http://host.example:8080/c/ ') + ).toBe('http://host.example:8080/c'); + }); + + it('feeds the legacy fallback transform a rewritable path', () => { + // The offline-import fallback runs the legacy /c → portal.php + // rewrite on this form; unnormalized input would keep the /c page. + expect( + legacyTransformStalkerPortalUrl( + normalizeStalkerPortalInputUrl( + 'http://host.example/c?key=value' + ) ?? '' + ) + ).toBe('http://host.example/portal.php'); + }); + + it('returns null for unparseable input', () => { + expect(normalizeStalkerPortalInputUrl('not-a-url')).toBeNull(); + expect(normalizeStalkerPortalInputUrl(' ')).toBeNull(); + }); + + it('preserves the accepted URL authority instead of rebuilding from origin', () => { + // Basic-auth credentials must not be silently dropped… + expect( + normalizeStalkerPortalInputUrl( + 'https://user:pass@host.example/c?key=value' + ) + ).toBe('https://user:pass@host.example/c'); + // …and file: URLs (origin "null") must stay parseable rather than + // becoming "null/tmp/c" and throwing in the candidate builder. + expect(normalizeStalkerPortalInputUrl('file:///tmp/c')).toBe( + 'file:///tmp/c' + ); + expect(buildStalkerEndpointCandidates('file:///tmp/c')).toEqual([ + 'file:///tmp/portal.php', + 'file:///tmp/server/load.php', + 'file:///tmp/stalker_portal/server/load.php', + ]); + expect( + buildStalkerEndpointCandidates('https://user:pass@host.example/c') + ).toEqual([ + 'https://user:pass@host.example/portal.php', + 'https://user:pass@host.example/server/load.php', + 'https://user:pass@host.example/stalker_portal/server/load.php', + ]); + }); +}); + +describe('isStalkerAuthFailureBody', () => { + it.each([ + 'Authorization failed.', + 'Authorization failed. 75', + 'Access denied.', + 'Unauthorized request.', + ' Authorization failed. ', + ])('recognizes the middleware body %j', (body) => { + expect(isStalkerAuthFailureBody(body)).toBe(true); + }); + + it('rejects long HTML pages that merely mention the phrase', () => { + const page = `Site${'x'.repeat( + 300 + )} access denied ${'y'.repeat(100)}`; + expect(isStalkerAuthFailureBody(page)).toBe(false); + }); + + it('rejects non-string and empty responses', () => { + expect(isStalkerAuthFailureBody({ js: [] })).toBe(false); + expect(isStalkerAuthFailureBody(undefined)).toBe(false); + expect(isStalkerAuthFailureBody(null)).toBe(false); + expect(isStalkerAuthFailureBody('')).toBe(false); + expect(isStalkerAuthFailureBody('OK')).toBe(false); + }); +}); + +describe('isStalkerAuthFailureResponse', () => { + it('recognizes both the plain-text body and the JSON envelope forms', () => { + expect(isStalkerAuthFailureResponse('Authorization failed.')).toBe( + true + ); + expect( + isStalkerAuthFailureResponse({ + js: { error: 'Authorization failed' }, + }) + ).toBe(true); + expect( + isStalkerAuthFailureResponse({ js: { msg: 'Access denied.' } }) + ).toBe(true); + }); + + it('recognizes the wider structured-field phrases the session service accepts', () => { + expect( + isStalkerAuthFailureResponse({ js: { error: 'Invalid token' } }) + ).toBe(true); + expect( + isStalkerAuthFailureResponse({ js: { error: 'Auth failed' } }) + ).toBe(true); + expect( + isStalkerAuthFailureResponse({ js: { msg: 'unauthorized' } }) + ).toBe(true); + }); + + it('does not flag ordinary data or unrelated js errors', () => { + expect(isStalkerAuthFailureResponse({ js: { data: [] } })).toBe(false); + expect( + isStalkerAuthFailureResponse({ + js: { error: 'Unknown action: get_genres' }, + }) + ).toBe(false); + expect(isStalkerAuthFailureResponse(undefined)).toBe(false); + }); +}); + +describe('isStalkerAuthFailureMessage', () => { + it('matches the wide phrase set our own auth layer produces', () => { + expect( + isStalkerAuthFailureMessage('Profile error: Invalid token') + ).toBe(true); + expect(isStalkerAuthFailureMessage('Profile error: Auth failed')).toBe( + true + ); + expect( + isStalkerAuthFailureMessage('Profile error: Access denied.') + ).toBe(true); + }); + + it('stays narrower than a free-form body check for unrelated text', () => { + expect(isStalkerAuthFailureMessage('nothing_to_play')).toBe(false); + expect(isStalkerAuthFailureMessage('timeout of 15000ms exceeded')).toBe( + false + ); + expect(isStalkerAuthFailureMessage(undefined)).toBe(false); + }); + + it('is wider than the plain-text BODY matcher on purpose', () => { + // A portal BODY saying "Invalid token" is not one of the three + // middleware phrases; a controlled Error message is. + expect(isStalkerAuthFailureBody('Invalid token')).toBe(false); + expect(isStalkerAuthFailureMessage('Invalid token')).toBe(true); + }); +}); + +describe('classifyStalkerProbeResponse', () => { + it('classifies a js envelope as data', () => { + expect(classifyStalkerProbeResponse({ js: [] })).toBe('data'); + expect(classifyStalkerProbeResponse({ js: { data: [] } })).toBe('data'); + }); + + it('classifies the plain-text auth failure as auth-required', () => { + expect(classifyStalkerProbeResponse('Authorization failed.')).toBe( + 'auth-required' + ); + }); + + it('classifies the JSON-envelope auth failure as auth-required, not data', () => { + // Some panels answer HTTP 200 + {js:{error:"Authorization failed"}} + // instead of the plain-text body; treating it as data would persist + // the portal as token-free with no repair trigger ever firing. + expect( + classifyStalkerProbeResponse({ + js: { error: 'Authorization failed' }, + }) + ).toBe('auth-required'); + }); + + it('requires a real get_genres data shape, not a bare js key', () => { + // A 200 error envelope must not end discovery on a broken + // candidate — the healthy sibling would never be probed. + expect( + classifyStalkerProbeResponse({ js: { error: 'Unknown action' } }) + ).toBe('not-a-portal'); + expect(classifyStalkerProbeResponse({ js: false })).toBe( + 'not-a-portal' + ); + expect(classifyStalkerProbeResponse({ js: null })).toBe( + 'not-a-portal' + ); + expect(classifyStalkerProbeResponse({ js: {} })).toBe('not-a-portal'); + }); + + it('classifies anything else as not-a-portal', () => { + expect(classifyStalkerProbeResponse('welcome')).toBe( + 'not-a-portal' + ); + expect(classifyStalkerProbeResponse(undefined)).toBe('not-a-portal'); + expect(classifyStalkerProbeResponse({ payload: 1 })).toBe( + 'not-a-portal' + ); + }); +}); + +describe('getStalkerRequestErrorStatus', () => { + it('reads the status the Electron transport throws for HTTP errors', () => { + expect( + getStalkerRequestErrorStatus({ + message: 'HTTP Error 404: Not Found', + status: 404, + }) + ).toBe(404); + }); + + it('parses the status out of the IPC-wrapped message — invoke strips custom properties', () => { + expect( + getStalkerRequestErrorStatus( + new Error( + "Error invoking remote method 'STALKER_REQUEST': HTTP Error 404: Not Found" + ) + ) + ).toBe(404); + // HTTP/2 has no reason phrases; the code alone must be enough. + expect( + getStalkerRequestErrorStatus(new Error('HTTP Error 404: ')) + ).toBe(404); + }); + + it('returns undefined for plain errors', () => { + expect(getStalkerRequestErrorStatus(new Error('boom'))).toBeUndefined(); + expect(getStalkerRequestErrorStatus(undefined)).toBeUndefined(); + expect(getStalkerRequestErrorStatus({ status: '404' })).toBeUndefined(); + }); +}); + +describe('legacyTransformStalkerPortalUrl', () => { + it('keeps the historical rewrites for the unreachable-host fallback', () => { + expect(legacyTransformStalkerPortalUrl('http://x.example/c')).toBe( + 'http://x.example/portal.php' + ); + expect( + legacyTransformStalkerPortalUrl('http://x.example/stalker_portal/c') + ).toBe('http://x.example/stalker_portal/server/load.php'); + expect( + legacyTransformStalkerPortalUrl('http://x.example/stalker_portal') + ).toBe('http://x.example/stalker_portal/server/load.php'); + expect( + legacyTransformStalkerPortalUrl('http://x.example/portal.php') + ).toBe('http://x.example/portal.php'); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts new file mode 100644 index 000000000..40a5ff0f0 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts @@ -0,0 +1,324 @@ +/** + * Pure helpers for Stalker portal endpoint discovery. + * + * The portal API endpoint cannot be derived reliably from the URL a user + * pastes: `portal.php` is a reseller-panel alias that official + * Stalker/Ministra never serves, while genuine installations answer at + * `/server/load.php` (optionally under `/stalker_portal`). Discovery + * therefore probes concrete candidates and classifies each endpoint by how + * it responds, instead of guessing from the URL shape (#850, #686, #755). + */ + +/** + * Candidate API endpoints for a pasted portal URL, in probe order. + * + * An explicit `.php` endpoint pasted by the user (or persisted by a previous + * import) always gets the first shot — nonstandard panel paths exist in the + * wild and must not lose to the standard candidates. After that the order is + * `portal.php` → `server/load.php` → `stalker_portal/server/load.php`, so + * reseller panels resolve exactly as they did before discovery existed. + */ +/** + * Reduces a pasted portal URL to `origin + pathname` (no query, no + * fragment, no trailing slashes). Suffix logic anywhere in discovery must + * run on this form: string-suffix matching on the raw URL would bolt + * endpoint rewrites onto the query instead of the path. Returns null for + * input the URL parser rejects. + */ +export function normalizeStalkerPortalInputUrl(rawUrl: string): string | null { + const trimmed = rawUrl.trim(); + if (!trimmed) { + return null; + } + + try { + // Mutate the parsed URL instead of rebuilding from `origin`: + // origin-reconstruction destroys authority information the import + // validator accepts — file: URLs have origin "null" and basic-auth + // credentials (user:pass@host) would be silently dropped. + const parsed = new URL(trimmed); + parsed.search = ''; + parsed.hash = ''; + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + return parsed.href; + } catch { + return null; + } +} + +export function buildStalkerEndpointCandidates(rawUrl: string): string[] { + // Queries and fragments are dropped from every candidate — the Stalker + // API endpoints take their parameters per request, and a stored query + // would collide with the transport's own query building. + const normalized = normalizeStalkerPortalInputUrl(rawUrl); + if (normalized === null) { + return []; + } + + const parsed = new URL(normalized); + const path = parsed.pathname.replace(/\/+$/, ''); + // Candidates swap only the PATH: scheme, credentials, host and port of + // the accepted URL are preserved verbatim. + const candidateFrom = (candidatePath: string): string => { + const candidate = new URL(parsed.href); + candidate.pathname = candidatePath; + return candidate.href; + }; + + const candidates: string[] = []; + if (/\.php$/i.test(path)) { + candidates.push(candidateFrom(path)); + } + + const base = /\.php$/i.test(path) + ? // An endpoint file was pasted: strip only the FILE part. The `/c` + // landing-page rewrite must not run here — a real installation + // directory named `c` (`/tenant/c/portal.php`) would otherwise be + // stripped too and the siblings probed one level too high. + path + .replace(/\/portal\.php$/i, '') + .replace(/\/server\/load\.php$/i, '') + // A nonstandard pasted endpoint (…/cp/api.php) keeps its + // first-shot candidate above, but the standard fallbacks must + // be its SIBLINGS — the directory, not the file. + .replace(/\/[^/]*\.php$/i, '') + : // The `/c` landing page users copy from the browser is not part + // of the API path. + path.replace(/\/c$/i, ''); + + candidates.push(candidateFrom(`${base}/portal.php`)); + candidates.push(candidateFrom(`${base}/server/load.php`)); + // `/server/load.php` already IS the canonical form when the base + // ends in /stalker_portal — nesting it again would probe a path no + // server has. + if (!/\/stalker_portal(\/|$)/i.test(base)) { + candidates.push(candidateFrom(`${base}/stalker_portal/server/load.php`)); + } + + return [...new Set(candidates)]; +} + +/** + * The stock Stalker middleware answers auth failures with HTTP 200 and a + * bare plain-text body — never a 401/403. These are the three exact strings + * it emits (sometimes with a trailing numeric counter). + */ +const STALKER_AUTH_FAILURE_PATTERNS = [ + /authorization\s+failed/i, + /access\s+denied/i, + /unauthorized\s+request/i, +]; + +/** + * Whether a portal response body is one of the middleware's plain-text auth + * failures. The length cap keeps an arbitrary HTML error page that merely + * mentions "access denied" from being mistaken for the middleware's bare + * phrase. + */ +export function isStalkerAuthFailureBody(response: unknown): boolean { + if (typeof response !== 'string') { + return false; + } + + const body = response.trim(); + if (body.length === 0 || body.length > 200) { + return false; + } + + return STALKER_AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(body)); +} + +/** + * 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 (`{ js: { error: "Authorization failed" } }` / + * `{ js: { msg: … } }` — the same forms + * `StalkerSessionService.isAuthorizationError()` recognizes). 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. + */ +export function isStalkerAuthFailureResponse(response: unknown): boolean { + if (isStalkerAuthFailureBody(response)) { + return true; + } + + if ( + response === null || + typeof response !== 'object' || + !('js' in (response as Record)) + ) { + return false; + } + + const js = (response as { js?: unknown }).js; + if (js === null || typeof js !== 'object') { + return false; + } + + const { error, msg } = js as { error?: unknown; msg?: unknown }; + return [error, msg].some( + (value) => + typeof value === 'string' && isStalkerJsonAuthFailurePhrase(value) + ); +} + +/** + * Auth-failure phrases accepted inside the STRUCTURED `js.error`/`js.msg` + * fields. Deliberately wider than the plain-text body patterns (which stay + * narrow to avoid matching arbitrary HTML pages): these are the same forms + * `StalkerSessionService.isAuthorizationError()` recognizes — panels answer + * "Invalid token", "Auth failed" or bare "unauthorized" here. + */ +const STALKER_JSON_AUTH_FAILURE_PATTERNS = [ + ...STALKER_AUTH_FAILURE_PATTERNS, + /auth\s+failed/i, + /invalid\s+token/i, + /\bunauthorized\b/i, + /authorization/i, +]; + +/** + * Whether an ERROR MESSAGE reports an authorization failure. Uses the wide + * phrase set (including `Invalid token` / `Auth failed`) because the input + * is a controlled string produced by our own auth layer — e.g. + * `Error('Profile error: Invalid token')` — not an arbitrary portal body, + * where the same breadth would false-positive on HTML pages. + */ +export function isStalkerAuthFailureMessage(message: unknown): boolean { + return ( + typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message) + ); +} + +function isStalkerJsonAuthFailurePhrase(value: string): boolean { + const phrase = value.trim(); + if (phrase.length === 0 || phrase.length > 200) { + return false; + } + + return STALKER_JSON_AUTH_FAILURE_PATTERNS.some((pattern) => + pattern.test(phrase) + ); +} + +export type StalkerProbeClassification = 'data' | 'auth-required' | 'not-a-portal'; + +/** + * Classifies what a token-less content request got back from a candidate + * endpoint: real JSON data (token-free panel), the middleware's plain-text + * auth failure (endpoint exists and enforces the token), or something that + * is not a Stalker portal at all. + */ +export function classifyStalkerProbeResponse( + response: unknown +): StalkerProbeClassification { + if (isStalkerAuthFailureResponse(response)) { + return 'auth-required'; + } + + if (response !== null && typeof response === 'object') { + const js = (response as { js?: unknown }).js; + // The probe asks for `itv/get_genres`, whose success shape is a + // list (or a `{data: [...]}` envelope). A bare `js` key is NOT + // enough: panels answer HTTP 200 with `{js: {error: "Unknown + // action"}}` or `{js: false}`, and accepting those would end + // discovery on a broken candidate and persist an empty catalog. + if (Array.isArray(js)) { + return 'data'; + } + if (js !== null && typeof js === 'object') { + const { data, error } = js as { data?: unknown; error?: unknown }; + if (Array.isArray(data) && error === undefined) { + return 'data'; + } + } + } + + return 'not-a-portal'; +} + +/** + * HTTP status carried by a failed Stalker transport call, when there is one. + * The Electron handler rejects with an Error whose message is + * `HTTP Error : ` (network-level failures map to 500) — + * but `ipcRenderer.invoke` strips every custom property from a rejected + * value and re-wraps the message, so in the renderer the numeric `status` + * field usually does NOT survive and the code must be parsed back out of + * the message text. + */ +export function getStalkerRequestErrorStatus( + error: unknown +): number | undefined { + if (error === null || typeof error !== 'object') { + return undefined; + } + + if ('status' in error) { + const status = (error as { status?: unknown }).status; + if (typeof status === 'number') { + return status; + } + } + + if ('message' in error) { + const match = /HTTP Error (\d{3})\b/.exec( + String((error as { message?: unknown }).message ?? '') + ); + if (match) { + return Number(match[1]); + } + } + + return undefined; +} + +/** + * Whether a status-less probe failure is a TIMEOUT (renderer probe budget, + * axios request timeout, ETIMEDOUT). A timeout can be one hanging handler + * while sibling endpoints answer fine, so discovery continues past it; + * connection-level failures (ECONNREFUSED, ENOTFOUND, …) still stop the + * loop — they prove the HOST is unreachable for every candidate. + */ +export function isStalkerProbeTimeout(error: unknown): boolean { + if (error === null || typeof error !== 'object' || !('message' in error)) { + return false; + } + + const message = String((error as { message?: unknown }).message ?? ''); + return /timed out|timeout of \d+\s*ms|ETIMEDOUT/i.test(message); +} + +/** + * The pre-discovery URL rewrite (`…/c` → `portal.php`, `…/stalker_portal/c` + * → `…/stalker_portal/server/load.php`). Kept ONLY as the fallback for + * imports where no candidate could be probed (host unreachable); discovery + * results always win over this guess. + */ +export function legacyTransformStalkerPortalUrl(url: string): string { + url = url.replace(/\/+$/, ''); + + if (url.endsWith('/c')) { + if (url.includes('/stalker_portal')) { + return url.replace( + /\/stalker_portal\/c$/, + '/stalker_portal/server/load.php' + ); + } + return url.replace(/\/c$/, '/portal.php'); + } + + if (url.includes('/stalker_portal') && !url.includes('/server/load.php')) { + if (url.endsWith('/stalker_portal')) { + return url + '/server/load.php'; + } + if (!url.endsWith('/load.php')) { + return url.replace( + /\/stalker_portal(\/.*)?$/, + '/stalker_portal/server/load.php' + ); + } + } + + return url; +} 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 new file mode 100644 index 000000000..fd99719f3 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -0,0 +1,671 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service'; +import { StalkerPortalRepairService } from './stalker-portal-repair.service'; +import { StalkerSessionService } from './stalker-session.service'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const MISCLASSIFIED = { + _id: 'portal-1', + title: 'Canonical Ministra', + portalUrl: 'http://ministra.example/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: false, +} as PlaylistMeta; + +describe('StalkerPortalRepairService', () => { + let service: StalkerPortalRepairService; + let discover: jest.Mock; + let transformPlaylistMeta: jest.Mock; + /** What the persisted row looks like when the repair re-verifies it. */ + let persistedRow: Playlist | undefined; + /** The row the atomic transform actually wrote, if any. */ + let writtenRow: Playlist | null; + /** When set, the atomic write fails AFTER the transform verified. */ + let persistError: Error | null; + let setCachedToken: jest.Mock; + let clearCachedToken: jest.Mock; + let refreshActiveWatchdogPlaylist: jest.Mock; + + beforeEach(() => { + discover = jest.fn(); + persistedRow = MISCLASSIFIED as Playlist; + writtenRow = null; + persistError = null; + // Mirrors PlaylistsService.transformPlaylistMeta semantics: the + // transform runs on the current row inside the write queue; null + // aborts, otherwise the returned row is persisted. + transformPlaylistMeta = jest.fn((_id, transform) => { + if (!persistedRow) { + return of(null); + } + const next = transform(persistedRow) as Playlist | null; + if (next === null) { + return of(null); + } + if (persistError) { + return throwError(() => persistError); + } + writtenRow = next; + return of(next); + }); + setCachedToken = jest.fn(); + clearCachedToken = jest.fn(); + refreshActiveWatchdogPlaylist = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: StalkerPortalDiscoveryService, + useValue: { discover }, + }, + { + provide: PlaylistsService, + useValue: { + transformPlaylistMeta, + getPlaylistById: jest.fn(() => of(persistedRow)), + }, + }, + { + provide: StalkerSessionService, + useValue: { + setCachedToken, + clearCachedToken, + refreshActiveWatchdogPlaylist, + }, + }, + ], + }); + + service = TestBed.inject(StalkerPortalRepairService); + }); + + describe('shouldAttemptRepair', () => { + it('triggers on the middleware plain-text auth bodies', () => { + expect( + service.shouldAttemptRepair(MISCLASSIFIED, 'Authorization failed.') + ).toBe(true); + expect( + service.shouldAttemptRepair( + MISCLASSIFIED, + 'Unauthorized request.' + ) + ).toBe(true); + }); + + it('triggers on HTTP 404 — the persisted endpoint does not exist', () => { + expect( + service.shouldAttemptRepair(MISCLASSIFIED, { + message: 'HTTP Error 404: Not Found', + status: 404, + }) + ).toBe(true); + }); + + it('triggers on HTTP 401/403 — discovery classifies those endpoints as auth-required', () => { + expect( + service.shouldAttemptRepair(MISCLASSIFIED, { + message: 'HTTP Error 401: Unauthorized', + status: 401, + }) + ).toBe(true); + expect( + service.shouldAttemptRepair(MISCLASSIFIED, { + message: 'HTTP Error 403: Forbidden', + status: 403, + }) + ).toBe(true); + // Endpoint-specific server errors are still not repair triggers. + expect( + service.shouldAttemptRepair(MISCLASSIFIED, { + message: 'HTTP Error 500: Internal Server Error', + status: 500, + }) + ).toBe(false); + }); + + it('triggers on terminal session auth errors', () => { + expect( + service.shouldAttemptRepair( + MISCLASSIFIED, + new Error('Authorization failed after retry') + ) + ).toBe(true); + expect( + service.shouldAttemptRepair( + MISCLASSIFIED, + new Error('Handshake failed: No token received') + ) + ).toBe(true); + }); + + it.each([ + 'Profile error: Access denied.', + 'Profile error: Unauthorized request.', + 'Profile error: Invalid token', + 'Profile error: Auth failed', + ])('triggers on the wrapped profile denial %j', (message) => { + // Authentication wraps structured denials; the trigger uses the + // same failure set as discovery and the session service, so + // these cannot bypass the repair. + expect( + service.shouldAttemptRepair(MISCLASSIFIED, new Error(message)) + ).toBe(true); + }); + + it('never triggers on timeouts or other network failures', () => { + expect( + service.shouldAttemptRepair(MISCLASSIFIED, { + type: 'ERROR', + message: 'timeout of 15000ms exceeded', + status: 500, + }) + ).toBe(false); + expect( + service.shouldAttemptRepair(MISCLASSIFIED, { js: [] }) + ).toBe(false); + }); + + it('never triggers for playlists without portal coordinates', () => { + expect( + service.shouldAttemptRepair( + { _id: 'x', macAddress: 'mac' } as PlaylistMeta, + 'Authorization failed.' + ) + ).toBe(false); + }); + }); + + describe('repairPortal', () => { + it('persists a proven different mode and returns the patched playlist', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://ministra.example/server/load.php', + isFullStalkerPortal: true, + token: 'TOKEN1', + }); + + const repaired = await service.repairPortal(MISCLASSIFIED); + + expect(repaired).toMatchObject({ + _id: 'portal-1', + portalUrl: 'http://ministra.example/server/load.php', + isFullStalkerPortal: true, + }); + // The atomic transform patched the FRESH row (verified inside + // the write queue), so user state can never be clobbered. + expect(writtenRow).toMatchObject({ + _id: 'portal-1', + 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( + 'portal-1', + 'TOKEN1', + expect.objectContaining({ _id: 'portal-1' }) + ); + // A repaired ACTIVE playlist must re-sync the watchdog now: a + // simple→full flip has to start the keepalive mid-session. + expect(refreshActiveWatchdogPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'portal-1', + isFullStalkerPortal: true, + }) + ); + }); + + it('repairs a dead portal.php endpoint to the canonical one', async () => { + const wrongEndpoint = { + ...MISCLASSIFIED, + portalUrl: 'http://ministra.example/portal.php', + } as PlaylistMeta; + persistedRow = wrongEndpoint as Playlist; + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://ministra.example/server/load.php', + isFullStalkerPortal: true, + token: 'TOKEN2', + }); + + const repaired = await service.repairPortal(wrongEndpoint); + + expect(repaired?.portalUrl).toBe( + 'http://ministra.example/server/load.php' + ); + expect(service.applyOverride(wrongEndpoint).portalUrl).toBe( + 'http://ministra.example/server/load.php' + ); + }); + + it('changes nothing when probing confirms the stored configuration', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: false, + }); + + const repaired = await service.repairPortal(MISCLASSIFIED); + + expect(repaired).toBeNull(); + expect(writtenRow).toBeNull(); + expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED); + expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); + }); + + it('changes nothing when the probe finds no working configuration', async () => { + discover.mockResolvedValue({ status: 'unreachable' }); + + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(writtenRow).toBeNull(); + }); + + it('changes nothing when the probe is rejected by the portal', async () => { + discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: MISCLASSIFIED.portalUrl, + }); + + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(writtenRow).toBeNull(); + }); + + it('probes at most once per playlist per session', async () => { + discover.mockResolvedValue({ status: 'unreachable' }); + + await service.repairPortal(MISCLASSIFIED); + await service.repairPortal(MISCLASSIFIED); + + expect(discover).toHaveBeenCalledTimes(1); + }); + + it('re-enters for an edited configuration after awaiting a pending probe', async () => { + // A's probe is in flight when a request from the EDITED config B + // fails: after A settles (and is discarded by the row guard), B + // must get its own probe instead of inheriting A's outcome. + const edited = { + ...MISCLASSIFIED, + portalUrl: 'http://edited.example/portal.php', + } as PlaylistMeta; + persistedRow = edited as Playlist; + + let resolveFirstDiscovery!: (value: unknown) => void; + discover.mockReturnValueOnce( + new Promise((resolve) => (resolveFirstDiscovery = resolve)) + ); + discover.mockResolvedValueOnce({ + status: 'resolved', + portalUrl: 'http://edited.example/server/load.php', + isFullStalkerPortal: true, + }); + + const oldRepair = service.repairPortal(MISCLASSIFIED); + const editedRepair = service.repairPortal(edited); + + resolveFirstDiscovery({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + + expect(await oldRepair).toBeNull(); + const repaired = await editedRepair; + expect(discover).toHaveBeenCalledTimes(2); + expect(repaired?.portalUrl).toBe( + 'http://edited.example/server/load.php' + ); + }); + + it('shares one in-flight probe between concurrent failing requests', async () => { + let resolveDiscovery!: (value: unknown) => void; + discover.mockReturnValue( + new Promise((resolve) => (resolveDiscovery = resolve)) + ); + + const first = service.repairPortal(MISCLASSIFIED); + const second = service.repairPortal(MISCLASSIFIED); + resolveDiscovery({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + + const [a, b] = await Promise.all([first, second]); + expect(discover).toHaveBeenCalledTimes(1); + expect(a?.isFullStalkerPortal).toBe(true); + expect(b?.isFullStalkerPortal).toBe(true); + }); + + it('hands out the completed override to later failing callers without re-probing', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + + await service.repairPortal(MISCLASSIFIED); + // A caller still holding the stale playlist object fails and asks + // again: it gets the override without a second probe. + const again = await service.repairPortal(MISCLASSIFIED); + + expect(discover).toHaveBeenCalledTimes(1); + expect(again?.isFullStalkerPortal).toBe(true); + + // A caller already on the repaired configuration gets null — its + // failure has another cause, and retrying would loop. + const alreadyApplied = service.applyOverride(MISCLASSIFIED); + expect(await service.repairPortal(alreadyApplied)).toBeNull(); + }); + + it('drops the override and re-arms probing when the user edits portal metadata', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + await service.repairPortal(MISCLASSIFIED); + expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({ + isFullStalkerPortal: true, + }); + + // The user pointed the playlist somewhere else through the + // playlist dialog: the ID-keyed override must not keep rewriting + // requests to the old repaired endpoint. + const edited = { + ...MISCLASSIFIED, + portalUrl: 'http://other.example/portal.php', + } as PlaylistMeta; + expect(service.applyOverride(edited)).toBe(edited); + + // …and the once-per-session latch re-arms so the EDITED + // configuration may probe if it fails too. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://other.example/server/load.php', + isFullStalkerPortal: true, + }); + persistedRow = edited as Playlist; + const repairedAgain = await service.repairPortal(edited); + expect(discover).toHaveBeenCalledTimes(2); + expect(repairedAgain?.portalUrl).toBe( + 'http://other.example/server/load.php' + ); + }); + + it('discards an in-flight repair when the row was edited during the probe', async () => { + // The probe can run for tens of seconds; a user who saves a new + // portal URL meanwhile must win over the repair of the old one. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + persistedRow = { + ...MISCLASSIFIED, + portalUrl: 'http://edited.example/portal.php', + } as Playlist; + + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(writtenRow).toBeNull(); + expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); + expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED); + }); + + it('discards an in-flight repair when the MAC or identity changed during the probe', async () => { + // The probe authenticated AS an identity — a token and watchdog + // for the old MAC must not be installed onto the edited account. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + persistedRow = { + ...MISCLASSIFIED, + macAddress: '00:1A:79:00:99:99', + } as Playlist; + + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(writtenRow).toBeNull(); + expect(setCachedToken).not.toHaveBeenCalled(); + expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); + }); + + it('never aliases distinct identities across field boundaries', async () => { + // Delimiter-style fingerprints would treat serial "a|b" + + // empty device as equal to serial "a" + device "b" and skip + // the identity invalidation entirely. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + const pipedIdentity = { + ...MISCLASSIFIED, + stalkerSerialNumber: 'a|b', + } as PlaylistMeta; + persistedRow = pipedIdentity as Playlist; + await service.repairPortal(pipedIdentity); + clearCachedToken.mockClear(); + + const shiftedIdentity = { + ...MISCLASSIFIED, + stalkerSerialNumber: 'a', + stalkerDeviceId1: 'b', + } as PlaylistMeta; + + // A DIFFERENT identity must invalidate, not inherit. + expect(service.applyOverride(shiftedIdentity)).toBe( + shiftedIdentity + ); + expect(clearCachedToken).toHaveBeenCalledWith('portal-1'); + }); + + it('reinstalls the remembered repair when a restored configuration fails again', async () => { + // Repair A, edit to B (drops the active override), restore A: + // A's next failure must reinstall the remembered outcome + // WITHOUT a second discovery — not stay broken until restart. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + await service.repairPortal(MISCLASSIFIED); + expect(discover).toHaveBeenCalledTimes(1); + + const editedIdentity = { + ...MISCLASSIFIED, + macAddress: '00:1A:79:00:44:44', + } as PlaylistMeta; + // The edit drops the active override… + expect(service.applyOverride(editedIdentity)).toBe(editedIdentity); + expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED); + + // …and the restored configuration reinstalls it on failure. + refreshActiveWatchdogPlaylist.mockClear(); + const restored = await service.repairPortal(MISCLASSIFIED); + expect(discover).toHaveBeenCalledTimes(1); + expect(restored).toMatchObject({ isFullStalkerPortal: true }); + expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({ + isFullStalkerPortal: true, + }); + // The reinstall re-syncs the watchdog exactly like a fresh + // repair — the intermediate edit may have stopped the keepalive. + expect(refreshActiveWatchdogPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ isFullStalkerPortal: true }) + ); + }); + + it('does not resurrect a remembered override while the row holds another config', async () => { + // Repair A→B, then the user edits the row to an unrelated C. A + // stale A request failing afterwards must NOT reinstall B (it + // would retry against B and repoint the watchdog away from C). + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://b.example/server/load.php', + isFullStalkerPortal: true, + }); + await service.repairPortal(MISCLASSIFIED); + + const otherConfig = { + ...MISCLASSIFIED, + portalUrl: 'http://c.example/portal.php', + } as PlaylistMeta; + // The edit drops the active override… + expect(service.applyOverride(otherConfig)).toBe(otherConfig); + persistedRow = otherConfig as Playlist; + refreshActiveWatchdogPlaylist.mockClear(); + + // …and a stale A request does not bring it back. + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED); + expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); + }); + + it('drops the override and the cached token when only the identity was edited', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + await service.repairPortal(MISCLASSIFIED); + expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({ + isFullStalkerPortal: true, + }); + clearCachedToken.mockClear(); + + // Same URL and mode, different MAC: the repair token belongs to + // the previous identity and must be retired with the override. + const editedIdentity = { + ...MISCLASSIFIED, + macAddress: '00:1A:79:00:88:88', + } as PlaylistMeta; + + expect(service.applyOverride(editedIdentity)).toBe(editedIdentity); + expect(clearCachedToken).toHaveBeenCalledWith('portal-1'); + // The latch is re-armed for the edited identity. + discover.mockClear(); + discover.mockResolvedValue({ status: 'unreachable' }); + persistedRow = editedIdentity as Playlist; + await service.repairPortal(editedIdentity); + expect(discover).toHaveBeenCalledTimes(1); + }); + + it('re-probes a DISCARDED configuration once the row is restored to it', async () => { + // A's probe was discarded because the row moved to B mid-probe; + // after the user restores the row to A, A's next failure must + // probe again instead of staying dead for the session. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + persistedRow = { + ...MISCLASSIFIED, + portalUrl: 'http://edited.example/portal.php', + } as Playlist; + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(discover).toHaveBeenCalledTimes(1); + + // Row restored to A → the discarded marker yields to a new probe. + persistedRow = MISCLASSIFIED as Playlist; + const repaired = await service.repairPortal(MISCLASSIFIED); + expect(discover).toHaveBeenCalledTimes(2); + expect(repaired).toMatchObject({ isFullStalkerPortal: true }); + }); + + it('re-arms the EDITED configuration after a mid-probe edit discarded a repair', async () => { + const edited = { + ...MISCLASSIFIED, + portalUrl: 'http://edited.example/portal.php', + } as PlaylistMeta; + + // Probe of the OLD config lands after the user edit → discarded. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + persistedRow = edited as Playlist; + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(discover).toHaveBeenCalledTimes(1); + + // A stale snapshot of the already-probed config must NOT loop + // the probe… + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(discover).toHaveBeenCalledTimes(1); + + // …but the EDITED configuration failing later must be allowed + // to repair without an application restart. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://edited.example/server/load.php', + isFullStalkerPortal: true, + }); + const repaired = await service.repairPortal(edited); + expect(discover).toHaveBeenCalledTimes(2); + expect(repaired?.portalUrl).toBe( + 'http://edited.example/server/load.php' + ); + }); + + it('discards an in-flight repair when the playlist was deleted during the probe', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + persistedRow = undefined; + + expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); + expect(writtenRow).toBeNull(); + }); + + it('keeps the session-only override when persisting fails', async () => { + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: MISCLASSIFIED.portalUrl, + isFullStalkerPortal: true, + }); + // The transform verified the row, but the WRITE failed. + persistError = new Error('db locked'); + + const repaired = await service.repairPortal(MISCLASSIFIED); + + expect(repaired?.isFullStalkerPortal).toBe(true); + expect(service.applyOverride(MISCLASSIFIED).isFullStalkerPortal).toBe( + true + ); + }); + + it('clears a stale cached token when a portal turns out token-free', async () => { + const wronglyFull = { + ...MISCLASSIFIED, + portalUrl: 'http://panel.example/server/load.php', + isFullStalkerPortal: true, + } as PlaylistMeta; + persistedRow = wronglyFull as Playlist; + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://panel.example/portal.php', + isFullStalkerPortal: false, + }); + + const repaired = await service.repairPortal(wronglyFull); + + expect(repaired?.isFullStalkerPortal).toBe(false); + expect(clearCachedToken).toHaveBeenCalledWith('portal-1'); + }); + }); +}); 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 new file mode 100644 index 000000000..f72bff60c --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -0,0 +1,490 @@ +import { Injectable, Injector, inject } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { PlaylistsService } from '@iptvnator/services'; +import { + isFullStalkerPortalPlaylist, + type PlaylistMeta, +} from '@iptvnator/shared/interfaces'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service'; +import { + getStalkerRequestErrorStatus, + isStalkerAuthFailureMessage, + isStalkerAuthFailureResponse, +} from './stalker-portal-discovery.utils'; +import { + getStalkerPortalIdentityFromPlaylist, + stalkerIdentityFingerprint, +} from './stalker-identity.utils'; +import { StalkerSessionService } from './stalker-session.service'; +import { + type StalkerPortalRepairApi, + toStalkerSessionPlaylist, +} from './stores/utils/stalker-request.utils'; + +/** + * What a probe of one source configuration concluded this session: + * an override to (re)install, 'no-change' (probed; the stored configuration + * is what probing proves, or nothing answered), or 'discarded' (the row + * moved on mid-probe, so the outcome never applied to any persisted state). + */ +type StalkerProbeRecord = StalkerPortalModeOverride | 'no-change' | 'discarded'; + +interface StalkerPortalModeOverride { + /** The failing configuration this repair replaced. */ + sourcePortalUrl?: string; + sourceIsFullStalkerPortal: boolean; + /** MAC + Stalker identity the repair probe authenticated as. */ + identityFingerprint: string; + /** The proven-working configuration. */ + portalUrl: string; + isFullStalkerPortal: boolean; +} + + + +/** + * Lazy repair for playlists whose persisted portal endpoint or mode is + * wrong. The flag used to be a URL-shape guess frozen at import, so a + * canonical `…/server/load.php` portal could sit misclassified as + * token-free forever — every request answered `Authorization failed.` and + * the only "fix" was deleting the playlist (losing favorites, recents and + * positions). + * + * Deliberately NOT an eager one-shot migration: a large share of users are + * on reseller `portal.php` panels that work without any auth, and nothing + * short of probing can distinguish those from misclassified canonical + * portals. Instead, repair is evidence-driven and conservative: + * + * - it runs only after a request ACTUALLY failed with a repair trigger + * (the middleware's plain-text auth bodies, or HTTP 404 — a portal that + * works is never probed, let alone rewritten); + * - it probes at most once per SOURCE CONFIGURATION (endpoint, mode, MAC, + * identity) per playlist per session — an edited configuration may probe + * when it fails, an already-probed one stays latched; + * - it persists only a configuration that discovery PROVED to answer, and + * only when that configuration differs from the failing one. + * + * A successful repair also installs an in-session override so already-held + * stale playlist objects (store state, route snapshots) start using the + * corrected endpoint immediately — the persisted row makes it permanent. + */ +@Injectable({ providedIn: 'root' }) +export class StalkerPortalRepairService implements StalkerPortalRepairApi { + private readonly discovery = inject(StalkerPortalDiscoveryService); + // Resolved lazily: PlaylistsService pulls the whole persistence stack + // (IndexedDB, snackbar, translations) and is only needed at the moment a + // repair actually persists — never on the hot request path. + private readonly injector = inject(Injector); + private readonly stalkerSession = inject(StalkerSessionService); + private readonly logger = createLogger('StalkerPortalRepair'); + + private readonly overrides = new Map(); + /** + * The OUTCOME of every probe this session, per playlist, keyed by the + * source-configuration fingerprint: the override the probe produced, or + * null when it changed nothing. Keeping full history (not just the last + * entry) stops alternating edits (A→B→A) from re-running discovery via + * stale snapshots — and keeping the OUTCOME (not just an attempted + * flag) lets a configuration the user restores reinstall its remembered + * repair instead of staying broken until restart. A configuration never + * probed — including one whose mid-probe edit discarded a repair — + * probes when IT fails. + */ + private readonly probeHistory = new Map< + string, + Map + >(); + private readonly pendingRepairs = new Map< + string, + Promise + >(); + + constructor() { + // The watchdog resolves its playlist from the persisted row; while a + // repair's persistence is pending (or failed) that row still carries + // the broken configuration, so pings must see the override too. The + // typeof guard keeps isolated TestBeds with partial session mocks + // working. + if ( + typeof this.stalkerSession.registerWatchdogPlaylistDecorator === + 'function' + ) { + this.stalkerSession.registerWatchdogPlaylistDecorator((playlist) => + this.applyOverride(playlist) + ); + } + } + + /** + * Returns the playlist with a completed repair applied, or the playlist + * unchanged (same reference) when there is nothing to apply. + * + * The override is tied to the SOURCE configuration it repaired: it only + * rewrites objects still carrying that failing configuration (stale + * store snapshots). A playlist carrying anything else means the user + * edited the portal metadata through the playlist dialog — the override + * and the once-per-session probe latch are dropped so the edited + * configuration is used verbatim and may repair again if IT fails. + */ + applyOverride(playlist: T): T { + const override = this.overrides.get(playlist._id); + if (!override) { + return playlist; + } + + if ( + stalkerIdentityFingerprint(playlist) !== override.identityFingerprint + ) { + // The MAC or Stalker identity was edited after the repair. The + // override AND the token the repair authenticated for the + // PREVIOUS identity must go — otherwise requests and watchdog + // pings would pair the edited identity with a foreign session. + this.dropOverride(playlist._id); + return playlist; + } + + if ( + playlist.portalUrl === override.portalUrl && + playlist.isFullStalkerPortal === override.isFullStalkerPortal + ) { + // Already carrying the repaired values (e.g. a freshly read row). + return playlist; + } + + if ( + playlist.portalUrl === override.sourcePortalUrl && + isFullStalkerPortalPlaylist(playlist) === + override.sourceIsFullStalkerPortal + ) { + return { + ...playlist, + portalUrl: override.portalUrl, + isFullStalkerPortal: override.isFullStalkerPortal, + }; + } + + // The portal URL or mode was edited to something else entirely — + // same story: the edited configuration is used verbatim and the + // repair session state is retired. + this.dropOverride(playlist._id); + return playlist; + } + + /** + * Retires the session artifacts a repair installed for a playlist: the + * ACTIVE override and the cached token (authenticated for the pre-edit + * identity/endpoint). The probe history deliberately survives: it both + * blocks re-probing of already-attempted configurations (A→B→A cannot + * loop discovery through stale snapshots) and lets a restored + * configuration reinstall its remembered repair. + */ + private dropOverride(playlistId: string): void { + this.overrides.delete(playlistId); + this.stalkerSession.clearCachedToken(playlistId); + } + + /** + * Whether a failure justifies probing at all. Only the failure shapes a + * wrong endpoint/mode actually produces qualify: the middleware's + * plain-text/JSON auth failures (misclassified canonical portal + * answering a token-less request), HTTP 404 (persisted endpoint does + * not exist), HTTP 401/403 (endpoint behind an HTTP auth gate), and + * the session service's terminal auth/handshake errors. Timeouts and + * other network failures never trigger a probe — + * a portal that is temporarily down must not be reclassified. + */ + shouldAttemptRepair(playlist: PlaylistMeta, failure: unknown): boolean { + if (!playlist._id || !playlist.portalUrl || !playlist.macAddress) { + return false; + } + + if (isStalkerAuthFailureResponse(failure)) { + return true; + } + + const status = getStalkerRequestErrorStatus(failure); + if (status === 404 || status === 401 || status === 403) { + // 404: the persisted endpoint does not exist on this server. + // 401/403: a token-free-classified playlist hit an HTTP auth + // gate — discovery classifies these endpoints as auth-required, + // so the repair must be allowed to reach it. + return true; + } + + if ( + failure !== null && + typeof failure === 'object' && + 'message' in failure + ) { + const message = String( + (failure as { message?: unknown }).message ?? '' + ); + // The SAME failure set the session service uses for error + // messages — authentication wraps structured denials as + // `Error('Profile error: Access denied.')` or + // `Error('Profile error: Invalid token')`, and a narrower + // pattern here would let those bypass the repair entirely. + return ( + isStalkerAuthFailureMessage(message) || + /handshake failed/i.test(message) + ); + } + + return false; + } + + /** + * Probes the stored portal and, when discovery proves a DIFFERENT + * working configuration, persists it and returns the patched playlist + * for a one-shot retry. Returns null when nothing may change: probe + * found nothing, probe confirmed the stored configuration (the failure + * has another cause, e.g. an expired subscription), or a repair for + * this playlist already ran this session. + */ + async repairPortal(playlist: PlaylistMeta): Promise { + const playlistId = playlist._id; + + const pending = this.pendingRepairs.get(playlistId); + if (pending) { + // Wait the in-flight probe out, then RE-ENTER: the caller may + // carry a different (edited) configuration whose fingerprint + // was never attempted — it must get its own probe instead of + // inheriting whatever the old repair concluded. + await pending; + return this.repairPortal(playlist); + } + + const fingerprint = this.repairSourceFingerprint(playlist); + const history = this.probeHistory.get(playlistId) ?? new Map(); + const record = history.get(fingerprint) as + | StalkerProbeRecord + | undefined; + if (record === 'discarded') { + // The probe for this configuration was discarded because the + // row had moved on mid-probe. If the row has since been + // RESTORED to it, the outcome was never recorded — probe again. + // A stale snapshot (row still elsewhere) stays declined, gated + // by one cheap row read instead of a discovery run. + if (!(await this.rowCurrentlyMatches(playlist))) { + return this.reapplyIfChanged(playlist); + } + history.delete(fingerprint); + } else if (record !== undefined) { + if ( + record !== 'no-change' && + !this.overrides.has(playlistId) && + // Reinstall ONLY when the persisted row actually carries + // this configuration again. A stale request for A while the + // row now holds an unrelated C must not resurrect A's + // override — that would retry against B and repoint the + // watchdog away from C. + (await this.rowCurrentlyMatches(playlist)) + ) { + // The user restored a configuration whose override was + // dropped by an intermediate edit: reinstall the remembered + // outcome — probing again is unnecessary, and doing nothing + // would leave the restored configuration broken until + // restart. + this.overrides.set(playlistId, record); + // Same synchronization as a fresh repair: if the + // intermediate configuration stopped the active watchdog, + // the restored full-portal session needs its keepalive back. + this.stalkerSession.refreshActiveWatchdogPlaylist( + toStalkerSessionPlaylist(this.applyOverride(playlist)) + ); + } + return this.reapplyIfChanged(playlist); + } + + // Reserved BEFORE the probe; the run overwrites it with the + // produced override or the 'discarded' marker. + history.set(fingerprint, 'no-change'); + this.probeHistory.set(playlistId, history); + const run = this.runRepair(playlist); + this.pendingRepairs.set(playlistId, run); + try { + return await run; + } finally { + this.pendingRepairs.delete(playlistId); + } + } + + /** + * Everything a probe's outcome depends on: endpoint, mode, MAC and the + * full Stalker identity — the same field set `rowStillMatchesSource` + * verifies before committing. + */ + private repairSourceFingerprint(playlist: PlaylistMeta): string { + // JSON-encoded for the same reason as the identity fingerprint: + // unrestricted values must not alias across field boundaries. + return JSON.stringify([ + playlist.portalUrl ?? '', + isFullStalkerPortalPlaylist(playlist), + stalkerIdentityFingerprint(playlist), + ]); + } + + private reapplyIfChanged(playlist: PlaylistMeta): PlaylistMeta | null { + const applied = this.applyOverride(playlist); + return applied === playlist ? null : applied; + } + + private async runRepair( + playlist: PlaylistMeta + ): Promise { + const outcome = await this.discovery.discover( + playlist.portalUrl ?? '', + playlist.macAddress ?? '', + getStalkerPortalIdentityFromPlaylist(playlist) + ); + + if (outcome.status !== 'resolved') { + this.logger.info( + `Portal probe found no working configuration (${outcome.status}); leaving playlist untouched` + ); + return null; + } + + const storedMode = isFullStalkerPortalPlaylist(playlist); + if ( + outcome.portalUrl === playlist.portalUrl && + outcome.isFullStalkerPortal === storedMode + ) { + // The stored configuration is exactly what probing proves — the + // failure has a different cause and rewriting would fix nothing. + return null; + } + + // TOCTOU guard: the probe can run for tens of seconds, and the user + // may have edited the portal metadata (or deleted the playlist) + // meanwhile. The verification and the patch run ATOMICALLY inside + // the per-playlist write queue — a plain read-check-then-update + // pair would still race an edit that is queued but not committed. + // The transform patches the FRESH row, so nothing stale can clobber + // user state; returning null aborts without writing. + let verifiedAgainstRow = false; + try { + await firstValueFrom( + this.injector + .get(PlaylistsService) + .transformPlaylistMeta(playlist._id, (row) => { + if (!this.rowMatchesSource(row, playlist, storedMode)) { + return null; + } + verifiedAgainstRow = true; + return { + ...row, + portalUrl: outcome.portalUrl, + isFullStalkerPortal: outcome.isFullStalkerPortal, + }; + }) + ); + } catch (error) { + // A failed WRITE after successful verification keeps the + // session-only override below; a failed READ means the premise + // could not be verified and the repair is discarded. + this.logger.warn( + 'Persisting repaired portal mode failed', + error + ); + } + + if (!verifiedAgainstRow) { + this.logger.info( + 'Portal configuration changed while probing; discarding repair' + ); + // Marked explicitly: a later failure of this configuration may + // probe again once the row is RESTORED to it — unlike a probe + // whose outcome genuinely applied ('no-change'/override). + this.probeHistory + .get(playlist._id) + ?.set(this.repairSourceFingerprint(playlist), 'discarded'); + return null; + } + + const override: StalkerPortalModeOverride = { + sourcePortalUrl: playlist.portalUrl, + sourceIsFullStalkerPortal: storedMode, + identityFingerprint: stalkerIdentityFingerprint(playlist), + portalUrl: outcome.portalUrl, + isFullStalkerPortal: outcome.isFullStalkerPortal, + }; + this.overrides.set(playlist._id, override); + this.probeHistory + .get(playlist._id) + ?.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( + playlist._id, + outcome.token, + playlist + ); + } else if (!outcome.isFullStalkerPortal) { + this.stalkerSession.clearCachedToken(playlist._id); + } + + // If this playlist currently owns the watchdog, re-sync it with the + // repaired configuration: a simple→full repair must START the + // keepalive and an endpoint change must repoint it — the session + // service otherwise keeps the activation-time snapshot forever. + this.stalkerSession.refreshActiveWatchdogPlaylist( + toStalkerSessionPlaylist(this.applyOverride(playlist)) + ); + + this.logger.info( + `Repaired portal mode: isFullStalkerPortal=${outcome.isFullStalkerPortal}` + ); + + return this.applyOverride(playlist); + } + + /** + * Cheap gate for retrying a DISCARDED configuration: reads the current + * row and reports whether it now carries the caller's configuration. + * Only avoids pointless discovery runs — the authoritative check stays + * the atomic transform. + */ + private async rowCurrentlyMatches( + playlist: PlaylistMeta + ): Promise { + try { + const row = await firstValueFrom( + this.injector + .get(PlaylistsService) + .getPlaylistById(playlist._id) + ); + return ( + !!row && + this.repairSourceFingerprint(row) === + this.repairSourceFingerprint(playlist) + ); + } catch { + return false; + } + } + + /** + * Whether the persisted row still carries the configuration the repair + * was computed for — endpoint, mode, and the identity the probe + * authenticated as. Runs synchronously INSIDE the atomic transform. + */ + private rowMatchesSource( + row: PlaylistMeta, + playlist: PlaylistMeta, + sourceMode: boolean + ): boolean { + return ( + row.portalUrl === playlist.portalUrl && + isFullStalkerPortalPlaylist(row) === sourceMode && + stalkerIdentityFingerprint(row) === + stalkerIdentityFingerprint(playlist) + ); + } + +} 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 9da8369c3..ee2e776b7 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 @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { DataService } from '@iptvnator/services'; +import { of } from 'rxjs'; +import { DataService, PlaylistsService } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; import { STALKER_SERIAL_NUMBER, @@ -23,6 +24,327 @@ type GetProfileWithIdentity = ( handshakeRandom: string ) => Promise; +describe('StalkerSessionService watchdog row resolution', () => { + const activationSnapshot = { + _id: 'portal-1', + title: 'Portal', + portalUrl: 'https://portal.example.com/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: true, + lastUsage: '', + } as unknown as Playlist; + + let sendIpcEvent: jest.Mock; + let getPlaylistById: jest.Mock; + let service: StalkerSessionService; + + beforeEach(() => { + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + digest: jest.fn( + async () => new Uint8Array(20).fill(1).buffer + ), + }, + }, + }); + sendIpcEvent = jest + .fn() + .mockResolvedValue({ js: { token: 'TOK', random: 'r' } }); + getPlaylistById = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + StalkerSessionService, + { provide: DataService, useValue: { sendIpcEvent } }, + { + provide: PlaylistsService, + useValue: { getPlaylistById }, + }, + ], + }); + service = TestBed.inject(StalkerSessionService); + }); + + it('authenticates watchdog pings as the freshly persisted row, not the activation snapshot', async () => { + // The user edited the MAC after the watchdog started: the very next + // ping must use the stored row — pairing the old identity would + // keep an old session alive and repopulate the token cache with it. + const editedRow = { + ...activationSnapshot, + macAddress: '00:1A:79:00:77:77', + }; + getPlaylistById.mockReturnValue(of(editedRow)); + + service.setActiveWatchdogPlaylist(activationSnapshot); + // The init ping runs on a floating promise chain. + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } + + expect(getPlaylistById).toHaveBeenCalledWith('portal-1'); + expect(sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + macAddress: '00:1A:79:00:77:77', + }) + ); + service.setActiveWatchdogPlaylist(null); + }); + + it('overlays the registered repair decorator on the resolved row', async () => { + // Simple→full repair whose persistence has not landed yet: the row + // still says simple, and without the overlay the ping would stop + // the freshly started keepalive. + const simpleRow = { + ...activationSnapshot, + isFullStalkerPortal: false, + }; + getPlaylistById.mockReturnValue(of(simpleRow)); + service.registerWatchdogPlaylistDecorator((playlist) => ({ + ...playlist, + isFullStalkerPortal: true, + })); + + service.setActiveWatchdogPlaylist(activationSnapshot); + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } + + // The keepalive survived (no stopWatchdog) and authenticated. + expect(sendIpcEvent).toHaveBeenCalled(); + service.setActiveWatchdogPlaylist(null); + }); +}); + +describe('StalkerSessionService identity-tagged token cache', () => { + const playlistA = { + _id: 'portal-1', + title: 'Portal', + portalUrl: 'https://portal.example.com/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: true, + lastUsage: '', + } as unknown as Playlist; + + let sendIpcEvent: jest.Mock; + let service: StalkerSessionService; + + beforeEach(() => { + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + digest: jest.fn( + async () => new Uint8Array(20).fill(1).buffer + ), + }, + }, + }); + sendIpcEvent = jest + .fn() + .mockResolvedValue({ js: { token: 'FRESH', random: 'r' } }); + + TestBed.configureTestingModule({ + providers: [ + StalkerSessionService, + { provide: DataService, useValue: { sendIpcEvent } }, + ], + }); + service = TestBed.inject(StalkerSessionService); + }); + + it('reuses a cached token only for the identity it was negotiated for', async () => { + service.setCachedToken('portal-1', 'OLD-IDENTITY-TOKEN', playlistA); + + const sameIdentity = await service.ensureToken(playlistA); + expect(sameIdentity.token).toBe('OLD-IDENTITY-TOKEN'); + expect(sendIpcEvent).not.toHaveBeenCalled(); + + // The user edited the MAC: the cached session belongs to the old + // identity and must be replaced by a fresh authentication. + const editedIdentity = { + ...playlistA, + macAddress: '00:1A:79:00:66:66', + } as Playlist; + const reAuthenticated = await service.ensureToken(editedIdentity); + + expect(reAuthenticated.token).toBe('FRESH'); + expect(sendIpcEvent).toHaveBeenCalled(); + }); + + it('does not hand an in-flight authentication result to an edited identity', async () => { + // Deferred transport: the first auth (old identity) is still in + // flight when the edited identity asks for a token. + const pendingResolvers: Array<(value: unknown) => void> = []; + sendIpcEvent.mockImplementation( + () => + new Promise((resolve) => { + pendingResolvers.push(resolve); + }) + ); + + const oldAuth = service.ensureToken(playlistA); + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + + const editedIdentity = { + ...playlistA, + macAddress: '00:1A:79:00:55:55', + } as Playlist; + const editedAuth = service.ensureToken(editedIdentity); + + // 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(); + } + pendingResolvers[1]?.({ js: {} }); + await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' }); + + // The edited identity re-enters and negotiates its OWN session. + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + pendingResolvers[2]?.({ js: { token: 'TOKEN-NEW', random: 'r' } }); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + pendingResolvers[3]?.({ js: {} }); + + await expect(editedAuth).resolves.toMatchObject({ + token: 'TOKEN-NEW', + }); + }); + + it('treats IPC-wrapped HTTP 401/403 as an authorization failure', async () => { + // The custom `status` property does not survive ipcRenderer, so an + // expired-token 403 arrives as message text only. + service.setCachedToken('portal-1', 'EXPIRED', playlistA); + sendIpcEvent.mockRejectedValueOnce( + new Error( + "Error invoking remote method 'STALKER_REQUEST': HTTP Error 403: Forbidden" + ) + ); + sendIpcEvent.mockResolvedValue({ js: { token: 'FRESH', random: 'r' } }); + + await service + .makeAuthenticatedRequest(playlistA, { action: 'get_genres' }) + .catch(() => undefined); + + // The dead token was retired rather than kept for the next caller. + expect(service.getCachedToken('portal-1')).not.toBe('EXPIRED'); + }); + + it.each(['Access denied.', 'Unauthorized request.'])( + 'retires the token for the %j plain-text failure too', + async (body) => { + // The session predicate shares the discovery/repair failure + // set: a phrase one layer treats as an auth failure must not be + // ignored here, or the expired token survives the session. + service.setCachedToken('portal-1', 'EXPIRED', playlistA); + sendIpcEvent.mockResolvedValue(body); + + await service + .makeAuthenticatedRequest( + playlistA, + { action: 'get_genres' }, + false + ) + .catch(() => undefined); + + expect(service.getCachedToken('portal-1')).toBeNull(); + } + ); + + it('retires a failed token even on the no-retry path (watchdog pings)', async () => { + service.setCachedToken('portal-1', 'DEAD', playlistA); + sendIpcEvent.mockResolvedValue('Authorization failed.'); + + await expect( + service.makeAuthenticatedRequest( + playlistA, + { action: 'get_events' }, + false + ) + ).rejects.toThrow('Authorization failed after retry'); + + // Leaving the dead token cached would hand it to the next caller. + expect(service.getCachedToken('portal-1')).toBeNull(); + }); +}); + +describe('StalkerSessionService.refreshActiveWatchdogPlaylist', () => { + const basePlaylist = { + _id: 'portal-1', + title: 'Portal', + portalUrl: 'https://portal.example.com/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: false, + lastUsage: '', + } as unknown as Playlist; + + let service: StalkerSessionService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + StalkerSessionService, + { + provide: DataService, + useValue: { + sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }), + }, + }, + ], + }); + service = TestBed.inject(StalkerSessionService); + }); + + it('re-applies the repaired configuration to the ACTIVE watchdog playlist', () => { + service.setActiveWatchdogPlaylist(basePlaylist); + const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist'); + + const repaired = { + ...basePlaylist, + isFullStalkerPortal: true, + } as Playlist; + service.refreshActiveWatchdogPlaylist(repaired); + + // Delegation is the contract: setActiveWatchdogPlaylist owns the + // start/stop/repoint logic, refresh only feeds it the fresh row. + expect(apply).toHaveBeenCalledWith(repaired); + service.setActiveWatchdogPlaylist(null); + }); + + it('ignores playlists that do not own the watchdog', () => { + service.setActiveWatchdogPlaylist(basePlaylist); + const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist'); + + service.refreshActiveWatchdogPlaylist({ + ...basePlaylist, + _id: 'other-portal', + isFullStalkerPortal: true, + } as Playlist); + + expect(apply).not.toHaveBeenCalled(); + service.setActiveWatchdogPlaylist(null); + }); + + it('is a no-op when no watchdog playlist is active at all', () => { + const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist'); + + service.refreshActiveWatchdogPlaylist({ + ...basePlaylist, + isFullStalkerPortal: true, + } as Playlist); + + expect(apply).not.toHaveBeenCalled(); + }); +}); + describe('StalkerSessionService identity payloads', () => { const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php'; @@ -261,7 +583,7 @@ describe('StalkerSessionService identity payloads', () => { isFullStalkerPortal: true, } as Playlist; - service.setCachedToken(playlist._id, 'stale-token'); + service.setCachedToken(playlist._id, 'stale-token', playlist); let release: (value: { token: string }) => void = () => undefined; jest.spyOn(service, 'authenticate').mockImplementation( @@ -296,7 +618,7 @@ describe('StalkerSessionService identity payloads', () => { jest.spyOn(service, 'ensureToken') .mockResolvedValueOnce({ token: 'stale-token' }) .mockResolvedValueOnce({ token: 'fresh-token' }); - service.setCachedToken(playlist._id, 'fresh-token'); + service.setCachedToken(playlist._id, 'fresh-token', playlist); dataService.sendIpcEvent .mockResolvedValueOnce({ js: 'Authorization failed. 75' }) @@ -327,7 +649,7 @@ describe('StalkerSessionService identity payloads', () => { jest.spyOn(service, 'ensureToken') .mockResolvedValueOnce({ token: 'dead-token' }) .mockResolvedValueOnce({ token: 'new-token' }); - service.setCachedToken(playlist._id, 'dead-token'); + service.setCachedToken(playlist._id, 'dead-token', playlist); dataService.sendIpcEvent .mockResolvedValueOnce({ js: 'Authorization failed. 75' }) 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 bca8ef00b..910036dec 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,11 +1,23 @@ -import { Injectable, inject } from '@angular/core'; -import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; -import { DataService } from '@iptvnator/services'; +import { Injectable, Injector, inject } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + isFullStalkerPortalPlaylist, + isFullStalkerPortalUrl, + Playlist, + PlaylistMeta, + STALKER_REQUEST, +} 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, normalizeStalkerPortalIdentity, + stalkerIdentityFingerprint, type StalkerPortalIdentity, } from './stalker-identity.utils'; @@ -88,16 +100,33 @@ 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. + private readonly injector = inject(Injector); private readonly logger = createLogger('StalkerSession'); - // In-memory token cache for current session (keyed by playlist ID) - private tokenCache = new Map(); + // 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 + // 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<{ token: string; serialNumber?: string }> + { + promise: Promise<{ token: string; serialNumber?: string }>; + identityFingerprint: string; + } >(); private watchdogIntervals = new Map< string, @@ -108,27 +137,49 @@ export class StalkerSessionService { private activeWatchdogPlaylistId: string | null = null; /** - * Checks if a URL is a full stalker portal URL (requires handshake) - * Full stalker portal URLs contain /stalker_portal/ in the path + * Checks if a URL looks like a full stalker portal URL (requires + * handshake). Delegates to the shared predicate in + * `@iptvnator/shared/interfaces` — the flag persisted by endpoint + * discovery is authoritative; this URL rule is only the legacy fallback. */ isFullStalkerPortal(url: string): boolean { - return ( - url.includes('/stalker_portal/') || url.includes('/server/load.php') - ); + return isFullStalkerPortalUrl(url); } /** - * Gets the cached token for a playlist, or null if not cached + * 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). */ getCachedToken(playlistId: string): string | null { - return this.tokenCache.get(playlistId) || null; + return this.tokenCache.get(playlistId)?.token || null; } /** - * Sets a token in the cache + * Caches a token together with the identity fingerprint of the playlist + * the session was negotiated for. */ - setCachedToken(playlistId: string, token: string): void { - this.tokenCache.set(playlistId, token); + setCachedToken( + playlistId: string, + token: string, + identitySource: PlaylistMeta + ): void { + this.tokenCache.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; } /** @@ -156,7 +207,7 @@ export class StalkerSessionService { if ( !playlist || - !playlist.isFullStalkerPortal || + !isFullStalkerPortalPlaylist(playlist) || !playlist.portalUrl || !playlist.macAddress ) { @@ -169,6 +220,22 @@ export class StalkerSessionService { this.startWatchdog(playlist); } + /** + * Re-evaluates the watchdog for a playlist whose portal configuration + * 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). + */ + refreshActiveWatchdogPlaylist(playlist: Playlist): void { + if (this.activeWatchdogPlaylistId !== playlist._id) { + return; + } + + this.setActiveWatchdogPlaylist(playlist); + } + private startWatchdog(playlist: Playlist): void { const playlistId = playlist._id; this.watchdogPlaylists.set(playlistId, playlist); @@ -196,6 +263,47 @@ export class StalkerSessionService { 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. + */ + 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( playlistId: string, init: '0' | '1' @@ -204,19 +312,21 @@ export class StalkerSessionService { return; } - const playlist = this.watchdogPlaylists.get(playlistId); - if ( - !playlist || - !playlist.portalUrl || - !playlist.macAddress || - !playlist.isFullStalkerPortal - ) { - this.stopWatchdog(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, { @@ -417,6 +527,8 @@ export class StalkerSessionService { ): Promise<{ token: string; accountInfo?: StalkerProfileResponse['js']['account_info']; + /** Raw envelope so callers can apply their own failure checks. */ + profileResponse?: StalkerProfileResponse; }> { const normalizedIdentity = normalizeStalkerPortalIdentity(identity); @@ -451,6 +563,7 @@ export class StalkerSessionService { return { token, accountInfo: profileResponse?.js?.account_info, + profileResponse, }; } catch (error) { // Profile fetch failed - this is a real error, propagate it @@ -468,24 +581,52 @@ export class StalkerSessionService { playlist: Playlist ): Promise<{ token: string | null; serialNumber?: string }> { // If not a full stalker portal, no token needed - if (!playlist.isFullStalkerPortal) { + if (!isFullStalkerPortalPlaylist(playlist)) { return { token: null }; } const identity = getStalkerPortalIdentityFromPlaylist(playlist); - // Check in-memory cache first (valid for current session only) - const cachedToken = this.getCachedToken(playlist._id); - if (cachedToken) { - return { token: cachedToken, serialNumber: identity.serialNumber }; + // 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); } // Check if there's already a pending authentication for this playlist // This prevents race conditions when multiple resources request a token simultaneously - const pendingPromise = this.pendingAuth.get(playlist._id); - if (pendingPromise) { - this.logger.debug('Waiting for pending authentication...'); - return pendingPromise; + const pendingEntry = this.pendingAuth.get(playlist._id); + if (pendingEntry) { + if ( + pendingEntry.identityFingerprint === + stalkerIdentityFingerprint(playlist) + ) { + this.logger.debug('Waiting for pending authentication...'); + return pendingEntry.promise; + } + + // An authentication for a DIFFERENT (pre-edit) identity is in + // flight. Its result must not be adopted, but starting a + // competing handshake would strand it with a dead token on + // strict portals — wait for it to settle, then re-enter and + // authenticate as the current identity. + this.logger.debug( + 'Waiting out an authentication for a different identity...' + ); + await pendingEntry.promise.catch(() => undefined); + return this.ensureToken(playlist); } // No cached token - need to do full authentication (handshake + get_profile) @@ -506,7 +647,7 @@ export class StalkerSessionService { macAddress, identity ); - this.setCachedToken(playlist._id, token); + this.setCachedToken(playlist._id, token, playlist); return { token, serialNumber: identity.serialNumber }; } finally { // Clean up pending promise regardless of success/failure @@ -515,7 +656,10 @@ export class StalkerSessionService { })(); // Store the pending promise so other concurrent requests can wait on it - this.pendingAuth.set(playlist._id, authPromise); + this.pendingAuth.set(playlist._id, { + promise: authPromise, + identityFingerprint: stalkerIdentityFingerprint(playlist), + }); return authPromise; } @@ -553,7 +697,7 @@ export class StalkerSessionService { this.logger.debug('Waiting for pending authentication...'); // A failed pending auth must not abort the refresh; this call // performs its own handshake either way. - await inFlight.catch(() => undefined); + await inFlight.promise.catch(() => undefined); } // Publish the slot before the first await so no other waiter can @@ -574,7 +718,11 @@ 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); - this.pendingAuth.set(playlist._id, slot); + const slotEntry = { + promise: slot, + identityFingerprint: stalkerIdentityFingerprint(playlist), + }; + this.pendingAuth.set(playlist._id, slotEntry); // ensureToken() reads tokenCache before pendingAuth, so leaving the // old token there would hand a token this handshake is about to @@ -588,7 +736,7 @@ export class StalkerSessionService { macAddress, identity ); - this.setCachedToken(playlist._id, result.token); + this.setCachedToken(playlist._id, result.token, playlist); settleSlot({ token: result.token, serialNumber: identity.serialNumber, @@ -600,7 +748,7 @@ 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) === slot) { + if (this.pendingAuth.get(playlist._id) === slotEntry) { this.pendingAuth.delete(playlist._id); } } @@ -630,6 +778,29 @@ export class StalkerSessionService { 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; + } + // Convert response to string for pattern matching const responseStr = JSON.stringify(responseOrError).toLowerCase(); @@ -682,9 +853,11 @@ export class StalkerSessionService { // Check for authorization failure in response if (this.isAuthorizationError(response)) { - if (retryOnAuthFailure && playlist.isFullStalkerPortal) { - // Retire the failed token so the retry re-authenticates - this.retireFailedToken(playlist._id, token); + // 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)) { // Retry once with fresh authentication return this.makeAuthenticatedRequest( playlist, @@ -699,18 +872,21 @@ export class StalkerSessionService { return response; } catch (error) { // Check if error indicates auth failure - if ( - this.isAuthorizationError(error) && - retryOnAuthFailure && - playlist.isFullStalkerPortal - ) { - // Retire the failed token and retry with new handshake + if (this.isAuthorizationError(error)) { + // Same rule as above: a failed session is retired even on + // the no-retry path. this.retireFailedToken(playlist._id, token); - return this.makeAuthenticatedRequest( - playlist, - params, - false - ); + if ( + retryOnAuthFailure && + isFullStalkerPortalPlaylist(playlist) + ) { + // Retry with a fresh handshake + return this.makeAuthenticatedRequest( + playlist, + params, + false + ); + } } throw error; } diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts index c527fd0b2..c92727cb8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts @@ -148,7 +148,6 @@ describe('StalkerStore API compatibility smoke', () => { 'addToRecentlyViewed', 'removeFromRecentlyViewed', 'fetchChannelEpg', - 'makeStalkerRequest', ]; for (const methodName of expectedMethods) { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 567152ec3..6591af5fb 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -18,6 +18,7 @@ import { } from '../../models'; import { StalkerContentTypes } from '../../stalker-content-types'; import { StalkerItvCacheService } from '../../stalker-itv-cache.service'; +import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { ResourceState, @@ -194,6 +195,7 @@ export function withStalkerContent() { store, dataService = inject(DataService), stalkerSession = inject(StalkerSessionService), + portalRepair = inject(StalkerPortalRepairService), translateService = inject(TranslateService), itvCache = inject(StalkerItvCacheService) ) => { @@ -202,6 +204,7 @@ export function withStalkerContent() { const requestDeps = { dataService, stalkerSession, + portalRepair, }; return { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index 02b0110bb..b4975ac0f 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -16,6 +16,7 @@ import { StalkerPortalActions, } from '@iptvnator/shared/interfaces'; import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; +import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts'; import { executeStalkerRequest } from '../utils'; @@ -101,6 +102,7 @@ export function withStalkerEpg() { store, dataService = inject(DataService), stalkerSession = inject(StalkerSessionService), + portalRepair = inject(StalkerPortalRepairService), runtime = inject(RuntimeCapabilitiesService), epgBridge = inject(EpgRuntimeBridgeService) ) => { @@ -109,6 +111,7 @@ export function withStalkerEpg() { const requestDeps = { dataService, stalkerSession, + portalRepair, }; const supportsEpg = (): boolean => runtime.supportsEpg; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 9509aa759..37b0d9e98 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -16,6 +16,7 @@ import { isCrossOriginStalkerStream, STALKER_MAG_USER_AGENT, } from '../../stalker-live-playback.utils'; +import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { normalizeStalkerEntityId, @@ -54,6 +55,7 @@ export function withStalkerPlayer() { playlistService = inject(PlaylistsService), playerService = inject(PORTAL_PLAYER), stalkerSession = inject(StalkerSessionService), + portalRepair = inject(StalkerPortalRepairService), snackBar = inject(MatSnackBar), translate = inject(TranslateService), ngrxStore = inject(Store) @@ -63,6 +65,7 @@ export function withStalkerPlayer() { const requestDeps = { dataService, stalkerSession, + portalRepair, }; const createRequestPlaylist = ( diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts index 30225b006..159c2001e 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts @@ -1,7 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { signalStore } from '@ngrx/signals'; import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; -import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerPortal } from './with-stalker-portal.feature'; @@ -38,6 +39,7 @@ describe('withStalkerPortal', () => { ensureToken: jest.Mock; setActiveWatchdogPlaylist: jest.Mock; }; + let applyOverride: jest.Mock; beforeEach(() => { dbCreatePlaylist = jest.fn().mockResolvedValue(undefined); @@ -57,6 +59,7 @@ describe('withStalkerPortal', () => { ensureToken: jest.fn(), setActiveWatchdogPlaylist: jest.fn(), }; + applyOverride = jest.fn((playlist) => playlist); TestBed.configureTestingModule({ providers: [ @@ -75,6 +78,10 @@ describe('withStalkerPortal', () => { provide: StalkerSessionService, useValue: stalkerSession, }, + { + provide: StalkerPortalRepairService, + useValue: { applyOverride }, + }, ], }); @@ -100,6 +107,29 @@ describe('withStalkerPortal', () => { ); }); + it('hands the repaired configuration to the watchdog and store on activation', async () => { + // Route re-activation passes the stale NgRx meta; the repair + // override must win here, or reopening the portal would stop or + // repoint the repaired keepalive back to the broken configuration. + const repaired = { + ...PLAYLIST, + portalUrl: 'http://demo.example/server/load.php', + isFullStalkerPortal: true, + }; + applyOverride.mockReturnValue(repaired); + + await store.setCurrentPlaylist(PLAYLIST); + + expect(applyOverride).toHaveBeenCalledWith(PLAYLIST); + expect(stalkerSession.setActiveWatchdogPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + portalUrl: 'http://demo.example/server/load.php', + isFullStalkerPortal: true, + }) + ); + expect(store.currentPlaylist()).toEqual(repaired); + }); + it('does not touch SQLite when the Electron bridge is partial', async () => { runtime.supportsStalkerPlaylistSqliteSync = false; @@ -109,21 +139,4 @@ describe('withStalkerPortal', () => { expect(dbCreatePlaylist).not.toHaveBeenCalled(); }); - it('sends Stalker requests through DataService without requiring the SQLite bridge', async () => { - const dataService = TestBed.inject(DataService) as unknown as { - sendIpcEvent: jest.Mock; - }; - dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } }); - - await store.makeStalkerRequest(PLAYLIST, { action: 'get_profile' }); - - expect(dataService.sendIpcEvent).toHaveBeenCalledWith( - STALKER_REQUEST, - expect.objectContaining({ - macAddress: '00:1A:79:00:00:01', - params: { action: 'get_profile' }, - url: 'http://demo.example/stalker_portal/server/load.php', - }) - ); - }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts index 16c8cd06c..81549ea38 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts @@ -3,12 +3,12 @@ import { patchState, signalStoreFeature, withMethods, - withProps, withState, } from '@ngrx/signals'; -import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { toStalkerSessionPlaylist } from '../utils'; @@ -40,57 +40,32 @@ export function withStalkerPortal() { const logger = createLogger('withStalkerPortal'); return signalStoreFeature( withState(initialPortalState), - withProps( - ( - _store, - dataService = inject(DataService), - stalkerSession = inject(StalkerSessionService) - ) => ({ - /** - * Helper to make stalker requests with automatic token handling - */ - async makeStalkerRequest( - playlist: PlaylistMeta, - params: Record - ) { - // Get token if it's a full stalker portal - let token: string | undefined; - let serialNumber: string | undefined; - if (playlist.isFullStalkerPortal) { - try { - const result = await stalkerSession.ensureToken( - toStalkerSessionPlaylist(playlist) - ); - token = result.token ?? undefined; - serialNumber = result.serialNumber; - } catch (error) { - logger.error('Failed to get stalker token', error); - } - } - - return dataService.sendIpcEvent(STALKER_REQUEST, { - url: playlist.portalUrl, - macAddress: playlist.macAddress, - params, - token, - serialNumber, - }); - }, - }) - ), + // NOTE: the old `makeStalkerRequest` prop was removed — it was a + // production-dead fourth copy of the portal-mode branch. All request + // paths go through `executeStalkerRequest` (stores/utils), which + // applies the shared predicate and the lazy portal repair. withMethods( ( store, stalkerSession = inject(StalkerSessionService), + portalRepair = inject(StalkerPortalRepairService), runtime = inject(RuntimeCapabilitiesService) ) => ({ async setCurrentPlaylist(playlist: PlaylistMeta | undefined) { + // A lazy repair may have corrected this playlist's + // endpoint/mode while the NgRx meta stayed stale; route + // re-activation must not hand the stale snapshot back to + // the watchdog (it would stop or repoint the repaired + // keepalive) or into the store state. + const effectivePlaylist = playlist + ? portalRepair.applyOverride(playlist) + : playlist; stalkerSession.setActiveWatchdogPlaylist( - playlist - ? toStalkerSessionPlaylist(playlist) + effectivePlaylist + ? toStalkerSessionPlaylist(effectivePlaylist) : undefined ); - patchState(store, { currentPlaylist: playlist }); + patchState(store, { currentPlaylist: effectivePlaylist }); // Ensure Stalker playlist exists in SQLite for playback positions // Only sync if this is actually a Stalker playlist (has macAddress and portalUrl) diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts index 79ecf9256..5d749bf21 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts @@ -16,6 +16,7 @@ import { StalkerVodSeriesSeason, } from '../../models'; import { StalkerContentTypes } from '../../stalker-content-types'; +import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { isStalkerSeriesFlag } from '../../stalker-vod.utils'; import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts'; @@ -82,13 +83,15 @@ export function withStalkerSeries() { ( store, dataService = inject(DataService), - stalkerSession = inject(StalkerSessionService) + stalkerSession = inject(StalkerSessionService), + portalRepair = inject(StalkerPortalRepairService) ) => { const storeContext = store as typeof store & StalkerSeriesStoreContext; const requestDeps = { dataService, stalkerSession, + portalRepair, }; return { @@ -223,13 +226,15 @@ export function withStalkerSeries() { ( store, dataService = inject(DataService), - stalkerSession = inject(StalkerSessionService) + stalkerSession = inject(StalkerSessionService), + portalRepair = inject(StalkerPortalRepairService) ) => { const storeContext = store as typeof store & Pick; const requestDeps = { dataService, stalkerSession, + portalRepair, }; return { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts index 6bd6416e0..413ab6d0e 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts @@ -11,6 +11,10 @@ const PLAYLIST = { title: 'Demo Stalker', portalUrl: 'http://demo.example/stalker_portal/server/load.php', macAddress: '00:1A:79:00:00:01', + // Explicit: with the flag undefined the shared predicate would fall back + // to the URL shape, classify this as a full portal and route through the + // (empty) session mock instead of the DataService mock under test. + isFullStalkerPortal: false, } as PlaylistMeta; const SNAPSHOT: StalkerVodSource = { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.ts index 7c427590c..5c9f22450 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.ts @@ -12,6 +12,7 @@ import { StalkerPortalActions, } from '@iptvnator/shared/interfaces'; import { StalkerVodSource } from '../../models'; +import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; import { StalkerPortalStoreContract } from '../stalker-store.contracts'; @@ -50,13 +51,15 @@ export function withStalkerSnapshotRefresh() { ( store, dataService = inject(DataService), - stalkerSession = inject(StalkerSessionService) + stalkerSession = inject(StalkerSessionService), + portalRepair = inject(StalkerPortalRepairService) ) => { const storeContext = store as typeof store & StalkerPortalStoreContract; const requestDeps: StalkerRequestDeps = { dataService, stalkerSession, + portalRepair, }; const findFreshRow = async ( diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts index f625d5438..38e94137f 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.spec.ts @@ -33,6 +33,73 @@ describe('stalker-player-request.utils', () => { }; }); + it('resolves relative replies against arbitrary discovered installations', async () => { + // Discovery can persist a nested endpoint (/cp/server/load.php); + // the base must come from the endpoint's API suffix, not from a + // fixed stalker_portal|c|portal segment allowlist. + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: '/media/video_5.mpg' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink( + { + dataService: dataService as never, + stalkerSession: stalkerSession as StalkerSessionService, + }, + { + playlist: { + ...PLAYLIST, + portalUrl: 'http://demo.example/cp/server/load.php', + } as PlaylistMeta, + selectedContentType: 'vod', + cmd: '/media/source.mpg', + } + ); + + expect(streamUrl).toBe('http://demo.example/cp/media/video_5.mpg'); + }); + + it('resolves relative create_link replies against the repaired endpoint', async () => { + // A lazy repair can move the endpoint while the caller still holds + // the activation-time playlist snapshot; the relative `js.cmd` must + // resolve against the endpoint that actually answered. + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: 'ffmpeg /media/video_9.mpg' }, + }); + // Old row: root portal.php (base path ''). Repaired endpoint lives + // under /stalker_portal — the resolver keeps that segment as the + // base for root-relative replies, so the two resolve differently. + const stalePlaylist = { + ...PLAYLIST, + portalUrl: 'http://demo.example/portal.php', + } as PlaylistMeta; + const repaired = { + ...stalePlaylist, + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + } as PlaylistMeta; + + const streamUrl = await fetchStalkerPlaybackLink( + { + dataService: dataService as never, + stalkerSession: stalkerSession as StalkerSessionService, + portalRepair: { + applyOverride: jest.fn().mockReturnValue(repaired), + shouldAttemptRepair: jest.fn().mockReturnValue(false), + repairPortal: jest.fn().mockResolvedValue(null), + }, + }, + { + playlist: stalePlaylist, + selectedContentType: 'vod', + cmd: '/media/source.mpg', + } + ); + + expect(streamUrl).toBe( + 'http://demo.example/stalker_portal/media/video_9.mpg' + ); + }); + it('builds create_link requests and normalizes relative portal URLs', async () => { dataService.sendIpcEvent.mockResolvedValue({ js: { cmd: '/media/video_77.mpg' }, diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts index 7ee5b9c51..a5649fa03 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-player-request.utils.ts @@ -7,7 +7,10 @@ import { import { StalkerSessionService } from '../../stalker-session.service'; import { StalkerContentTypes } from '../../stalker-content-types'; import { StalkerContentType } from '../stalker-store.contracts'; -import { executeStalkerRequest } from './stalker-request.utils'; +import { + executeStalkerRequest, + type StalkerPortalRepairApi, +} from './stalker-request.utils'; export interface StalkerPlayerResponse { js?: { @@ -23,6 +26,7 @@ export interface StalkerPlayerResponse { export interface StalkerPlayerRequestDeps { dataService: DataService; stalkerSession: StalkerSessionService; + portalRepair?: StalkerPortalRepairApi; } export interface StalkerPlayableItemLike extends StalkerPortalItem { @@ -68,17 +72,28 @@ export function resolveStalkerPlaybackUrl( try { const portalUrlObj = new URL(portalUrl); - const pathParts = portalUrlObj.pathname.split('/'); + // The installation base is the endpoint path MINUS the API suffix + // discovery appended (`/portal.php`, `/server/load.php`) — endpoint + // discovery can persist arbitrary nested installations + // (`/cp/server/load.php`), so a fixed segment allowlist would + // resolve `/media/...` against the wrong root. The legacy marker + // segments stay as the fallback for URLs that carry neither suffix. + const endpointPath = portalUrlObj.pathname; let basePath = ''; - - for (let index = 0; index < pathParts.length; index += 1) { - if ( - pathParts[index] === 'stalker_portal' || - pathParts[index] === 'c' || - pathParts[index] === 'portal' - ) { - basePath = '/' + pathParts.slice(1, index + 1).join('/'); - break; + const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i; + if (apiSuffix.test(endpointPath)) { + basePath = endpointPath.replace(apiSuffix, ''); + } else { + const pathParts = endpointPath.split('/'); + for (let index = 0; index < pathParts.length; index += 1) { + if ( + pathParts[index] === 'stalker_portal' || + pathParts[index] === 'c' || + pathParts[index] === 'portal' + ) { + basePath = '/' + pathParts.slice(1, index + 1).join('/'); + break; + } } } @@ -146,8 +161,16 @@ export async function fetchStalkerPlaybackLink( throw new Error(response.js.error); } + // Applied AFTER the request on purpose: a lazy repair may have moved + // the endpoint during this very call, and a relative `js.cmd` + // (`/media/...`) must resolve against the endpoint that actually + // answered — not the activation-time snapshot in options.playlist. + const effectivePlaylist = deps.portalRepair + ? deps.portalRepair.applyOverride(options.playlist) + : options.playlist; + const streamUrl = resolveStalkerPlaybackUrl( - options.playlist.portalUrl ?? '', + effectivePlaylist.portalUrl ?? '', options.cmd, response.js?.cmd ?? '' ); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts index 47607e9ef..568055315 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.spec.ts @@ -1,6 +1,7 @@ import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { executeStalkerRequest, + type StalkerPortalRepairApi, type StalkerRequestDeps, } from './stalker-request.utils'; @@ -76,3 +77,141 @@ describe('executeStalkerRequest', () => { ).not.toHaveBeenCalled(); }); }); + +describe('executeStalkerRequest portal-mode fallback', () => { + it('authenticates legacy rows with an undefined flag but a canonical URL', async () => { + // The historical import predicate persisted nothing for some rows; + // the shared predicate must fall back to the URL rule instead of + // silently skipping authentication (the #850 failure shape). + const deps = createDeps(); + const playlist = { + _id: 'stalker-legacy', + title: 'Legacy row', + portalUrl: 'https://portal.example.test/server/load.php', + macAddress: 'has-mac-address', + } as PlaylistMeta; + + await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS); + + expect(deps.stalkerSession.makeAuthenticatedRequest).toHaveBeenCalled(); + expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); +}); + +describe('executeStalkerRequest lazy portal repair', () => { + const PLAYLIST = { + _id: 'stalker-misclassified', + title: 'Misclassified portal', + portalUrl: 'https://portal.example.test/server/load.php', + macAddress: 'has-mac-address', + isFullStalkerPortal: false, + } as PlaylistMeta; + + function createRepair( + overrides: Partial = {} + ): StalkerPortalRepairApi { + return { + applyOverride: jest.fn((playlist) => playlist), + shouldAttemptRepair: jest.fn().mockReturnValue(false), + repairPortal: jest.fn().mockResolvedValue(null), + ...overrides, + } as StalkerPortalRepairApi; + } + + it('retries once against the repaired configuration after an auth-failure body', async () => { + const deps = createDeps(); + deps.dataService.sendIpcEvent = jest + .fn() + .mockResolvedValue('Authorization failed.'); + const repaired = { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta; + deps.portalRepair = createRepair({ + shouldAttemptRepair: jest.fn( + (_playlist, failure) => failure === 'Authorization failed.' + ), + repairPortal: jest.fn().mockResolvedValue(repaired), + }); + + const response = await executeStalkerRequest( + deps, + PLAYLIST, + CATEGORY_PARAMS + ); + + // The retry ran in full-portal mode against the repaired row. + expect( + deps.stalkerSession.makeAuthenticatedRequest + ).toHaveBeenCalledWith( + expect.objectContaining({ isFullStalkerPortal: true }), + CATEGORY_PARAMS + ); + expect(response).toEqual({ js: [] }); + }); + + it('returns the raw response when the repair declines to change anything', async () => { + const deps = createDeps(); + deps.dataService.sendIpcEvent = jest + .fn() + .mockResolvedValue('Authorization failed.'); + deps.portalRepair = createRepair({ + shouldAttemptRepair: jest.fn().mockReturnValue(true), + }); + + const response = await executeStalkerRequest( + deps, + PLAYLIST, + CATEGORY_PARAMS + ); + + expect(response).toBe('Authorization failed.'); + expect(deps.dataService.sendIpcEvent).toHaveBeenCalledTimes(1); + }); + + it('repairs after a thrown transport error and rethrows when nothing changed', async () => { + const deps = createDeps(); + const notFound = { message: 'HTTP Error: Not Found', status: 404 }; + deps.dataService.sendIpcEvent = jest.fn().mockRejectedValue(notFound); + const repair = createRepair({ + shouldAttemptRepair: jest.fn().mockReturnValue(true), + }); + deps.portalRepair = repair; + + await expect( + executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS) + ).rejects.toBe(notFound); + expect(repair.repairPortal).toHaveBeenCalledWith(PLAYLIST); + }); + + it('applies an existing override before dispatching', async () => { + const deps = createDeps(); + const repaired = { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta; + deps.portalRepair = createRepair({ + applyOverride: jest.fn().mockReturnValue(repaired), + }); + + await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS); + + expect( + deps.stalkerSession.makeAuthenticatedRequest + ).toHaveBeenCalled(); + expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('never repairs on healthy responses', async () => { + const deps = createDeps(); + const repair = createRepair(); + deps.portalRepair = repair; + + await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS); + + expect(repair.shouldAttemptRepair).toHaveBeenCalledWith(PLAYLIST, { + js: [], + }); + expect(repair.repairPortal).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts index bc61bd959..f07f4b622 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts @@ -1,10 +1,34 @@ import { DataService } from '@iptvnator/services'; -import { Playlist, PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; +import { + isFullStalkerPortalPlaylist, + Playlist, + PlaylistMeta, + STALKER_REQUEST, +} from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from '../../stalker-session.service'; +/** + * The slice of `StalkerPortalRepairService` the request pipeline needs. + * Declared here (instead of importing the service) so the service can live + * at the lib root and depend on these utils without a cycle. + */ +export interface StalkerPortalRepairApi { + /** Applies a completed repair; returns the SAME reference when no-op. */ + applyOverride(playlist: T): T; + /** Whether this failure shape justifies probing the portal at all. */ + shouldAttemptRepair(playlist: PlaylistMeta, failure: unknown): boolean; + /** + * Probes and persists a proven-different configuration; null when + * nothing may change. + */ + repairPortal(playlist: PlaylistMeta): Promise; +} + export interface StalkerRequestDeps { dataService: DataService; stalkerSession: StalkerSessionService; + /** Optional lazy portal-mode repair; wired by the store feature slices. */ + portalRepair?: StalkerPortalRepairApi; } export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist { @@ -14,12 +38,19 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist { } as Playlist; } -export async function executeStalkerRequest( +/** + * Routes one Stalker request according to the playlist's portal mode: + * full portals go through the authenticated session (handshake + Bearer + * token + retry), token-free panels are called directly. The mode comes + * from the shared `isFullStalkerPortalPlaylist` predicate — the single + * rule every portal-mode consumer uses. + */ +async function dispatchStalkerRequest( deps: StalkerRequestDeps, playlist: PlaylistMeta, params: Record ): Promise { - if (playlist.isFullStalkerPortal) { + if (isFullStalkerPortalPlaylist(playlist)) { return deps.stalkerSession.makeAuthenticatedRequest( toStalkerSessionPlaylist(playlist), params @@ -32,3 +63,48 @@ export async function executeStalkerRequest( params, }); } + +/** + * Single choke point for Stalker API calls. On top of the mode routing it + * hooks the lazy portal repair: when a request fails in a way only a wrong + * persisted endpoint/mode produces (plain-text `Authorization failed.` + * bodies on token-less requests, HTTP 404 on a vanished endpoint, terminal + * handshake failures), the repair service re-probes the portal once per + * session and — only when a different configuration is PROVEN to work — + * the request is retried against it. Healthy portals never probe. + */ +export async function executeStalkerRequest( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + params: Record +): Promise { + const effective = deps.portalRepair + ? deps.portalRepair.applyOverride(playlist) + : playlist; + + try { + const response = await dispatchStalkerRequest( + deps, + effective, + params + ); + + if (deps.portalRepair?.shouldAttemptRepair(effective, response)) { + const repaired = await deps.portalRepair.repairPortal(effective); + if (repaired) { + return dispatchStalkerRequest(deps, repaired, params); + } + } + + return response; + } catch (error) { + if (deps.portalRepair?.shouldAttemptRepair(effective, error)) { + const repaired = await deps.portalRepair.repairPortal(effective); + if (repaired) { + return dispatchStalkerRequest(deps, repaired, params); + } + } + + throw error; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 19f22ab74..3a3b03f4f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -13,13 +13,15 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { + executeStalkerRequest, + StalkerPortalRepairService, + StalkerSessionService, +} from '@iptvnator/portal/stalker/data-access'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { PlaybackPositionData, - Playlist, ResolvedPortalPlayback, - STALKER_REQUEST, StalkerPortalActions, VodDetailsItem, } from '@iptvnator/shared/interfaces'; @@ -97,6 +99,7 @@ export class StalkerSearchComponent { private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly stalkerStore = inject(StalkerStore); private readonly stalkerSession = inject(StalkerSessionService); + private readonly portalRepair = inject(StalkerPortalRepairService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly logger = createLogger('StalkerSearch'); @@ -191,30 +194,19 @@ export class StalkerSearchComponent { ...(contentType === 'vod' ? { genre: '0' } : {}), }; - // Full portals need the handshake token. The persisted flag can - // be missing on the active-playlist meta object, so fall back - // to URL detection — otherwise the portal answers - // "Authorization failed." and the search looks empty. - const isFullPortal = - (playlist as Playlist).isFullStalkerPortal ?? - this.stalkerSession.isFullStalkerPortal(portalUrl); - - const response = isFullPortal - ? await this.stalkerSession.makeAuthenticatedRequest( - { - ...(playlist as Playlist), - isFullStalkerPortal: true, - }, - requestParams - ) - : await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params: requestParams, - } - ); + // executeStalkerRequest owns the portal-mode decision (shared + // predicate with URL fallback for legacy rows) and the lazy + // portal repair, so search cannot drift from the catalog paths. + const response = + await executeStalkerRequest( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, + playlist, + requestParams + ); const items = response.js?.data || []; return items.map((item: StalkerVodSource) => this.processItemUrls(item, portalUrl) diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index f240dd9fd..144d062a0 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -1290,6 +1290,9 @@ describe('PlaylistsService', () => { store.current = target; } }), + dbDeletePlaylist: jest.fn(async () => { + store.current = undefined as unknown as Playlist; + }), }; return { store, electron }; } @@ -1479,6 +1482,87 @@ describe('PlaylistsService', () => { expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(1); }); + it('serializes deletion behind queued writes so nothing resurrects the row', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-delete-race') + ); + testWindow.electron = electron; + const service = createService(); + + // A conditional transform (the repair's write path) is queued + // when the user deletes the playlist: the delete must run AFTER + // the queued write, leaving the row deleted — not upserted back. + await Promise.all([ + firstValueFrom( + service.transformPlaylistMeta( + 'portal-delete-race', + (current) => ({ + ...current, + portalUrl: 'http://x/portal.php', + }) + ) + ), + firstValueFrom(service.deletePlaylist('portal-delete-race')), + ]); + + expect(store.current).toBeUndefined(); + const upsertOrder = + electron.dbUpsertAppPlaylist.mock.invocationCallOrder[0]; + const deleteOrder = + electron.dbDeletePlaylist.mock.invocationCallOrder[0]; + expect(deleteOrder).toBeGreaterThan(upsertOrder); + }); + + it('transformPlaylistMeta aborts without writing when the transform returns null', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-meta-abort') + ); + testWindow.electron = electron; + const service = createService(); + const writesBefore = electron.dbUpsertAppPlaylist.mock.calls.length; + + const result = await firstValueFrom( + service.transformPlaylistMeta('portal-meta-abort', () => null) + ); + + expect(result).toBeNull(); + expect(electron.dbUpsertAppPlaylist.mock.calls.length).toBe( + writesBefore + ); + }); + + it('transformPlaylistMeta persists the transformed row and serializes with queued edits', async () => { + const { store, electron } = createStatefulElectronStore( + createBasePlaylist('portal-meta-write') + ); + testWindow.electron = electron; + const service = createService(); + + // A queued edit commits first; the conditional transform then + // sees ITS result — the property the Stalker portal repair + // relies on to never overwrite a user edit racing the probe. + await Promise.all([ + firstValueFrom( + service.updatePlaylistMeta({ + _id: 'portal-meta-write', + title: 'Edited Title', + } as never) + ), + firstValueFrom( + service.transformPlaylistMeta( + 'portal-meta-write', + (current) => + current.title === 'Edited Title' + ? { ...current, portalUrl: 'http://x/portal.php' } + : null + ) + ), + ]); + + expect(store.current.title).toBe('Edited Title'); + expect(store.current.portalUrl).toBe('http://x/portal.php'); + }); + it('applies overlapping favorites transforms atomically', async () => { const { store, electron } = createStatefulElectronStore( createBasePlaylist('portal-transform-race') diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 85b332a10..2b3031066 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -22,6 +22,7 @@ import { Channel, DbStores, extractStalkerItemId, + isFullStalkerPortalUrl, isM3uRecentlyViewedItem, M3uFavoriteChannel, M3uRecentlyViewedItem, @@ -254,13 +255,10 @@ export class PlaylistsService { } const portalUrl = playlist.portalUrl ?? playlist.url ?? ''; - const isFullPortal = - portalUrl.includes('/stalker_portal') || - portalUrl.includes('/server/load.php'); return { ...playlist, - isFullStalkerPortal: isFullPortal, + isFullStalkerPortal: isFullStalkerPortalUrl(portalUrl), }; } @@ -475,17 +473,27 @@ export class PlaylistsService { } deletePlaylist(playlistId: string): Observable<{ success: boolean }> { - const delete$: Observable = this.isElectronStorageAvailable - ? this.runOnSqlite(async () => { - const electron = this.electronApi; - if (!electron) { - return undefined; - } + // Deletion goes through the SAME per-playlist queue as every write: + // a queued mutation (e.g. the Stalker portal repair's conditional + // transform) landing after an unserialized delete would upsert the + // row back and resurrect the playlist. + const delete$: Observable = this.serializePlaylistWrite( + playlistId, + async () => { + if (this.isElectronStorageAvailable) { + await this.ensureElectronPlaylistMigrations(); + const electron = this.electronApi; + if (electron) { + await electron.dbDeletePlaylist(playlistId); + } + return undefined; + } - await electron.dbDeletePlaylist(playlistId); - return undefined; - }) - : this.dbService.delete(DbStores.Playlists, playlistId); + return firstValueFrom( + this.dbService.delete(DbStores.Playlists, playlistId) + ); + } + ); return delete$.pipe( switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))), @@ -769,6 +777,41 @@ export class PlaylistsService { }); } + /** + * Applies an atomic, conditional meta mutation: the transform runs on + * the freshly read row INSIDE the per-playlist write queue and may + * return null to abort without writing. Callers use this when the + * decision to write depends on the row's CURRENT state — e.g. the lazy + * Stalker portal repair verifying the row still carries the + * configuration it probed; a plain read-check-then-update pair would + * race a user edit already queued but not yet committed. + */ + transformPlaylistMeta( + playlistId: string, + transform: (current: Playlist) => Playlist | null + ): Observable { + if (!playlistId) { + throw new Error('Playlist ID is required'); + } + + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); + if (!playlist) { + return null; + } + + const nextPlaylist = transform(playlist); + if (nextPlaylist === null) { + return null; + } + + await this.persistPlaylistMutation(nextPlaylist); + return nextPlaylist; + }); + } + updateManyPlaylists(playlists: Playlist[]) { if (playlists.length === 0) { return of([]); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index f9fbbf598..4593b09ab 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -40,6 +40,7 @@ export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; export * from './lib/stalker-cmd-encoding.util'; export * from './lib/stalker-portal-actions.enum'; +export * from './lib/stalker-portal-mode.util'; export * from './lib/store-keys.enum'; export * from './lib/stream-format.enum'; export * from './lib/catalog-title-match.interface'; diff --git a/libs/shared/interfaces/src/lib/stalker-portal-mode.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-portal-mode.util.spec.ts new file mode 100644 index 000000000..c58ee8a1b --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-portal-mode.util.spec.ts @@ -0,0 +1,75 @@ +import { + isFullStalkerPortalPlaylist, + isFullStalkerPortalUrl, +} from './stalker-portal-mode.util'; + +describe('isFullStalkerPortalUrl', () => { + it.each([ + // The canonical Ministra endpoint the old IMPORT predicate missed — + // the root cause of "portal added, no content" (#850). + 'http://portal.example/server/load.php', + // /stalker_portal without a trailing slash, which the old RUNTIME + // predicate (`includes('/stalker_portal/')`) missed. + 'http://portal.example/stalker_portal', + 'http://portal.example/stalker_portal/c', + 'http://portal.example/stalker_portal/server/load.php', + ])('classifies %s as a full portal URL', (url) => { + expect(isFullStalkerPortalUrl(url)).toBe(true); + }); + + it.each([ + 'http://portal.example/portal.php', + 'http://portal.example/c', + 'http://portal.example', + '', + ])('classifies %s as a non-full portal URL', (url) => { + expect(isFullStalkerPortalUrl(url)).toBe(false); + }); +}); + +describe('isFullStalkerPortalPlaylist', () => { + it('trusts an explicit true flag even for a portal.php URL', () => { + // Discovery can prove a portal.php panel enforces the token; the + // observed behavior must beat the URL shape. + expect( + isFullStalkerPortalPlaylist({ + isFullStalkerPortal: true, + portalUrl: 'http://portal.example/portal.php', + }) + ).toBe(true); + }); + + it('trusts an explicit false flag even for a canonical URL', () => { + expect( + isFullStalkerPortalPlaylist({ + isFullStalkerPortal: false, + portalUrl: 'http://portal.example/server/load.php', + }) + ).toBe(false); + }); + + it('falls back to the portalUrl shape when the flag is undefined', () => { + expect( + isFullStalkerPortalPlaylist({ + portalUrl: 'http://portal.example/server/load.php', + }) + ).toBe(true); + expect( + isFullStalkerPortalPlaylist({ + portalUrl: 'http://portal.example/portal.php', + }) + ).toBe(false); + }); + + it('falls back to the legacy url field when portalUrl is absent', () => { + expect( + isFullStalkerPortalPlaylist({ + url: 'http://portal.example/stalker_portal/c', + }) + ).toBe(true); + }); + + it('treats a playlist with no URL at all as a simple portal', () => { + expect(isFullStalkerPortalPlaylist({})).toBe(false); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts b/libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts new file mode 100644 index 000000000..4fcc23362 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts @@ -0,0 +1,47 @@ +/** + * Single source of truth for the "full Stalker portal" decision. + * + * A full portal is one that requires the handshake + Bearer-token auth + * lifecycle (canonical Stalker/Ministra middleware); a simple portal is a + * reseller-style `portal.php` panel that answers without any auth. The + * persisted `Playlist.isFullStalkerPortal` flag is authoritative — since + * endpoint discovery it records OBSERVED behavior, not a URL guess. The URL + * shape is only a fallback for legacy rows that predate the flag. + * + * History: three predicates used to coexist (import checked + * `/stalker_portal`, runtime checked `/stalker_portal/` OR + * `/server/load.php`, the legacy-repair migration checked a third variant) + * and their drift misclassified canonical `…/server/load.php` portals as + * token-free at import (#850, #686, #755). Every consumer must go through + * these helpers so the rule cannot fork again. + */ + +/** + * URL shapes that identify a canonical (full) Stalker portal endpoint. + * Union of the historical import/runtime/migration variants: matches + * `/stalker_portal` with or without a trailing slash and the root-level + * `/server/load.php` used by Ministra installations. + */ +export function isFullStalkerPortalUrl(url: string): boolean { + return url.includes('/stalker_portal') || url.includes('/server/load.php'); +} + +/** + * Whether a playlist should use the full-portal auth lifecycle + * (handshake, Bearer token, watchdog). + * + * The persisted flag wins when present; rows restored from older backups can + * carry `undefined` even after the one-shot metadata migration ran, so those + * fall back to the URL rule instead of being mislabelled as legacy panels. + */ +export function isFullStalkerPortalPlaylist(playlist: { + isFullStalkerPortal?: boolean; + portalUrl?: string; + url?: string; +}): boolean { + if (playlist.isFullStalkerPortal !== undefined) { + return Boolean(playlist.isFullStalkerPortal); + } + + return isFullStalkerPortalUrl(playlist.portalUrl ?? playlist.url ?? ''); +}