From b92503feaede2b86c59e4ae468006ef4f94af980 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:01:45 +0200 Subject: [PATCH] feat(stalker): endpoint probing + behavior-based portal mode with lazy repair (#1344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(stalker): endpoint probing + behavior-based portal mode with lazy repair Replace the URL-shape guess behind isFullStalkerPortal with real endpoint discovery: at import, probe portal.php -> server/load.php -> stalker_portal/server/load.php (the pasted .php endpoint first) and classify the portal by observed behavior — a token-less itv/get_genres answering data proves a token-free panel, the middleware's plain-text auth failure proves the endpoint enforces the token, confirmed by the real handshake + get_profile. The proven endpoint and mode are persisted. The three diverging portal-mode predicates (import, session service, legacy migration) collapse into one shared helper in @iptvnator/shared/interfaces; executeStalkerRequest becomes the single request choke point (search and the collection stream resolver fold in), and the production-dead makeStalkerRequest copy is removed. Existing misclassified playlists repair themselves lazily: only after a request actually fails with the plain-text auth bodies, HTTP 404, or a terminal handshake error, at most once per playlist per session, and only a configuration discovery proved to answer is persisted — via a minimal portalUrl/isFullStalkerPortal patch, so favorites, recents and playback positions survive. Working reseller panels are never probed or rewritten; there is deliberately no eager one-shot migration, because tolerant portal.php panels cannot be told apart from misclassified canonical portals without probing. The Electron handler now embeds the HTTP status code in the error message (ipcRenderer.invoke strips custom properties from rejections), and probe requests carry silent:true so expected 404s do not toast error snackbars. The stalker mock gains a portal.php-less /ministra host so e2e can prove the 404 fallthrough end to end. Fixes #850, #686, #755. Co-Authored-By: Claude Fable 5 * fix(stalker): sync watchdog, PWA proxy errors and cmd resolution with lazy repair Review round 1 (Greptile P1, Codex P1/P2): - A successful repair now re-syncs the ACTIVE watchdog playlist via the new StalkerSessionService.refreshActiveWatchdogPlaylist(): a simple-to-full repair starts the required keepalive mid-session, full-to-simple stops it, and an endpoint change repoints the pings instead of leaving them on the activation-time snapshot. - PwaService.forwardStalkerRequest surfaces the web-backend proxy's normalized { message, status } no-payload envelope as an HTTP error carrying the status, so endpoint discovery and the lazy repair can classify upstream 404s in the PWA too (previously payload unwrapping returned undefined and dead endpoints were unrepairable there). Probe requests pass silent:true and skip the error snackbar. - fetchStalkerPlaybackLink and the collection StreamResolverService re-apply the repair override AFTER the request, so a relative create_link reply resolves against the endpoint that actually answered (the resolver keeps the /stalker_portal path segment as base, so this matters beyond origin). Co-Authored-By: Claude Fable 5 * fix(stalker): parse candidate URLs and tie repair overrides to their source config Review round 2 (Codex P2 x2): - Endpoint candidates are now derived from the parsed origin + pathname: a pasted URL carrying a query or fragment (host/c?key=value) no longer gets /portal.php bolted onto the query, which made every probe hit /c and persisted the non-API URL. - A repair override is tied to the failing configuration it replaced. Playlists carrying anything else (the user edited the portal URL or mode through the playlist dialog) drop the override and re-arm the once-per-session probe latch, so edited metadata is used verbatim and may repair again if it fails. Co-Authored-By: Claude Fable 5 * fix(stalker): auth-gated probes, normalized offline fallback, mock docs sync Review round 3 (Codex P1 x2, P2): - A probe answered with HTTP 401/403 now classifies the endpoint as auth-required and attempts the real handshake instead of skipping the candidate: non-standard middlewares answer 401 where the stock server sends HTTP 200 + plain text, and such portals authenticated fine before discovery existed. - The unreachable-host import fallback normalizes the pasted URL (origin + pathname) before the legacy /c -> portal.php rewrite, so a query or fragment can no longer make it persist the browser page URL - a 200 HTML answer from /c is not a repair trigger, which would have left the playlist empty for good. - The stalker mock-server README and architecture doc now describe behavior-based discovery and the /ministra host instead of the retired URL-shape rule and its "known inconsistency" note. Co-Authored-By: Claude Fable 5 * fix(stalker): recognize JSON auth failures and guard repairs against mid-probe edits Review round 4 (Codex P1 + P2): - isStalkerAuthFailureResponse() recognizes the JSON envelope some panels answer instead of the plain-text body ({js:{error:"Authorization failed"}} / {js:{msg:...}}). Probe classification treats it as auth-required instead of token-free data, and the lazy-repair trigger fires on it at runtime — previously such a portal was persisted simple with no repair path at all. - A repair is committed only after re-reading the persisted row and verifying it still carries the configuration that failed: a user who edits the portal URL (or deletes the playlist) during the multi-second probe now wins over the in-flight repair result for the old URL. Co-Authored-By: Claude Fable 5 * fix(stalker): probe past endpoint 5xx, sibling fallbacks, identity-aware repair guard Review round 5 (Codex P2 x3): - A probe that fails with a RESOLVABLE HTTP status keeps discovery going: a broken /portal.php handler answering 500 must not hide a healthy sibling endpoint. Only status-less failures (true network level) stop the loop. The Electron handler now gives real HTTP 5xx responses the same parseable "HTTP Error " message shape as 4xx, so the renderer can tell them apart from ECONNREFUSED/timeouts after ipcRenderer strips the object shape. - Standard fallback candidates for a nonstandard pasted endpoint (.../cp/api.php) derive from its DIRECTORY, so recovery probes hit /cp/portal.php instead of /cp/api.php/portal.php. - The repair's row re-verification also compares the MAC and all Stalker identity fields: a probe authenticated as the old identity must not install its token/watchdog or persist onto a row whose credentials were edited mid-probe. Co-Authored-By: Claude Fable 5 * fix(stalker): reactivation-safe watchdog, wider JSON auth phrases, per-config probe latch Review round 6 (Greptile 4/5 concern + Codex P1/P2): - setCurrentPlaylist applies the repair override before feeding the watchdog and store state: re-activating the portal route with the stale NgRx meta no longer stops or repoints the repaired keepalive back to the broken configuration. - The structured js.error/js.msg fields accept the full phrase set the session service recognizes (Invalid token, Auth failed, bare unauthorized/authorization) — panels answering those envelopes were still classified token-free. Plain-text body matching stays narrow on purpose (HTML false positives). - The once-per-session probe latch is keyed by the SOURCE configuration fingerprint (endpoint, mode, MAC, identity) instead of the playlist id: a repair discarded because of a mid-probe edit no longer blocks the edited configuration from repairing, while stale snapshots of an already-probed configuration still cannot loop the probe. Co-Authored-By: Claude Fable 5 * fix(stalker): identity-aware override invalidation and timeout-tolerant probing Review round 7 (Greptile P1 + Codex P2): - The repair override records the identity fingerprint the probe authenticated as. Editing the MAC or any Stalker identity field afterwards drops the override, the per-config probe latch AND the cached token, so requests and watchdog pings never pair the edited identity with a session negotiated for the previous one. - A status-less probe failure that is a TIMEOUT (renderer budget, axios request timeout, ETIMEDOUT) continues to the next candidate — one hanging handler must not hide healthy siblings; connection-level failures (refused, unresolvable host) still stop discovery, so dead hosts keep failing fast. Co-Authored-By: Claude Fable 5 * fix(stalker): watchdog pings authenticate as the persisted row Review round 8 (Greptile 4/5 concern): The watchdog held its activation-time playlist snapshot for the whole session, so portal metadata edited (or repaired) mid-session kept the keepalive authenticating as the previous identity/endpoint — its pings could keep the old session alive and repopulate the playlist-scoped token cache with a token for the pre-edit identity. Each ping now resolves the playlist from the persisted row first (the single source of truth), falling back to the snapshot only when the store cannot be read, and refreshes the snapshot on every successful read. Any edit — identity, endpoint or mode — reaches the keepalive within one ping cycle; a row now marked simple (or deleted) stops the watchdog. The in-flight guard is claimed before the row read so overlapping pings cannot double-fire. Co-Authored-By: Claude Fable 5 * fix(stalker): identity-tagged tokens, watchdog override overlay, retire-on-failure Review round 9 (Greptile 4/5 concern + Codex P2): - The session token cache is tagged with the identity fingerprint (MAC + all Stalker identity fields) the session was negotiated for; ensureToken re-authenticates instead of handing an edited identity the previous token. The fingerprint helper is shared (stalker-identity.utils) with the repair layer's override/latch checks. - Watchdog pings overlay the repair layer's in-session override on the resolved row (registered decorator, no import cycle): a simple-to-full repair whose persistence is pending or failed no longer reads the stale row and stops the freshly started keepalive. - makeAuthenticatedRequest retires a failed token even on the no-retry path (watchdog pings), so a dead session is never handed to the next caller. Co-Authored-By: Claude Fable 5 * fix(stalker): pending authentications are identity-scoped Review round 10 (Greptile 4/5 concern): pendingAuth entries carry the identity fingerprint they authenticate as. A request for an edited identity no longer adopts an in-flight result negotiated for the previous identity: it waits the old authentication out (a competing handshake would strand it with a dead token on strict portals) and then negotiates its own session. This was the last id-only-keyed session structure — override, probe latch, token cache, watchdog snapshot and pending auth are now all identity-aware. Co-Authored-By: Claude Fable 5 * fix(stalker): atomic repair persistence, full probe history, normalized offline classify Review round 11 (Codex P2 x3 + P1 docs): - The repair's row verification and patch now run ATOMICALLY inside the per-playlist write queue via the new PlaylistsService.transformPlaylistMeta(): a user edit that is queued but not yet committed wins over the repair — the transform sees the edited row and aborts instead of overwriting it. Write failures after a successful verification keep the session-only override, read failures discard the repair. - The per-playlist probe latch keeps EVERY attempted source fingerprint, so alternating edits (A -> B -> A) cannot evict a fingerprint and let stale snapshots re-run discovery. - The unreachable-host import fallback classifies the normalized origin+pathname, so a query merely mentioning /server/load.php cannot make a panel URL look canonical and abort the offline import. - docs/architecture/stalker-portal.md documents the actual probe sequencing: any resolvable HTTP status (incl. 5xx) and timeouts continue, 401/403 classify as auth-required, only connection-level failures abort. Co-Authored-By: Claude Fable 5 * fix(stalker): collision-proof session fingerprints Review round 12 (Greptile P1): identity values are unrestricted strings, so the delimiter-joined fingerprint could alias distinct identity tuples (serial "a|b" + empty device vs serial "a" + device "b") and bypass the identity invalidation. Both the identity fingerprint and the repair source fingerprint are JSON-encoded now; regression test pins the exact aliasing pair. Co-Authored-By: Claude Fable 5 * fix(stalker): preserve URL authority in normalization; document per-config latch Review round 13 (Codex P1 docs + P2): - normalizeStalkerPortalInputUrl mutates the parsed URL (clear query/ fragment, trim pathname) instead of rebuilding from origin, and the candidate builder swaps only the path — file: URLs (origin "null") no longer make the builder throw, and basic-auth credentials are not silently dropped before probing. - The canonical docs and the repair service JSDoc now describe the actual loop guard: at most one probe per SOURCE CONFIGURATION (endpoint, mode, MAC, identity) per playlist per session, not once per playlist. Co-Authored-By: Claude Fable 5 * fix(stalker): HTTP 401/403 failures trigger the lazy repair Review round 14 (Codex P1): discovery classifies 401/403 endpoints as auth-required, but the repair trigger accepted only 404 — a legacy playlist misclassified token-free against an HTTP-auth-gated middleware could never reach discovery and stayed unusable. 401/403 now qualify; endpoint-specific 5xx still do not. Co-Authored-By: Claude Fable 5 * fix(stalker): re-enter repair for edited configurations after a pending probe Review round 15 (Codex P2): a request carrying an edited configuration that raced an in-flight probe only awaited it and inherited its outcome — the edited fingerprint stayed unattempted and the first request failed without triggering its own discovery. repairPortal now re-enters after awaiting the pending probe, so the per-config latch decides: already attempted -> reapply, never attempted -> own probe. Co-Authored-By: Claude Fable 5 * fix(stalker): probe history remembers outcomes so restored configs repair again Review round 16 (Greptile P1): the per-config latch kept A's fingerprint after an edit to B dropped A's override, so restoring A left it latched with nothing to reapply — broken until restart. The history now stores each probe's OUTCOME (override or null): a restored configuration reinstalls its remembered repair without a second discovery, and the anti-ping-pong property (A<->B alternation never re-runs discovery from stale snapshots) is preserved. Co-Authored-By: Claude Fable 5 * fix(playlist): serialize deletion behind the per-playlist write queue Review round 17 (Codex P2): deletePlaylist bypassed serializePlaylistWrite, so a queued mutation (e.g. the Stalker portal repair's conditional transform) finishing after an unserialized delete could upsert the row back and resurrect the playlist. Deletion now runs through the same queue: queued writes commit first, the delete lands last, and a transform enqueued after the delete reads a missing row and aborts. Regression test pins the write-then-delete ordering. Co-Authored-By: Claude Fable 5 * fix(stalker): reinstalled repairs re-sync the watchdog like fresh ones Review round 18 (Greptile P1): the restored-configuration branch reinstalled the remembered override without the watchdog refresh the fresh-repair path performs — if the intermediate edit stopped the keepalive, the restored full-portal session recovered requests but never its pings. The reinstall now calls refreshActiveWatchdogPlaylist with the override applied, symmetric with a fresh repair. Co-Authored-By: Claude Fable 5 * fix(stalker): discarded probes retry once their configuration is restored Review round 19 (Greptile P1): the pre-probe history reservation survived the row-mismatch discard, so restoring the original configuration hit the latch with nothing to reinstall — lazy repair stayed disabled for the session. Probe records are now explicit (override / no-change / discarded): a discarded configuration probes again once one cheap row read confirms the row was RESTORED to it, while stale snapshots of it stay declined without a discovery run. Co-Authored-By: Claude Fable 5 * fix(stalker): IPC-safe transport errors, repairable profile path, nested base paths Review round 20 (Codex P2 x4): - The Electron handler throws a real Error for axios failures without a response: Electron serializes rejections via toString(), so a plain object arrived as "[object Object]" and discovery could not tell a timeout (keep probing) from a dead host (stop). - isAuthorizationError parses HTTP 401/403 out of the IPC-wrapped message, so an expired-token 403 retires the token and re-authenticates instead of surfacing as a plain failure. - The account-info full-profile path (which bypasses executeStalkerRequest) routes repair-trigger failures through StalkerPortalRepairService and retries with the repaired playlist, so opening the dialog can fix a stale endpoint. - resolveStalkerPlaybackUrl derives the installation base from the endpoint's API suffix instead of a fixed stalker_portal|c|portal allowlist: relative create_link replies now resolve correctly under arbitrary discovered installations such as /cp/server/load.php. Co-Authored-By: Claude Fable 5 * fix(stalker): strict probe data shape, mode-aware profile retry, docs API name Review round 21 (Codex P1 docs + P2 x2): - Probe classification requires the real get_genres shape (array, or a {data: []} envelope without an error) instead of a bare `js` key: a 200 error envelope ({js:{error:"Unknown action"}}, {js:false}) no longer ends discovery on a broken candidate and persists an empty catalog. - After a repair that flips the portal to simple mode, the account-info retry re-enters the mode routing and uses get_main_info instead of handshaking against a token-free panel again. - docs/architecture/stalker-portal.md names transformPlaylistMeta and its atomic source-check invariant (plus the serialized deletion) rather than the race-prone updatePlaylistMeta. Co-Authored-By: Claude Fable 5 * fix(stalker): account dialog re-routes after a simple-to-full repair Review round 22 (Codex P2): fetchViaMainInfo runs through executeStalkerRequest, whose lazy repair retries the SAME action, so a repair proving the portal is actually full left the dialog calling get_main_info — canonical installations publish subscription details only through handshake + get_profile, leaving the dialog empty. The routing is now symmetric with the full-to-simple case: an empty main-info result whose repair flipped the mode re-enters the profile flow. Co-Authored-By: Claude Fable 5 * fix(stalker): row-gate override reinstall; document mode-based account routing Review round 23 (Codex P2 + P1 docs): - Reinstalling a remembered override now requires the persisted row to actually carry that configuration again. A stale request for A while the row holds an unrelated C no longer resurrects A's override, which would retry against B and repoint the active watchdog away from C. (The edit-back-to-A case stays as documented: there the row IS A.) - docs/architecture/stalker-portal.md and CLAUDE.md describe account-info routing by the observed portal MODE instead of the endpoint shape — a token-enforcing portal.php is a full portal now — and note the mode-change re-routing in both directions. Co-Authored-By: Claude Fable 5 * fix(stalker): share the auth-failure predicate; prefer profile over partial main-info Review round 24 (Codex P1 + P2): - isAuthorizationError now reuses isStalkerAuthFailureResponse, so the phrases discovery and the lazy repair already classify as auth failures (Access denied., Unauthorized request., and their JSON envelopes) also retire the session token. Previously a full portal expiring with either phrase kept its dead token: the repair rediscovered the same endpoint/mode, recorded no-change, and every later request stayed broken. - After a simple-to-full repair, even a PARTIAL get_main_info answer no longer wins over the profile flow — expiry and tariff live only behind handshake + get_profile. The partial facts are kept only if the profile path itself publishes nothing. Co-Authored-By: Claude Fable 5 * fix(stalker): keep a literal c installation directory in candidate derivation Review round 25 (Codex P2): the /c landing-page rewrite ran after the endpoint file was stripped, so `/tenant/c/portal.php` collapsed to `/tenant` and the sibling probes went one level too high, rejecting a valid portal whose installation directory is literally named `c`. The rewrite now applies only when the pathname itself ends in `/c` (no endpoint file); pasted endpoints strip only the file part. Co-Authored-By: Claude Fable 5 * fix(stalker): route rejected post-repair main-info retries to the profile flow Review round 26 (Codex P2): a simple-to-full repair during fetchViaMainInfo makes executeStalkerRequest retry the same action against the repaired full portal, and installations that do not implement get_main_info answer 404 — the rejection escaped before the repaired-mode check, so the dialog failed instead of switching to get_profile. The rejection is captured and reaches the same check; without a mode change it is rethrown unchanged. Co-Authored-By: Claude Fable 5 * fix(stalker): full predicate for wrapped denials; record the removed store prop Review round 27 (Codex P2 + P1 docs): - The repair trigger applies the shared auth-failure predicate to the error MESSAGE too, so authentication's wrapped structured denials (Error('Profile error: Access denied.')) reach the repair instead of bypassing it and leaving a healthy sibling endpoint unprobed. - docs/architecture/stalker-store-api-baseline.md records makeStalkerRequest as removed, with the reason it gets no facade alias: it was production-dead and held a fourth private copy of the portal-mode branch that the shared predicate exists to prevent. Co-Authored-By: Claude Fable 5 * fix(stalker): complete auth predicate for wrapped error messages Review round 28 (Codex P2): the plain-text BODY matcher deliberately knows only the three middleware phrases, so passing an error message through it let authenticate()'s wrapped denials — Error('Profile error: Invalid token') / 'Auth failed' — bypass both the repair trigger and the session auth predicate. A dedicated isStalkerAuthFailureMessage() applies the wide phrase set to controlled error strings, while arbitrary portal bodies keep the narrow matcher that cannot false-positive on HTML pages. Co-Authored-By: Claude Fable 5 * fix(stalker): reject denied profiles during confirmation; document all repair triggers Review round 29 (Codex P2 + P1 docs): - Full-portal confirmation validates the get_profile envelope with the shared structured predicate: a handshake can hand out a token whose profile still answers {js:{error:"Invalid token"}}, and authenticate() inspects only msg/block_msg — discovery would have persisted an unusable endpoint and stopped before the healthy sibling. authenticate() now returns the raw profile response for that check. - The canonical lazy-repair contract lists the complete trigger set: the plain-text bodies AND their JSON envelopes, HTTP 404, HTTP 401/403, and terminal handshake/profile errors. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/stalker-endpoint-discovery.md | 11 + CLAUDE.md | 2 +- .../src/stalker-portal-discovery.e2e.ts | 257 +++++++ .../src/app/events/stalker.events.ts | 43 +- apps/stalker-mock-server/README.md | 32 +- apps/stalker-mock-server/src/main.ts | 8 + apps/web-e2e/src/stalker.e2e.ts | 71 +- apps/web/src/app/services/electron.service.ts | 18 +- apps/web/src/app/services/pwa.service.spec.ts | 42 ++ apps/web/src/app/services/pwa.service.ts | 42 +- docs/architecture/stalker-mock-server.md | 17 +- docs/architecture/stalker-portal.md | 108 ++- .../stalker-store-api-baseline.md | 11 +- .../stalker-portal-import.component.spec.ts | 79 ++- .../stalker-portal-import.component.ts | 180 +++-- .../lib/collection/stream-resolver.service.ts | 47 +- libs/portal/stalker/data-access/src/index.ts | 3 + .../lib/stalker-account-info.service.spec.ts | 194 +++++ .../src/lib/stalker-account-info.service.ts | 105 ++- .../src/lib/stalker-identity.utils.ts | 32 + .../src/lib/stalker-itv-cache.service.spec.ts | 4 +- .../src/lib/stalker-itv-cache.service.ts | 2 + .../stalker-portal-discovery.service.spec.ts | 346 +++++++++ .../lib/stalker-portal-discovery.service.ts | 252 +++++++ .../stalker-portal-discovery.utils.spec.ts | 385 ++++++++++ .../src/lib/stalker-portal-discovery.utils.ts | 324 +++++++++ .../lib/stalker-portal-repair.service.spec.ts | 671 ++++++++++++++++++ .../src/lib/stalker-portal-repair.service.ts | 490 +++++++++++++ .../src/lib/stalker-session.service.spec.ts | 330 ++++++++- .../src/lib/stalker-session.service.ts | 294 ++++++-- .../src/lib/stalker.store.compat.spec.ts | 1 - .../features/with-stalker-content.feature.ts | 3 + .../features/with-stalker-epg.feature.ts | 3 + .../features/with-stalker-player.feature.ts | 3 + .../with-stalker-portal.feature.spec.ts | 49 +- .../features/with-stalker-portal.feature.ts | 63 +- .../features/with-stalker-series.feature.ts | 9 +- ...h-stalker-snapshot-refresh.feature.spec.ts | 4 + .../with-stalker-snapshot-refresh.feature.ts | 5 +- .../stalker-player-request.utils.spec.ts | 67 ++ .../utils/stalker-player-request.utils.ts | 47 +- .../utils/stalker-request.utils.spec.ts | 139 ++++ .../lib/stores/utils/stalker-request.utils.ts | 82 ++- .../stalker-search.component.ts | 46 +- .../src/lib/playlists.service.spec.ts | 84 +++ libs/services/src/lib/playlists.service.ts | 71 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/stalker-portal-mode.util.spec.ts | 75 ++ .../src/lib/stalker-portal-mode.util.ts | 47 ++ 49 files changed, 4765 insertions(+), 434 deletions(-) create mode 100644 .changes/stalker-endpoint-discovery.md create mode 100644 apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-portal-mode.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-portal-mode.util.ts 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 ?? ''); +}