From d44948f2fa0c10ccc9e80c3d4560c138dd60ae6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:50:48 +0200 Subject: [PATCH 1/3] chore(deps): bump angularx-qrcode from 21.0.4 to 21.0.5 (#1351) Bumps [angularx-qrcode](https://github.com/Cordobo/angularx-qrcode) from 21.0.4 to 21.0.5. - [Release notes](https://github.com/Cordobo/angularx-qrcode/releases) - [Commits](https://github.com/Cordobo/angularx-qrcode/compare/v21.0.4...v21.0.5) --- updated-dependencies: - dependency-name: angularx-qrcode dependency-version: 21.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 126 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 7cfd3dd6b..82d3d67fb 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@ngrx/store-devtools": "21.1.1", "@ngx-pwa/local-storage": "21.0.0", "@yangkghjh/videojs-aspect-ratio-panel": "0.0.1", - "angularx-qrcode": "21.0.4", + "angularx-qrcode": "21.0.5", "artplayer": "5.4.0", "axios": "1.18.1", "better-sqlite3": "12.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 619e3b961..bd3eee3ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,8 +95,8 @@ importers: specifier: 0.0.1 version: 0.0.1 angularx-qrcode: - specifier: 21.0.4 - version: 21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) + specifier: 21.0.5 + version: 21.0.5(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) artplayer: specifier: 5.4.0 version: 5.4.0 @@ -5231,8 +5231,8 @@ packages: typescript: '*' typescript-eslint: ^8.0.0 - angularx-qrcode@21.0.4: - resolution: {integrity: sha512-GZFa/X/3rHx/4peA4zNROkK6UaYqxJX0dgkEMk7dCcoYNwJM8/UkOkEUfcx+Btjr7iT4UEhf9twWhFjFp58wfw==} + angularx-qrcode@21.0.5: + resolution: {integrity: sha512-9Kv/Mi5tKzAkBL2xhYZyPaJEoq+b24mIpUAntp16kIBiozVnpGI6DbigTPJiSDkV7EpYHdjBJNGj8igFAHEp6g==} peerDependencies: '@angular/common': ^21.0.0 '@angular/core': ^21.0.0 @@ -5490,6 +5490,10 @@ packages: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base-64@1.0.0: resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} @@ -5561,6 +5565,10 @@ packages: resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} engines: {node: 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -6000,6 +6008,7 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -7055,6 +7064,10 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -7460,6 +7473,10 @@ packages: resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -7547,8 +7564,8 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -8173,6 +8190,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonparse@1.3.1: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} @@ -8385,6 +8405,10 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} + make-fetch-happen@15.0.6: + resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==} + engines: {node: ^20.17.0 || >=22.9.0} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -8668,6 +8692,10 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -8701,6 +8729,10 @@ packages: resolution: {integrity: sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==} engines: {node: ^20.17.0 || >=22.9.0} + minipass-fetch@5.0.2: + resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} + engines: {node: ^20.17.0 || >=22.9.0} + minipass-flush@1.0.7: resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} engines: {node: '>= 8'} @@ -10314,6 +10346,7 @@ packages: shaka-player@5.2.2: resolution: {integrity: sha512-1Z5Sxqz7nEAtvp4zlZKAHQSEq6cUlw5CKAdUxqmuMNeZgu23090VXMcEDdiSLU5qtMn1skFOzq+KKCVX71bHqw==} + engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -10582,6 +10615,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -10890,6 +10927,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -13538,7 +13576,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3 - fs-extra: 11.3.5 + fs-extra: 11.4.0 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -14302,7 +14340,7 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -16529,7 +16567,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.4 + minimatch: 10.2.6 '@tybys/wasm-util@0.10.1': dependencies: @@ -17284,7 +17322,7 @@ snapshots: - chokidar - supports-color - angularx-qrcode@21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)): + angularx-qrcode@21.0.5(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)): dependencies: '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) @@ -17764,6 +17802,8 @@ snapshots: balanced-match@4.0.3: {} + balanced-match@4.0.4: {} + base-64@1.0.0: {} base64-js@1.5.1: {} @@ -17871,6 +17911,10 @@ snapshots: dependencies: balanced-match: 4.0.3 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -19595,6 +19639,13 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -20187,6 +20238,11 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + icss-utils@5.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -20253,7 +20309,7 @@ snapshots: ip-address@10.1.0: {} - ip-address@10.2.0: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -21221,6 +21277,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + jsonparse@1.3.1: {} keycode@2.2.1: {} @@ -21492,6 +21555,23 @@ snapshots: transitivePeerDependencies: - supports-color + make-fetch-happen@15.0.6: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 4.0.0 + '@npmcli/redact': 4.0.0 + cacache: 20.0.3 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + ssri: 13.0.1 + transitivePeerDependencies: + - supports-color + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -22018,6 +22098,10 @@ snapshots: dependencies: brace-expansion: 5.0.2 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.13 @@ -22060,6 +22144,14 @@ snapshots: optionalDependencies: encoding: 0.1.13 + minipass-fetch@5.0.2: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.3 + minipass-flush@1.0.7: dependencies: minipass: 3.3.6 @@ -24170,7 +24262,7 @@ snapshots: socks@2.8.9: dependencies: - ip-address: 10.2.0 + ip-address: 10.4.0 smart-buffer: 4.2.0 sorted-array-functions@1.3.0: {} @@ -24288,7 +24380,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string-width@7.2.0: dependencies: @@ -24345,6 +24437,10 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -24715,7 +24811,7 @@ snapshots: dependencies: '@tufjs/models': 4.1.0 debug: 4.4.3 - make-fetch-happen: 15.0.3 + make-fetch-happen: 15.0.6 transitivePeerDependencies: - supports-color @@ -25356,7 +25452,7 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi@9.0.2: dependencies: From e197409b107b71dea705203e7ffd77297d396943 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:36:56 +0200 Subject: [PATCH 2/3] fix(stalker): only mint a temporary link when the row asks for one (#1364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stalker): only mint a temporary link when the row asks for one `create_link` ran on every Stalker playback. The reference client — the portal's own `player.js`, mirrored by Kodi's pvr.stalker — mints a link only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`; otherwise it plays the static `cmd` that `get_all_channels` / `get_ordered_list` already returned. Neither flag was read anywhere in the codebase, so every channel paid a round trip and gained a failure point the reference client does not have. One helper now owns the decision (`resolveStalkerStaticPlaybackUrl`), used by `fetchStalkerPlaybackLink()` for ITV/VOD/radio, by the download path, and by `StreamResolverService` for Favorites/Recently Viewed. Its guards are deliberately wider than the flags alone and can only route a row back onto the `create_link` path: no row to read flags from, a relative or query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a loopback host. An episode always mints, since `series` selects it server-side. Radio joins the same decision, so a station the portal proxies now gets its link instead of playing a URL the portal never meant to serve. Temporary links live ~5 s, so the audit that came with this: favorites and recently-viewed persist the `cmd`, playback positions store ids, and the main-process context map stores headers keyed by origin+path — none replay a resolved URL. Downloads are the documented exception, and honouring the flags shrinks even that, since an unflagged movie now yields a permanent URL that survives retry. `forced_storage` and `play_token` stay unwired, with the reasoning recorded in the docs rather than left ambiguous. The mock's ITV/radio rows now carry both flags, and the new `static-channel-cmd` scenario (MAC 00:1A:79:00:00:0A) serves unflagged rows with a playable command so the e2e can assert that NO `create_link` request reaches the portal — verified to fail when the change is reverted, with a companion test proving the recorder sees a link when one is due. Co-Authored-By: Claude Opus 5 * fix(stalker): keep temporary-link flags across VOD normalization Codex P1 on #1364, and it is real. `buildStalkerSelectedVodItem()` narrows a raw portal row to an explicit whitelist, and the two flags were not on it. It feeds both `selectedItem()` — which the VOD playback path reads as `linkFlags` — and, through `createStalkerVodItem`, the download payload. So a flagged VOD row with an absolute HTTP `cmd` arrived looking unflagged and took the static path, playing the portal's non-final URL instead of minting a link. The direction of the failure is what makes it a P1: a dropped flag reads as "no temporary link needed", so the whitelist fails OPEN. Both flags now sit on `StalkerVodSource` / `StalkerSelectedVodItem` and on the whitelist, with the consequence spelled out at the normalizer so the next edit does not quietly undo it, and specs pinning all three normalizers plus a store-level test that a flagged VOD still mints. Also two things from re-reading my own diff: - The radio path called `resolveStalkerStaticPlaybackUrl` and then handed the same row to `fetchStalkerPlaybackLink`, which runs that exact check again. Two copies of one decision is the divergence this PR exists to remove, so the outer call and its now-unreachable guard are gone. - `portal-catalog-facade.ts` spells the flag shape out instead of importing `StalkerLinkFlagSource`; it now says why (`type:util`/`domain:portal-shared` may not depend on `type:data-access`/`domain:stalker`), so the obvious "reuse the type" cleanup does not get made and break the boundary lint. Co-Authored-By: Claude Opus 5 * fix(stalker): authenticate before serving a static collection stream Second Codex P1 on #1364, and a regression this PR introduced. `create_link` was also the request that warmed the portal session. Tokens live in memory only (`StalkerSessionService.tokenCache` is a plain Map), and the collection header builder reads the raw `getCachedToken()`. So a cold start from global Favorites or Recently Viewed — the portal never opened this session — took the static path, found no token, and handed a same-host gated stream headers with no `Authorization`: a 403 on exactly the streams the header contract exists for. The same raw accessor cannot tell a token negotiated for a pre-edit identity from a current one. `StreamResolverService` now calls `ensureToken()` before building a static playback. It is the right primitive: handshake + `get_profile` with no link minted, identity fingerprint validated, concurrent callers deduped, and an immediate null for simple portals — and calling it keeps this change out of `stalker-session.service.ts`, which PR 6 (#1354) is splitting. Best-effort by design: a static URL may point at a CDN that needs no credentials, so a failed handshake degrades to the token-less header set instead of costing the user their playback. Both halves are pinned by tests, and removing the call makes the cold-start test fail. The portal routes need no equivalent and do not get one: an item cannot be selected before its catalog has loaded, and every catalog load authenticates. That reasoning is now written down rather than assumed. Co-Authored-By: Claude Opus 5 * fix(stalker): warm the session at the choke point; keep downloads authenticated Two more Codex findings on #1364, and the first one shows my previous commit message reasoned too broadly. P1 — I claimed the portal routes are "structurally warm" because an item cannot be selected before its catalog loads. That is true of the routed portal views, but not of the global collection detail, which calls `setCurrentPlaylist()` and `setSelectedItem()` straight from a persisted row with no catalog load in between and then goes through the STORE playback path. A VOD opened from Favorites on a cold start therefore still played a same-host gated stream with no Bearer token. Rather than extend the per-route argument, the warm-up moved to the one place every static return passes through: `fetchStalkerPlaybackLink()` now calls the session before short-circuiting, covering ITV, VOD, radio and downloads at once. `StreamResolverService` keeps its own call — its static branch does not go through that function — but both now share a single primitive, `ensureStalkerSession()` in `stalker-request.utils.ts`, so the two routes cannot drift on when a session is required. Still best-effort, still outside `stalker-session.service.ts` (PR 6 territory). P2 — downloads cannot use that escape hatch at all: the main-process stored header allowlist is User-Agent/Origin/Referer only, no Cookie or Authorization, so a static same-host URL 401s where a minted one worked. `startStalkerVodDownload` now classifies the candidate with the shared `isStalkerStreamCredentialSafe()` and withholds the row — forcing `create_link` — for anything portal-owned. A CDN-hosted movie keeps the permanent URL that survives retry; a portal-hosted one keeps the minted URL that carries its own token. Both fixes mutation-checked: each reverted change fails exactly one test. Docs corrected, including the overreaching "structurally warm" claim. Co-Authored-By: Claude Opus 5 * docs(stalker): record the cached-token revalidation trade-off Codex flagged that the static path no longer self-heals a retired token, since `ensureToken` returns a same-identity cache entry without a network call — whereas `create_link` used to refresh it through `makeAuthenticatedRequest`'s auth-failure retry. The mechanism it posits does not exist on stock Stalker: per the 4.9.35 reference, handshake tokens have no TTL, and not sending the watchdog does not invalidate auth (it only clears the admin panel's "online" flag). The real residual vector is another device calling `get_profile` on the same MAC, which is common enough on shared subscriptions to be worth naming. Revalidating on every static playback would cost exactly the round trip this change removes, so it is deliberately not done. Recorded as a known trade-off with its mitigation (a running watchdog still self-heals within a ping cycle) and handed to PR 6, where a refresh on an OBSERVED playback authorization failure belongs. Co-Authored-By: Claude Opus 5 * docs(stalker): tighten the token-revalidation trade-off wording Greptile review feedback: the watchdog mitigation was the most important part of that paragraph and sat behind the caveat. It now follows the MAC-sharing vector directly, and the paragraph ends by naming what is actually left uncovered — a same-host static stream played while no watchdog is up — so a future reader can size the residual without re-deriving it. Co-Authored-By: Claude Opus 5 * fix(stalker): prefer the live playlist row over a stale favorite snapshot Codex P1 on #1364, and mine. `resolveStalker` reads its portal coordinates as `item.stalkerPortalUrl ?? playlist?.portalUrl` — item first. The create_link branch quietly corrected for that afterwards by re-reading `applyOverride(playlist).portalUrl`, so the row won wherever it existed, which is what the comment right above it already promised: "when the row exists it wins over the item's snapshot of the portal URL (a repaired endpoint must beat a stale favorite)". The static branch I added returns before that correction, so it shipped the stale snapshot. Consequences after a playlist edit: a same-host static URL matching the OLD host gets the newly negotiated token and identity headers sent to the previous portal, and a MAC-only edit pairs the new token with the old MAC cookie — precisely the pairing `stalkerIdentityFingerprint` exists to prevent. Both branches now derive the coordinates once, row-first with the repair override applied, and fall back to the item's snapshot only for a playlist that no longer exists — which is the role `buildStalkerPlayback` already documents for it. Mutation-checked: restoring item-first precedence fails the new test alone. Also documents a local-only e2e hazard found while re-running the suite: `mode: 'serial'` orders tests within one project, but chromium/firefox/webkit run the file concurrently against the same mock server, so one project's beforeEach reset can drop a session another is mid-test on — which is what a lone auth-spec failure that passes on rerun actually is. CI never sees it; the Web E2E job runs --project=chromium alone, and that command is clean (22/22). Co-Authored-By: Claude Opus 5 * fix(stalker): warm the session against the repaired portal configuration Found while auditing my own static branch against the create_link path rather than waiting for the next review round. `executeStalkerRequest` applies the lazy-repair override on its first line, so the create_link path always talks to the configuration a completed repair proved good. The session warm-up I added did not: it handed `ensureToken` the caller's pre-repair row, so a portal whose endpoint or mode had been repaired would handshake against the configuration the repair had already rejected — stranding the session precisely on the portals repair exists to rescue. The override now happens inside `ensureStalkerSession`, mirroring `executeStalkerRequest`'s first line, so every caller inherits the rule instead of each having to remember it. Mutation-checked: dropping the override fails the new test alone. Co-Authored-By: Claude Opus 5 * fix(stalker): fall back to create_link when a portal-owned static url has no session Codex P1 on #1364. `create_link` was also the request that could FAIL, and a failure is what triggers the lazy portal repair. A playlist still misclassified as token-free, or pointing at an unrepaired endpoint, used to self-heal on that failure and then play; the static path issues no request, so nothing fires and the stream just 401s. Its suggested remedy — routing a skipped warm-up through `repairPortal()` — cannot be taken literally: a skipped warm-up is the NORMAL case for the many legitimately token-free reseller panels, and probing each of them on every playback would cost far more than the round trip this PR removes. What is decidable without a request is whether we are about to serve a stream we already know will fail. `ensureStalkerSession` now reports whether the session can serve credentialed playback — true for a portal needing no token and for one holding a usable token, false for a full portal left without one — and both static call sites act on it: - foreign-host URL: served regardless, it never needed the session; - portal-owned URL with a usable session: served, as before; - portal-owned URL with no usable session: falls back to `create_link`, which mints a URL carrying its own token AND re-enters the only path that can observe a failure and repair. That covers the unrepaired-endpoint half exactly. The misclassified-as-simple half stays open by construction — no request means no evidence, and "simple portal" is indistinguishable from "misclassified" without one. It belongs with the other reactive-repair work already handed to PR 6: refresh and repair on an OBSERVED playback authorization failure. Co-Authored-By: Claude Opus 5 * fix(stalker): require flag evidence before trusting a row as unflagged Two Codex findings on #1364. P1 — legacy persisted snapshots. Favorites and Recently Viewed rows saved before this change went through `buildStalkerSelectedVodItem`'s whitelist, which dropped both flags, and `buildStalkerFavoritePayload` spreads that whitelisted object. So a legacy row is flagless because WE stripped it, not because the portal said no — and the helper was reading it as "explicitly unflagged". With an absolute HTTP `cmd` from a load-balanced portal that meant playing a non-final URL. There is no migration or provenance marker for those rows. A stock portal returns both flags on every row, so their PRESENCE is itself the provenance signal, and it is the only one available without a refetch. `resolveStalkerStaticPlaybackUrl` now requires at least one flag key to be present; absence reads as "no evidence" and routes back to `create_link`, which is the pre-PR behaviour. This costs the optimization on panels that omit the flags entirely — the honest price for not being able to tell them apart from our own stripped rows. Radio is the one documented exception. It has always played a directly usable command without `create_link`, so a flagless radio row keeps that rather than newly minting — a portal whose radio `create_link` never worked would otherwise lose playback it has today. ITV and VOD have no such history and stay conservative. P2 — loopback range. IPv4 reserves all of `127.0.0.0/8`, so `127.0.0.2` was being handed to the player as a real address. Classified by range now, with a test that `127.0.0.1.cdn.example` is still treated as the ordinary hostname it is. Co-Authored-By: Claude Opus 5 * fix(stalker): classify every portal-local IPv6 placeholder Codex P2 on #1364, same class as the 127.0.0.0/8 one. `http://[::]/ch/1234_` and the IPv4-mapped loopback forms slipped past the exact-name set and would have been handed to the player as real addresses. Checked how `URL` actually normalizes these rather than guessing at the spelling a portal might use: brackets are kept, `[0:0:0:0:0:0:0:1]` collapses to `[::1]`, and an IPv4-mapped address is rewritten to hex — `[::ffff:127.0.0.1]` arrives as `[::ffff:7f00:1]`. The guard now strips the brackets, matches `::1` and `::`, and decodes the mapped form by its high byte, so the whole of the mapped 127.0.0.0/8 range is covered along with the mapped unspecified address. The dotted tail is still accepted for any engine that leaves it alone. Routable hosts are unaffected, pinned by tests for `[2001:db8::1]` and `[::ffff:203.0.113.7]`. Mutation-checked: dropping `::` and the mapped-IPv4 decode fails five tests and nothing else. Co-Authored-By: Claude Opus 5 * fix(stalker): normalize hostname and scheme spelling before the static verdict Two Codex P2s on #1364, both about trusting how a portal spells things. `http://localhost./ch/1234_` — a trailing dot is the DNS root and resolves identically, but `URL` keeps it for names while dropping it for IP literals (`127.0.0.1.` arrives bare, `localhost.` does not). The exact-name check read that as a remote host and would have pointed the player at its own loopback. Stripped before classifying. `HTTP://cdn.example/a.ts` — RFC 3986 makes the scheme case-insensitive. The case-sensitive tests failed SAFE, minting a link instead, but that defeats the contract for a portal that spells it this way, and one whose `create_link` cannot resolve an already-playable row would break. There were five such tests, and only one was on the new static path: the other three live in `resolveStalkerPlaybackUrl`, the create_link RESPONSE resolver, where `ffrt3 HTTP://…` failed to split its solution prefix and a query-only reply was appended to the portal base instead of to the command. That is pre-existing, but it is the same bug in the same shared normalizer, and fixing only the half this PR introduced would leave exactly the divergence this PR keeps removing. All five now go through one `hasHttpScheme()`. The response resolver had only indirect coverage, so it gains a direct spec alongside the static-path tests. Mutation-checked: reverting the dot strip and the case-insensitive scheme fails ten tests and nothing else. Also carries a docblock fix noticed on a read-through: the guard list still pointed at `PORTAL_LOCAL_HOSTNAMES` after the logic moved into `isPortalLocalHostname`, which now covers considerably more than that set. Co-Authored-By: Claude Opus 5 * fix(stalker): normalize DNS root dots in the shared credential classifier Codex P2 on #1364, extending the `localhost.` fix into `isStalkerStreamCredentialSafe()`. It compared hostnames literally, so a portal on `portal.example` serving `https://portal.example./movie.mkv` classified its own stream as third-party. Wider than the download guard it was reported against: this predicate is the single rule BOTH the renderer playback-header builder and the Electron main-process fallback use to decide whether a stream may carry the mac cookie and Bearer token. A portal-owned stream spelled with the root dot was getting the credential-free profile and would 401 — pre-existing, and exactly the "only VLC works" class this contract exists to prevent. My PR added two new dependencies on the same predicate (the download static guard and the portal-owned fallback), which is how it surfaced. Both sides are normalized, so it stays symmetric, and it can only widen toward "same host" — never toward handing credentials to a different one. A test pins that `evil.portal.example.` is still rejected. Also carries the authority guard found by probing the same class myself rather than waiting for it to be reported: `http:///ch/1` has no authority and `URL` quietly reinterprets the first path segment as the host, so a malformed command reached the player as a nonsense address instead of going to the portal. `isPlayableHttpUrl()` now requires a non-empty authority. The other exotic spellings I probed were already covered — `URL` canonicalizes `127.1`, `2130706433` and `0x7f000001` to `127.0.0.1`, uppercases and expanded IPv6 normalize too. Co-Authored-By: Claude Opus 5 * perf(stalker): classify the static url before authenticating Codex P2 on #1364. Both static call sites awaited the session warm-up and only then asked whether the stream needed portal credentials at all — so a movie or channel on a foreign CDN paid for a handshake whose result was immediately discarded. That is not free: non-`create_link` requests carry a 15 s timeout (`stalker.events.ts`), so a portal that is slow or offline stalled playback of a stream the CDN would have served instantly. Cold Favorites/Recently Viewed starts are exactly where this bites, since that is where the session is not warm already. Classification now runs first. Foreign host returns immediately, portal-owned still warms and still falls back to `create_link` without a usable session. Behaviour is otherwise unchanged; only the order and the wasted wait are gone. Two tests moved with it: the foreign-host case now asserts the portal is not contacted at all rather than merely not asked for a link, and the repaired-endpoint case had been written against a foreign-host command, which under the new ordering correctly never reaches the handshake it was meant to be testing — it uses a portal-owned command now. Mutation-checked: restoring warm-before-classify fails the foreign-host test alone. Co-Authored-By: Claude Opus 5 * test(stalker): repoint two handshake tests at the path they claim to cover Self-audit, prompted by the previous round: the reorder exposed one test that was asserting through a path it no longer reached, so I checked the rest of that class rather than assume it was the only one. Two more had the same defect, both mine. `still returns the static url when the handshake fails` (both specs) mocked `ensureToken` to reject, but used a FOREIGN-host command. Now that classification runs before authentication, that command returns before the handshake is ever attempted — the rejection was never exercised and the test passed on the early return instead of the mechanism in its name. Worse, the foreign case is already covered by the test added alongside the reorder, so these were asserting nothing new. Both now use a portal-owned command, which is what actually reaches the handshake, and assert what a throw really produces: `ensureStalkerSession` swallows it, the verdict is false, and the row falls back to `create_link` rather than being served as a known 401. Each asserts `ensureToken` was in fact called, so neither can silently drift back into testing an early return. Docs corrected with them: the "best-effort degrades to the token-less header set" wording described behaviour the reorder removed. A foreign-host URL is now returned before any handshake, and a failed one routes to `create_link`. Co-Authored-By: Claude Opus 5 * test(stalker): make the simple-portal skip test prove portal mode Fourth test found passing through the wrong exit, from auditing all ten in the block rather than waiting to trip over another one. `skips the handshake for a simple portal` used a foreign-host command, so the classification step returned before the warm-up was reached. `ensureToken` was indeed not called — but because the host was foreign, not because the portal was simple, and the assertion could not tell those apart. The command is now portal-owned, so the skip can only come from the mode, and the test also pins the returned URL and that no request was made. Mutation-checked properly this time: removing the simple-portal early return from `ensureStalkerSession` now fails this test. Under the old command it would not have. Also records the pattern where the next person will meet it. The decision chain has several exits — no flag evidence, unresolvable command, `series` set, foreign host, unusable session — and more than one can satisfy the same assertion, so a foreign-host command silently stands in for "simple portal" or "handshake failed". Mutation testing does not catch that class: it proves a test is coupled to its target, not that it reached the mechanism it names. Co-Authored-By: Claude Opus 5 * fix(stalker): key the radio fallback on flag evidence, not snapshot presence Codex P2 on #1364, and a divergence I introduced myself. `withStalkerPlayer`'s radio branch checks `hasStalkerLinkFlagEvidence(item)` before synthesizing the zero flags. `StreamResolverService` used `??`, which only falls back when the snapshot is absent entirely. A radio Favorite or Recent row persisted before the flags were carried HAS a snapshot — the old whitelist just stripped the flags out of it — so the `??` selected that flagless object, the helper found no evidence, and the collection route began minting for exactly the rows that used to play directly. That breaks portals whose radio `create_link` is unsupported, which is the case the radio exception exists for. The two paths now apply the identical rule. The divergence came from fixing them in different rounds and is precisely the class this PR keeps closing, so the comment on each side now points at the other. The existing radio test carries no `stalkerItem` at all, so it exercises the missing-snapshot arm and stayed green throughout — the same "passes through a different exit" pattern documented in the section above. The new test supplies a present-but-flagless snapshot. Mutation-checked: restoring the presence check fails it alone. Co-Authored-By: Claude Opus 5 * fix(stalker): treat every reserved localhost name as portal-local Codex P2 on #1364, the fourth in this class. RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost` for the loopback interface, and resolvers honour it — so `http://stream.localhost/ch/1234_` reached the player's own machine instead of being sent to the portal to resolve. Closed the class rather than adding one more name: the suffix is matched, and `localhost.localdomain` goes in with it as the conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems. Together with the earlier rounds the predicate now covers `localhost` and `*.localhost`, `localhost.localdomain`, `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, the IPv4-mapped forms `URL` rewrites to hex, and a terminal DNS root dot on any of them. Only the suffix is reserved, so the guard must not over-match: tests pin that `localhost.cdn.example` and `notlocalhost` remain ordinary routable names and keep playing statically. Mutation-checked: dropping the suffix rule and the localdomain alias fails four tests and nothing else. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .changes/stalker-static-channel-links.md | 10 + CLAUDE.md | 17 + .../stalker-playback-context.service.spec.ts | 43 +++ apps/stalker-mock-server/README.md | 8 + .../src/app/data-generator.ts | 40 ++- apps/stalker-mock-server/src/app/scenarios.ts | 20 ++ apps/web-e2e/src/stalker.e2e.ts | 74 +++++ docs/architecture/stalker-mock-server.md | 13 + docs/architecture/stalker-portal.md | 225 +++++++++++++- .../stream-resolver.service.spec.ts | 292 ++++++++++++++++++ .../lib/collection/stream-resolver.service.ts | 119 ++++++- .../util/src/lib/portal-catalog-facade.ts | 14 +- .../models/stalker-favorite-item.interface.ts | 10 + .../src/lib/stalker-vod.utils.spec.ts | 50 +++ .../data-access/src/lib/stalker-vod.utils.ts | 12 + .../with-stalker-player.feature.spec.ts | 213 +++++++++++-- .../features/with-stalker-player.feature.ts | 64 ++-- .../data-access/src/lib/stores/utils/index.ts | 2 + .../stalker-link-semantics.utils.spec.ts | 270 ++++++++++++++++ .../utils/stalker-link-semantics.utils.ts | 197 ++++++++++++ .../stalker-playback-command.utils.spec.ts | 116 +++++++ .../utils/stalker-playback-command.utils.ts | 125 ++++++++ .../stalker-player-request.utils.spec.ts | 286 ++++++++++++++++- .../utils/stalker-player-request.utils.ts | 158 ++++------ .../lib/stores/utils/stalker-request.utils.ts | 58 ++++ .../stalker-catalog-detail.component.ts | 10 +- .../stalker-vod-download.spec.ts | 102 ++++++ .../stalker-vod-download.ts | 31 +- .../src/lib/stalker-catalog-facade.service.ts | 13 +- .../lib/stalker-stream-profile.util.spec.ts | 27 ++ .../src/lib/stalker-stream-profile.util.ts | 15 +- 31 files changed, 2459 insertions(+), 175 deletions(-) create mode 100644 .changes/stalker-static-channel-links.md create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts diff --git a/.changes/stalker-static-channel-links.md b/.changes/stalker-static-channel-links.md new file mode 100644 index 000000000..7b38e1328 --- /dev/null +++ b/.changes/stalker-static-channel-links.md @@ -0,0 +1,10 @@ +--- +type: fix +area: stalker +--- + +Stalker channels that the portal serves directly now start without an extra +link request to the portal, so they open faster and no longer fail when that +request does. Channels the portal does proxy still get their temporary link, +and radio stations the portal proxies now get one too instead of playing a URL +the portal never meant to serve. diff --git a/CLAUDE.md b/CLAUDE.md index cce81de06..a98df166d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -698,6 +698,23 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Xtream Codes API (`username`, `password`, `serverUrl`) - Stalker portal (`macAddress`, `url`) +**Stalker playback links**: `create_link` runs only when the catalog row sets +`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays +directly. One helper decides +(`resolveStalkerStaticPlaybackUrl` in +`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied +by `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by +`StreamResolverService` for Favorites/Recently Viewed. It falls back to +`create_link` for anything it cannot resolve alone: no row to read flags from, +a relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, +or a loopback host; an episode (`series` set) always mints, since the parameter +selects the episode server-side. Temporary links live ~5 s, so no resolved URL +is persisted or replayed — favorites and recently-viewed store the `cmd`, +playback positions store ids, and the main-process context map stores headers +keyed by origin+path. Downloads are the one exception (they must retry a URL). +`forced_storage`/`play_token` are deliberately unwired. Contract: +`docs/architecture/stalker-portal.md` ("Playback Link Resolution"). + **Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed on the command line, opened through a file association, or delivered by macOS' `open-file` event is normalized to an absolute path in the main process diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts index 00f3afb4a..588f76daa 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts @@ -106,4 +106,47 @@ describe('stalker playback context', () => { expect(headers).not.toHaveProperty('Cookie'); expect(headers).not.toHaveProperty('Authorization'); }); + + describe('temporary-link retention', () => { + // A Stalker temporary link expires after ~5 s. This map is a + // header lookup keyed BY the stream URL the player is already + // opening — it must never become a place a stale URL can be read + // back out of and replayed. + it('stores headers only, never the URL it was keyed with', () => { + const streamUrl = + 'http://tmp-link.example.test/ch/1?token=SECRET-TMP'; + rememberStalkerPlaybackContext({ + streamUrl, + portalUrl: + 'http://tmp-link.example.test/stalker_portal/server/load.php', + macAddress, + token: 'token-1', + }); + + const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {}; + + expect(Object.keys(headers).length).toBeGreaterThan(0); + expect(JSON.stringify(headers)).not.toContain('SECRET-TMP'); + expect(JSON.stringify(headers)).not.toContain('/ch/1'); + }); + + it('matches a re-minted link that differs only in its expiring query', () => { + // Consecutive create_link calls return the same path with a fresh + // token; the lookup keys on origin+path so the second link still + // finds its portal headers instead of playing bare. + const firstLink = 'http://tmp-link2.example.test/ch/7?token=first'; + const secondLink = 'http://tmp-link2.example.test/ch/7?token=second'; + rememberStalkerPlaybackContext({ + streamUrl: firstLink, + portalUrl: + 'http://tmp-link2.example.test/stalker_portal/server/load.php', + macAddress, + token: 'token-1', + }); + + expect( + getStalkerPlaybackContextHeaders(secondLink)?.['Cookie'] + ).toContain(`mac=${macAddress}`); + }); + }); }); diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index cc4138ba6..fa72541fc 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -83,8 +83,16 @@ actually get wrong: | `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | | `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing | | `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work | +| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials | +| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | +Every generated ITV channel and radio station carries the two temporary-link +flags a real portal sends. Outside the `static-channel-cmd` scenario they are +`use_http_tmp_link: '1'`, which is the honest annotation of their +`ffrt4://…` commands: those are portal-internal pseudo-URLs that only +`create_link` can resolve. + ## Configuration | Environment Variable | Default | Description | diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index dbc8cbaf5..29d8a73b0 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -17,7 +17,17 @@ export interface RawCategory { censored?: string; } -export interface RawChannel { +/** + * The two flags that tell a client whether the row needs `create_link`. Real + * portals send them on every ITV/radio row as `'0'`/`'1'` strings; a client + * that honours them plays the static `cmd` when both are `'0'`. + */ +export interface RawTemporaryLinkFlags { + use_http_tmp_link: '0' | '1'; + use_load_balancing: '0' | '1'; +} + +export interface RawChannel extends RawTemporaryLinkFlags { id: string; name: string; o_name: string; @@ -28,7 +38,7 @@ export interface RawChannel { xmltv_id: string; } -export interface RawRadioStation { +export interface RawRadioStation extends RawTemporaryLinkFlags { id: string; name: string; o_name: string; @@ -238,7 +248,12 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData }); let channelIndex = 0; for (const cat of data.itvCategories) { - const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex); + const channels = generateChannels( + cat.id, + config.itemsPerCategory, + channelIndex, + config.staticChannelCmd === true + ); data.channels.set(cat.id, channels); for (const ch of channels) { data.epg.set(ch.id, generateEpg(ch.name)); @@ -377,7 +392,12 @@ function generateCategories( // Channel generators // --------------------------------------------------------------------------- -function generateChannels(categoryId: string, count: number, startIndex: number): RawChannel[] { +function generateChannels( + categoryId: string, + count: number, + startIndex: number, + staticCmd: boolean +): RawChannel[] { return Array.from({ length: count }, (_, i) => { const globalIndex = startIndex + i; const id = String(10000 + globalIndex); @@ -386,11 +406,19 @@ function generateChannels(categoryId: string, count: number, startIndex: number) id, name, o_name: name, - cmd: `ffrt4://ch/live/${id}/index.m3u8`, + // A static row carries a playable address with the usual + // ` ` prefix; the default `ffrt4://` command is a + // portal-internal pseudo-URL that only `create_link` can resolve, + // which is exactly what `use_http_tmp_link` announces. + cmd: staticCmd + ? `ffrt3 ${pickStream(globalIndex)}` + : `ffrt4://ch/live/${id}/index.m3u8`, logo: logoUrl(`ch-${id}`), category_id: categoryId, tv_genre_id: categoryId, xmltv_id: `channel-${id}.example`, + use_http_tmp_link: staticCmd ? '0' : '1', + use_load_balancing: '0', }; }); } @@ -414,6 +442,8 @@ function generateRadioStations( tv_genre_id: categoryId, number: String(globalIndex + 1), radio: true, + use_http_tmp_link: '1', + use_load_balancing: '0', }; }); } diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index fe5bff591..74a8bd8e3 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -32,6 +32,12 @@ export interface ScenarioConfig { * the portal credentials (the "only VLC works" cluster). */ gatedStream?: true; + /** + * Generate ITV channels that need NO temporary link: a directly playable + * `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'`. A client + * honouring the flags must play those without calling `create_link`. + */ + staticChannelCmd?: true; } /** @@ -157,6 +163,20 @@ export const SCENARIOS: Record = { embeddedSeriesFraction: 0, gatedStream: true, }, + '00:1a:79:00:00:0a': { + name: 'static-channel-cmd', + description: + 'ITV rows that need no temporary link — playable cmd with ' + + 'use_http_tmp_link/use_load_balancing both 0', + seed: 1010, + categoryCount: { itv: 2, radio: 1, vod: 1, series: 1 }, + itemsPerCategory: 5, + seasonsPerSeries: 1, + episodesPerSeason: 3, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + staticChannelCmd: true, + }, }; /** diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 7b810053d..07597d02b 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -38,6 +38,16 @@ import { * * Giving each test its own MAC instead would mean inventing a scenario per * test; serializing one file is the cheaper trade. + * + * ACROSS BROWSER PROJECTS this does NOT hold, and it is a local-run hazard + * only. `mode: 'serial'` orders tests within one project; chromium, firefox + * and webkit still run the file concurrently against the SAME mock server, so + * one project's `beforeEach` reset can drop a session another project is + * mid-test on — the auth specs below are the ones that notice, failing as if + * the portal had dropped them. CI never sees it: the Web E2E job runs + * `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local + * all-project run shows a lone auth failure that passes on rerun, this is why; + * `--project=chromium` reproduces CI exactly. */ test.describe.configure({ mode: 'serial' }); @@ -65,6 +75,13 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05'; /** Legacy pagination MAC — portal without get_all_channels support */ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; +/** + * Static-cmd MAC — ITV rows carrying a directly playable `cmd` with + * `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that + * expects no `create_link` call at all. + */ +const STATIC_CMD_MAC = '00:1A:79:00:00:0A'; + /** * Dedicated MACs for the full-portal authentication tests. Mock state is keyed * by MAC, so keeping these distinct from the content scenarios above means an @@ -120,6 +137,7 @@ const OWNED_MACS = [ MINIMAL_MAC, EMBEDDED_SERIES_MAC, LEGACY_PAGINATION_MAC, + STATIC_CMD_MAC, AUTH_FLOW_MAC, AUTH_REAUTH_MAC, AUTH_REJECTED_MAC, @@ -681,6 +699,62 @@ test('@stalker create_link returns a playable stream URL', async ({ expect(streamUrl).toMatch(/\.m3u8$/); }); +/** + * Play the first channel of the first ITV category and report every + * `create_link` request the page made while doing so. + */ +async function playFirstItvChannel(page: Page): Promise { + const createLinkRequests: string[] = []; + page.on('request', (request) => { + if (request.url().includes('action=create_link')) { + createLinkRequests.push(request.url()); + } + }); + + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + await channels.first().click(); + await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); + await expect(page.locator('app-web-player-view')).toBeVisible({ + timeout: 20_000, + }); + + return createLinkRequests; +} + +test('@stalker ITV plays an unflagged channel without minting a link', async ({ + page, +}) => { + // The reference client only calls create_link when the row sets + // use_http_tmp_link or use_load_balancing; this portal sets neither, so + // the static cmd must reach the player untouched. The companion test + // below proves the recorder does see a create_link when one is due. + await addStalkerPortal(page, { + name: 'Static Cmd Portal', + mac: STATIC_CMD_MAC, + }); + + expect(await playFirstItvChannel(page)).toEqual([]); +}); + +test('@stalker ITV mints a link for a channel that asks for one', async ({ + page, +}) => { + await addStalkerPortal(page, { name: 'Tmp Link Portal' }); + + const createLinkRequests = await playFirstItvChannel(page); + + expect(createLinkRequests.length).toBeGreaterThan(0); + expect(createLinkRequests[0]).toContain('type=itv'); +}); + test('@stalker mock server returns radio categories and stations', async ({ request, }) => { diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index ae4bcf58c..2c193f90d 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -297,6 +297,9 @@ interface ScenarioConfig { supportsGetAllChannels?: boolean; // default true; false mimics legacy portals // without the ITV get_all_channels action marketingFixture?: true; // replace generated VOD with shared posters + requiresLogin?: true; // get_profile answers status 2 until do_auth + gatedStream?: true; // create_link returns a credential-gated URL + staticChannelCmd?: true; // ITV rows need no temporary link } ``` @@ -317,6 +320,16 @@ forwarded host/protocol) as `/assets/marketing/poster/.png`. `main.ts` serves the committed PNG directory directly, so the Xtream server does not need to run. +Every generated ITV channel and radio station carries `use_http_tmp_link` and +`use_load_balancing`, the flags a real portal uses to tell a client whether the +row needs `create_link`. They are `'1'`/`'0'` for the default generators — +honest, because those rows carry `ffrt4://…` pseudo-URLs. The +`static-channel-cmd` scenario (`00:1A:79:00:00:0A`) sets `staticChannelCmd`, +which gives ITV rows both flags at `'0'` and a real +`ffrt3 https://…m3u8` command, so `apps/web-e2e/src/stalker.e2e.ts` can assert +that no `create_link` request reaches the portal. See +`docs/architecture/stalker-portal.md`, "Playback Link Resolution". + ### Adding a New Scenario 1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index c53a1c0d5..bbc78ac74 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -305,6 +305,226 @@ the cross-portal collection resolver (`StreamResolverService`) use resolve relative (`/media/...`) or query-only (`?token=...`) `create_link` replies against the portal base URL. +## Playback Link Resolution + +### When `create_link` is called + +A Stalker catalog row decides for itself whether it needs a temporary link. +The portal's own `player.js` — mirrored by Kodi's `pvr.stalker` — calls +`create_link` only when the row sets `use_http_tmp_link` (the portal proxies +the stream through a per-session URL) or `use_load_balancing` (the portal +picks a storage server per request). Every other row plays the static `cmd` +that `get_all_channels` / `get_ordered_list` already returned. Until PR 8 the +app called `create_link` unconditionally, so every playback paid a round trip +and gained a failure point that the reference client does not have. + +One helper owns the decision: +`resolveStalkerStaticPlaybackUrl(row, cmd)` in +`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts`. +It returns the playable URL when the static path applies and `null` when the +portal has to resolve the command. `null` is deliberately wider than the flag +check alone; the extra guards can only push a row back onto the `create_link` +path, so they cannot regress a portal that works today: + +| Input | Verdict | Why | +| --- | --- | --- | +| Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. | +| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. | +| A row carrying neither flag KEY | `create_link` | Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by `buildStalkerSelectedVodItem`'s whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. **Radio is the documented exception**: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting. | +| Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. | +| Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. | +| Portal-local host — `localhost` and any `*.localhost` name (RFC 6761 §6.3 reserves the whole suffix for loopback), `localhost.localdomain`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, and the IPv4-mapped forms `URL` normalizes to hex (`::ffff:7f00:1`); a terminal DNS root dot is stripped first | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. | +| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. | + +`fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and +short-circuits before the request. It never applies it when `series` is set: +an episode is selected server-side by that parameter, so the parent row's +static `cmd` addresses the series, not the episode. + +**The flags must survive normalization.** `buildStalkerSelectedVodItem()` +(`stalker-vod.utils.ts`) narrows a raw portal row to a whitelist, so a field it +does not name is silently dropped — and it feeds both VOD playback +(`selectedItem()`) and the download payload (`createStalkerVodItem` passes its +`data` straight through). Losing a flag there fails **open**: the row reads as +"no temporary link needed" and the static path plays the portal's non-final +URL. Both flags are therefore on the whitelist, on `StalkerVodSource` / +`StalkerSelectedVodItem`, and pinned by tests in `stalker-vod.utils.spec.ts`. +Any new normalizer between a portal response and a playback call has the same +obligation. + +Callers pass the row they resolved the `cmd` from: + +- ITV and radio — the channel/station row (`withStalkerPlayer`); radio + previously bypassed `create_link` for any directly playable command, which + meant a proxied station played a URL the portal never intended to serve. +- VOD and series — `selectedItem()`. +- Downloads — the movie payload, through the optional `linkFlags` argument on + `fetchLinkToPlay()`, but only for a credential-free URL (see below). +- Favorites and Recently Viewed — `StreamResolverService.resolveStalker()` + reads the flags off the persisted raw row (`UnifiedCollectionItem.stalkerItem`). + Radio keeps its long-standing "directly usable command plays as-is" + behaviour, keyed off flag EVIDENCE rather than snapshot presence — a radio + row persisted before the flags were carried has a snapshot that simply lacks + them, and testing presence would skip the fallback for exactly those rows. + `withStalkerPlayer`'s radio branch applies the identical rule; the two must + not drift. + +### The static path still needs the session + +`create_link` was also the request that warmed the portal session, and tokens +live in memory only (`StalkerSessionService.tokenCache`). Skipping it therefore +has to account for streams that are still gated on the Bearer token — and +"which routes are already warm" is not a question worth answering per route: +the global collection detail sets the playlist and the selected item straight +from a persisted row, with no catalog load in between, so a VOD opened from +Favorites reaches the store's playback path stone cold. + +Every static return therefore warms first, through one primitive — +`ensureStalkerSession()` in `stalker-request.utils.ts`, wrapping +`StalkerSessionService.ensureToken()`: + +- `fetchStalkerPlaybackLink()` calls it before returning a static URL, which + covers ITV, VOD, radio and downloads at a single choke point. +- `StreamResolverService` calls it on its own static branch, which does not go + through that function. + +`ensureToken` performs handshake + `get_profile` with no link minted, and +validates the identity the cached token was negotiated for — which the raw +`getCachedToken()` the header builders use cannot. It is cheap where it is not +needed: a simple portal returns immediately and a warm cache with a matching +fingerprint resolves without a request. + +The classification happens BEFORE the handshake, not after: a **foreign-host** +static URL never needs the session at all, and warming it anyway would stall +playback behind a request worth up to 15 s against a portal that may be slow +or offline while the CDN is perfectly reachable — for a result that is then +discarded. + +A **foreign-host** static URL is returned before the handshake is even +attempted — it never needed the session. A **portal-owned** one with no usable +session (handshake failed, or threw) would be served knowing it will 401, so +both call sites fall back to `create_link` instead — which mints a URL +carrying its own token and, crucially, is the only path that can observe a +failure and trigger the lazy portal repair. That keeps a playlist still +misclassified as token-free, or pointing at an unrepaired endpoint, on the +self-healing path it was on before this change. + +`ensureStalkerSession` returns that verdict: `true` for a portal that needs no +token and for one holding a usable token, `false` for a full portal left +without one. It swallows a throwing handshake rather than propagating it — an +unreachable portal must not surface as an exception mid-playback — which +simply makes the verdict `false` and routes the row to `create_link`. + +**Known trade-off: a cached token is not revalidated.** `ensureToken` returns a +same-identity cache entry without touching the network, so the static path no +longer self-heals a token the server has retired — something `create_link` +used to do for free, since `makeAuthenticatedRequest` retires and re-auths on +an authorization failure. This is narrower than it sounds: per the 4.9.35 +reference, handshake tokens have **no TTL**, and failing to send the watchdog +does **not** invalidate auth (it only clears the admin panel's "online" +status). The one real vector left is another device performing `get_profile` +on the same MAC — common enough on shared subscriptions, but wherever a +watchdog is running it still self-heals within a ping cycle, because the ping +goes through `makeAuthenticatedRequest` too. What is left uncovered is a +same-host static stream played while no watchdog is up. + +Revalidating on every static playback would cost exactly the round trip this +section exists to remove, so it is deliberately not done here. The right home +for a fix is the auth lifecycle (PR 6): refresh on an observed playback +authorization failure, rather than pre-emptively on every play. + +**Downloads are the exception, and cannot use this.** A download request cannot +carry portal credentials at all — the main-process stored-header allowlist is +`User-Agent` / `Origin` / `Referer` only +(`download-request-headers.ts`), with no `Cookie` or `Authorization`. So +`startStalkerVodDownload` offers the static shortcut only for a URL that needs +none: it classifies the candidate with `isStalkerStreamCredentialSafe()` and +withholds the row (forcing `create_link`) for anything portal-owned. A +same-host movie keeps using the minted URL, which carries its own access token; +a CDN-hosted one keeps the permanent URL that survives retry. + +### Resolved links are never stored + +A temporary link lives about 5 seconds (`tv_tmp_link_ttl` / +`vclub_tmp_link_ttl`, both default 5). It is time-limited but not single-use, +so the rule is to resolve immediately before playback and never persist, +cache or replay the result. What each persisting path actually stores: + +| Path | Stores | Verdict | +| --- | --- | --- | +| Recently Viewed | the raw row including `cmd` (`buildStalkerRecentlyViewedPayload` spreads the item) | Re-resolves on replay. | +| Favorites | the raw row including `cmd` (`addStalkerFavorite`, `toggleFavorite`) | Re-resolves on replay. | +| Playback positions | `playlist_id` + `content_xtream_id` + content type only — no URL column | Not applicable. | +| Main-process playback context (`stalker-playback-context.service.ts`) | header sets keyed by the stream URL's origin + path, 15 min TTL | Stores no URL. The key drops the query, so a re-minted link with a fresh token still finds its headers instead of playing bare. | +| ITV full-list cache (`StalkerItvCacheService`) | catalog rows | Rows, not links. | +| Downloads | the resolved `url` on the `downloads` row | **The one exception** — see below. | + +The download row is the only place a resolved URL outlives the playback that +produced it, because the main-process downloader needs a URL it can retry and +resume with. Honouring the flags shrinks the exposure: an unflagged movie on a +host that needs no portal credentials now yields a permanent URL that survives +retry. A movie that needs a temporary link — or one on the portal host, which a +download cannot authenticate against — still stores a link that is dead by the +time retry runs. Fixing that needs the `cmd` on the download row plus a +re-resolution step before retry/resume, which is a schema change and is +deliberately out of scope here. + +### `forced_storage` and `play_token` + +Both are deliberately unused, and neither appears in the 4.9.35 reference +fact set as a parameter the stock server enforces: + +- **`forced_storage`** is a `create_link` request parameter that pins VOD + playback to one storage server. It exists for clients that let the user pick + a storage; IPTVnator has no such concept, and omitting the parameter is what + produces the empty value the portal treats as "no preference". Wiring it + would first need storage discovery and a picker in the VOD detail view. +- **`play_token`** is a `create_link` response field for clients that assemble + the stream URL themselves. IPTVnator plays the `cmd` the portal returns + verbatim (after the solution-prefix strip and base resolution above), so the + token the stream needs is already in the URL. Note the coupling with the + section above: on the static path no `create_link` runs at all, so no + `play_token` is ever produced — which is consistent, because a row that + wants neither flag is announcing that its `cmd` needs no portal-minted + credential. + +Revisit both only with a portal that demonstrably fails without them. + +### Regression coverage + +**Writing tests against this section:** the decision chain has several exits — +no flag evidence, unresolvable command shape, `series` set, foreign host, +session unusable — and more than one of them can satisfy the same assertion. +Four tests in the PR that introduced this were found passing through an exit +other than the one they named (a foreign-host command reaches neither the +handshake nor `create_link`, so it silently stands in for "simple portal" or +"handshake failed"). Mutation testing does not catch it: it proves a test is +coupled to its target, not that it reached the mechanism in its name. Check the +mock setup against the execution path, and assert the step you mean was +actually taken — `expect(ensureToken).toHaveBeenCalled()` rather than only the +returned URL. + +- `stalker-link-semantics.utils.spec.ts` — the decision table above. +- `stalker-vod.utils.spec.ts` — both flags survive + `buildStalkerSelectedVodItem` / `normalizeStalkerVodDetailsItem` / + `normalizeStalkerFavoriteItem`, and an unflagged row gains no flags. +- `stalker-player-request.utils.spec.ts` — static short-circuit, both flags, + the `series` exception, relative VOD commands, the session warm-up (simple + portal skipped, repaired endpoint used, failure degraded) and the + portal-owned-without-session fallback to `create_link`. +- `with-stalker-player.feature.spec.ts` — ITV/radio store paths and proof that + Recently Viewed stores the `cmd`, never the stream URL. +- `stream-resolver.service.spec.ts` — the collection route, plus the cold + full-portal session warm-up and its best-effort degradation. +- `stalker-vod-download.spec.ts` — a CDN movie takes the static shortcut, a + same-host one keeps minting. +- `stalker-playback-context.service.spec.ts` — headers only, query-insensitive + key. +- `apps/web-e2e/src/stalker.e2e.ts` — mock scenario `00:1A:79:00:00:0A` serves + unflagged ITV rows with a playable `cmd`; the spec asserts NO `create_link` + request reaches the portal, and a companion test on the default (flagged) + scenario proves the recorder does see one when a link is due. + ## Playback Header Contract Every playback kind — ITV, VOD, series episodes, and radio — resolves its @@ -326,7 +546,10 @@ live layout for the radio audio player, which renders outside Two stream profiles exist, selected by one shared predicate: - **Portal-owned** (`isStalkerStreamCredentialSafe()` in - `@iptvnator/shared/interfaces`): the stream host equals the portal host — + `@iptvnator/shared/interfaces`): the stream host equals the portal host + (compared with a terminal DNS root dot normalized away, since + `portal.example.` and `portal.example` are the same host but `URL` keeps + the dot) — including a different port or an http→https upgrade, the routine IPTV panel shape (#1158 class). These streams get the full MAG profile: `Cookie` (`mac=…` plus protocol cookies), `Authorization: Bearer ` when a diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index da145fc93..5d10d789b 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -45,6 +45,7 @@ describe('StreamResolverService', () => { stalkerSession = { getCachedToken: jest.fn(() => null), makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ token: null }), }; epgBridge = { getChannelPrograms: jest.fn(), @@ -703,6 +704,48 @@ describe('StreamResolverService', () => { expect(detail.playback.headers?.['Authorization']).toBeUndefined(); }); + it('plays a legacy radio favorite whose snapshot lost the flags', async () => { + // The row above this one carries NO `stalkerItem`, so it exercises the + // missing-snapshot arm. A radio favorite persisted before the flags + // were carried has a snapshot that simply lacks them — testing + // presence instead of evidence would skip the radio fallback and start + // minting for exactly those rows, breaking portals whose radio + // `create_link` is unsupported. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + + const detail = await service.resolveLiveDetail({ + uid: 'stalker::stalker-1::40003', + name: 'Legacy Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '40003', + stalkerCmd: 'ffmpeg https://media.example.com/legacy.mp3', + logo: 'legacy.png', + radio: 'true', + // Present, but stripped of the flags by the old whitelist. + stalkerItem: { + id: '40003', + cmd: 'ffmpeg https://media.example.com/legacy.mp3', + name: 'Legacy Radio', + }, + } as UnifiedCollectionItem); + + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled(); + expect(detail.playback.streamUrl).toBe( + 'https://media.example.com/legacy.mp3' + ); + }); + it('resolves relative Stalker create_link responses against the portal base', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ @@ -808,6 +851,255 @@ describe('StreamResolverService', () => { expect(playback.origin).toBeUndefined(); }); + it('plays an unflagged Stalker favorite from its stored cmd without create_link', async () => { + // Favorites persist the raw catalog row, so `use_http_tmp_link` / + // `use_load_balancing` come back with it — a row that sets neither is + // playable as it stands and must not cost a portal round trip. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::90', + name: 'Static Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '90', + stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8', + stalkerItem: { + id: '90', + cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8', + use_http_tmp_link: '0', + use_load_balancing: '0', + }, + } as UnifiedCollectionItem); + + // The detail route still loads EPG; what must not happen is a link + // request. + const requestedActions = [ + ...dataService.sendIpcEvent.mock.calls, + ...stalkerSession.makeAuthenticatedRequest.mock.calls, + ].map( + (call) => + (call[1] as { params?: { action?: string } } | undefined) + ?.params?.action + ); + expect(requestedActions).not.toContain('create_link'); + expect(playback.streamUrl).toBe('http://cdn.example.com/live/90.m3u8'); + expect(playback.isLive).toBe(true); + }); + + it('authenticates a cold full-portal session before a static stream', async () => { + // Skipping create_link also skips the request that used to warm the + // session. Tokens are in-memory only, so a cold start from global + // Favorites would otherwise hand a same-host gated stream headers + // with no Authorization and take a 403. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: + 'https://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial) + ); + // Cold: no token until ensureToken has run. + stalkerSession.getCachedToken.mockReturnValue(null); + stalkerSession.ensureToken.mockImplementation(async () => { + stalkerSession.getCachedToken.mockReturnValue('TOKEN-COLD'); + return { token: 'TOKEN-COLD' }; + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::91', + name: 'Cold Static Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '91', + stalkerCmd: 'ffrt3 https://stalker.example.com/live/91.m3u8', + stalkerItem: { + id: '91', + cmd: 'ffrt3 https://stalker.example.com/live/91.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'stalker-1' }) + ); + expect(playback.streamUrl).toBe( + 'https://stalker.example.com/live/91.m3u8' + ); + expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-COLD'); + }); + + it('serves a foreign-host static stream without waiting on the portal', async () => { + // The CDN needs no portal credentials, so the handshake would be a + // discarded result — and a stall of up to 15 s when the portal is + // slow or offline but the CDN is fine. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: + 'https://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial) + ); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::94', + name: 'Cdn Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '94', + stalkerCmd: 'ffrt3 https://cdn.example.com/live/94.m3u8', + stalkerItem: { + id: '94', + cmd: 'ffrt3 https://cdn.example.com/live/94.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(playback.streamUrl).toBe('https://cdn.example.com/live/94.m3u8'); + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + }); + + it('falls back to create_link when the handshake throws', async () => { + // A throwing handshake must not propagate. It leaves the session + // unusable, so a PORTAL-OWNED url goes to `create_link` rather than + // being served as a known 401 — the url has to be portal-owned for + // the handshake to run at all now that classification comes first. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: + 'https://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial) + ); + stalkerSession.ensureToken.mockRejectedValue(new Error('portal down')); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'https://stalker.example.com/tmp/92.ts?tok=1' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::92', + name: 'Portal Static Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '92', + stalkerCmd: 'ffrt3 https://stalker.example.com/live/92.m3u8', + stalkerItem: { + id: '92', + cmd: 'ffrt3 https://stalker.example.com/live/92.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalled(); + expect(playback.streamUrl).toBe( + 'https://stalker.example.com/tmp/92.ts?tok=1' + ); + }); + + it('prefers the edited playlist coordinates over a stale favorite snapshot', async () => { + // A favorite persists the portal URL and MAC it was saved with. After + // the playlist is edited, the session token is negotiated for the NEW + // identity — sending it with the old MAC cookie (or to the old host) + // is the mismatch the identity fingerprint exists to prevent. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: 'https://new.example.com/portal.php', + macAddress: 'AA:BB:CC:00:00:99', + isFullStalkerPortal: false, + } satisfies Partial) + ); + stalkerSession.getCachedToken.mockReturnValue('TOKEN-NEW'); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::93', + name: 'Edited Portal Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '93', + stalkerCmd: 'ffrt3 https://new.example.com/live/93.m3u8', + // Stale snapshot from before the edit. + stalkerPortalUrl: 'https://old.example.com/portal.php', + stalkerMacAddress: '00:11:22:33:44:55', + stalkerItem: { + id: '93', + cmd: 'ffrt3 https://new.example.com/live/93.m3u8', + use_http_tmp_link: '0', + }, + } as UnifiedCollectionItem); + + expect(playback.headers?.['Cookie']).toContain('mac=AA:BB:CC:00:00:99'); + expect(playback.headers?.['Cookie']).not.toContain( + 'mac=00:11:22:33:44:55' + ); + // Same host as the edited row ⇒ portal-owned ⇒ credentials attached. + expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-NEW'); + expect(playback.origin).toBe('https://new.example.com'); + }); + + it('mints a link for a flagged Stalker favorite', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: 'ffmpeg http://cdn.example.com/tmp/90.m3u8?tok=1' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::90', + name: 'Balanced Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '90', + stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8', + stalkerItem: { + id: '90', + cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8', + use_load_balancing: '1', + }, + } as UnifiedCollectionItem); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: expect.objectContaining({ action: 'create_link' }), + }) + ); + expect(playback.streamUrl).toBe( + 'http://cdn.example.com/tmp/90.m3u8?tok=1' + ); + }); + it('appends query-only Stalker create_link responses to the original cmd URL', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ 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 c1d726560..2d265e795 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 @@ -9,6 +9,7 @@ import { EpgItem, EpgProgram, Playlist, + isStalkerStreamCredentialSafe, ResolvedPortalPlayback, STALKER_REQUEST, StalkerPortalActions, @@ -19,15 +20,21 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { buildStalkerExternalPlaybackHeaders, + ensureStalkerSession, executeStalkerRequest, getStalkerPortalOrigin, + hasStalkerLinkFlagEvidence, isCrossOriginStalkerStream, - normalizeStalkerPlaybackCommand, resolveStalkerPlaybackUrl, + resolveStalkerStaticPlaybackUrl, StalkerPortalRepairService, StalkerSessionService, + type StalkerLinkFlagSource, } from '@iptvnator/portal/stalker/data-access'; -import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { + UnifiedCollectionItem, + createLogger, +} from '@iptvnator/portal/shared/util'; type PlaylistWithChannels = Playlist & { readonly playlist?: { readonly items?: Channel[] }; @@ -74,6 +81,7 @@ export class StreamResolverService { private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly stalkerSession = inject(StalkerSessionService); private readonly portalRepair = inject(StalkerPortalRepairService); + private readonly logger = createLogger('StreamResolver'); private readonly m3uEpgTimeoutMs = 3000; private readonly portalEpgTimeoutMs = 10000; private readonly xtreamEpgCache = new Map(); @@ -330,18 +338,84 @@ export class StreamResolverService { const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(item.playlistId) )) as Playlist | undefined; + // The playlist row wins whenever it exists: a repaired endpoint or an + // edited MAC must beat the snapshot a favorite persisted, and the + // session token is negotiated for the ROW's identity — pairing a fresh + // token with a stale MAC cookie is exactly the mismatch the identity + // fingerprint exists to prevent. The item's own coordinates are the + // fallback for a playlist that no longer exists. + const currentPlaylist = playlist + ? this.portalRepair.applyOverride(playlist) + : undefined; const portalUrl = - item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? ''; - const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? ''; - const normalizedCmd = normalizeStalkerPlaybackCommand( + currentPlaylist?.portalUrl ?? + currentPlaylist?.url ?? + item.stalkerPortalUrl ?? + ''; + const macAddress = + currentPlaylist?.macAddress ?? item.stalkerMacAddress ?? ''; + // Favorites and Recently Viewed persist the raw catalog row, so the + // temporary-link flags travel with the item and this route makes the + // same decision the portal views make: an unflagged, directly playable + // `cmd` plays as-is and never mints a 5 s link. An item that carries + // no row snapshot (router state holds only the projection) gets no + // verdict — except radio, whose directly usable commands have always + // played as-is, so it keeps behaving like a row without flags. + const snapshot = item.stalkerItem as StalkerLinkFlagSource | undefined; + // Radio keeps its historical rule: a directly usable command plays as + // as-is. That has to key off flag EVIDENCE, not merely a present + // snapshot — a radio row persisted before the flags were carried has a + // snapshot that lacks them, and testing presence alone would skip this + // fallback and start minting for exactly those rows. Mirrors + // `withStalkerPlayer`'s radio branch; the two must not drift. + const linkFlags = + item.radio === 'true' && !hasStalkerLinkFlagEvidence(snapshot) + ? { + ...(snapshot ?? {}), + use_http_tmp_link: '0', + use_load_balancing: '0', + } + : snapshot; + const staticUrl = resolveStalkerStaticPlaybackUrl( + linkFlags, item.stalkerCmd ?? '' ); - if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) { - return this.buildStalkerPlayback(item, playlist, { - macAddress, - portalUrl, - streamUrl: normalizedCmd, - }); + if (staticUrl) { + // Skipping `create_link` also skips the only authenticated + // request this route used to make, and it was what warmed the + // session. Tokens live in memory only, so on a cold start from + // global Favorites/Recently Viewed a same-host stream gated on + // the portal Bearer token would get headers without one and 403. + // `ensureToken` performs the handshake + get_profile — and + // validates the identity the cached token was negotiated for, + // which the raw `getCachedToken` below cannot — without minting a + // link; a simple portal returns null immediately. The raw row goes + // in: the helper applies the repair override itself, exactly as + // `executeStalkerRequest` does on the branch below. + // + // Classified BEFORE authenticating: a foreign-host stream never + // needs the session, and warming it anyway would stall playback + // behind a handshake against a portal that may be slow or offline + // while the CDN is perfectly reachable. + const servePlayback = () => + this.buildStalkerPlayback(item, playlist, { + macAddress, + portalUrl, + streamUrl: staticUrl, + isLive: item.radio === 'true' ? undefined : true, + }); + + if (!isStalkerStreamCredentialSafe(portalUrl, staticUrl)) { + return servePlayback(); + } + + // Portal-owned with no usable session would be served knowing it + // will 401, so fall through to `create_link` instead — it mints a + // URL that carries its own token and is the only path that can + // observe a failure and trigger the lazy portal repair. + if (await this.warmStalkerSession(playlist)) { + return servePlayback(); + } } const contentType = item.radio === 'true' ? 'radio' : 'itv'; @@ -404,6 +478,25 @@ export class StreamResolverService { }); } + /** + * Establish the portal session a static stream may still be gated on. + * Shares the single primitive with the store's playback path so the two + * routes cannot drift apart on when a session is required. + */ + private async warmStalkerSession( + playlist: Playlist | undefined + ): Promise { + return ensureStalkerSession( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, + playlist, + this.logger + ); + } + /** * The collection routes must hand players the SAME portal header set the * Stalker live layout builds — an auth-gated stream opened from Favorites @@ -1168,10 +1261,6 @@ export class StreamResolverService { })); } - private isHttpUrl(value: string): boolean { - return value.startsWith('http://') || value.startsWith('https://'); - } - /** * All keys a manual mapping for this channel may have been saved under: * the playlist-scoped Xtream key first, then the M3U lookup keys. diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index 62101dd08..b70d3b184 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -87,10 +87,22 @@ export interface StalkerPortalCatalogFacade< addToFavorites(item: Record, onDone?: () => void): void; removeFromFavorites(favoriteId: string, onDone?: () => void): void; fetchMovieFileId(itemId: string): Promise; + /** + * `linkFlags` carries the catalog row's `use_http_tmp_link` / + * `use_load_balancing`; without it the portal is always asked for a + * temporary link. + * + * The shape is spelled out rather than imported as `StalkerLinkFlagSource` + * on purpose: this lib is `type:util`/`domain:portal-shared` and may not + * depend on `portal-stalker-data-access` (`type:data-access`/`domain:stalker`) + * — the Nx module-boundary rule rejects that edge. + */ fetchLinkToPlay( portalUrl: string, macAddress: string, - cmd: string + cmd: string, + series?: number, + linkFlags?: { use_http_tmp_link?: unknown; use_load_balancing?: unknown } ): Promise; resolveVodPlayback( cmd?: string, diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts index 621d28344..d5027eed6 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-favorite-item.interface.ts @@ -17,6 +17,13 @@ export interface StalkerVodSource { cmd?: string; series?: unknown[]; has_files?: number; + /** + * Temporary-link flags the portal sets on a row. Carried through every + * normalizer because playback and downloads read them to decide whether + * `create_link` is required — dropping one fails open. + */ + use_http_tmp_link?: unknown; + use_load_balancing?: unknown; is_series?: boolean | number | string | null | undefined; video_id?: string; category_id?: string; @@ -47,6 +54,9 @@ export interface StalkerSelectedVodItem extends StalkerVodDetails { is_series?: true; video_id?: string; category_id?: string; + /** See {@link StalkerVodSource.use_http_tmp_link}. */ + use_http_tmp_link?: unknown; + use_load_balancing?: unknown; } /** diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index cda2d7219..8ac4bbda2 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -2,12 +2,14 @@ import { VodDetailsItem } from '@iptvnator/shared/interfaces'; import { StalkerFavoriteItem } from './models'; import { buildStalkerFavoritePayload, + buildStalkerSelectedVodItem, createStalkerInfo, createStalkerInlineDetailState, createStalkerDetailViewState, isStalkerSeriesFlag, normalizeStalkerFavoriteItem, normalizeStalkerSeriesFlag, + normalizeStalkerVodDetailsItem, toggleStalkerVodFavorite, } from './stalker-vod.utils'; @@ -286,4 +288,52 @@ describe('stalker-vod.utils regressions', () => { expect(info.tmdb_trailer).toBe('abc123def'); expect(info.tmdb_recommendations).toEqual(tmdbRecommendations); }); + + describe('temporary-link flags survive normalization', () => { + // `buildStalkerSelectedVodItem` is a whitelist, so a field it forgets + // is silently gone. For these two that fails OPEN: playback and + // downloads would read "no temporary link needed" and play the + // portal's non-final URL instead of minting one. + const FLAGGED_ROW = { + id: '42', + cmd: 'ffrt3 http://cdn.example/movie.mkv', + use_http_tmp_link: '1', + use_load_balancing: '0', + info: { name: 'Flagged Movie' }, + }; + + it('keeps both flags on the selected VOD item', () => { + const selected = buildStalkerSelectedVodItem(FLAGGED_ROW); + + expect(selected.use_http_tmp_link).toBe('1'); + expect(selected.use_load_balancing).toBe('0'); + }); + + it('keeps both flags through the VOD details normalizer', () => { + const normalized = normalizeStalkerVodDetailsItem(FLAGGED_ROW); + + expect(normalized.use_http_tmp_link).toBe('1'); + expect(normalized.use_load_balancing).toBe('0'); + }); + + it('keeps both flags on a normalized favorite', () => { + const normalized = normalizeStalkerFavoriteItem( + FLAGGED_ROW as StalkerFavoriteItem + ); + + expect(normalized.details.use_http_tmp_link).toBe('1'); + expect(normalized.details.use_load_balancing).toBe('0'); + }); + + it('leaves an unflagged row without inventing flags', () => { + const selected = buildStalkerSelectedVodItem({ + id: '43', + cmd: 'ffrt3 http://cdn.example/other.mkv', + info: { name: 'Plain Movie' }, + }); + + expect(selected.use_http_tmp_link).toBeUndefined(); + expect(selected.use_load_balancing).toBeUndefined(); + }); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index aca6cd3c2..398228470 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -160,6 +160,16 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { }; } +/** + * Narrows a raw portal row to the fields the detail/playback flows need. + * + * This is a whitelist, not a spread, so anything not named here is dropped. + * `use_http_tmp_link` / `use_load_balancing` MUST stay on the list: playback + * and downloads read them to decide whether `create_link` is required, and a + * missing flag reads as "no temporary link needed" — it fails OPEN, playing + * the portal's non-final URL instead of minting one. See + * `docs/architecture/stalker-portal.md`, "Playback Link Resolution". + */ export function buildStalkerSelectedVodItem( item: StalkerVodSource, forceSeries = false @@ -169,6 +179,8 @@ export function buildStalkerSelectedVodItem( cmd: toStringOrFallback(item.cmd), series: item.series, has_files: item.has_files, + use_http_tmp_link: item.use_http_tmp_link, + use_load_balancing: item.use_load_balancing, is_series: forceSeries || isStalkerSeriesFlag(item?.is_series) ? true diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 8e2f5f367..e42b528b9 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -31,6 +31,20 @@ const PLAYLIST = { isFullStalkerPortal: false, } as PlaylistMeta; +interface SelectedTestItem { + id: string; + cmd: string; + has_files?: boolean; + title?: string; + name?: string; + o_name?: string; + logo?: string; + category_id?: string; + cover?: string; + use_http_tmp_link?: unknown; + use_load_balancing?: unknown; +} + const TestPlayerStore = signalStore( withState({ currentPlaylist: PLAYLIST, @@ -41,31 +55,13 @@ const TestPlayerStore = signalStore( has_files: true, title: 'Original Title', category_id: 'vod', - } as { - id: string; - cmd: string; - has_files?: boolean; - title?: string; - name?: string; - o_name?: string; - logo?: string; - category_id?: string; - cover?: string; - }, + } as SelectedTestItem, }), withMethods((store) => ({ setSelectedContentType(type: 'vod' | 'series' | 'itv' | 'radio') { patchState(store, { selectedContentType: type }); }, - setSelectedItem(item: { - id: string; - cmd: string; - title?: string; - name?: string; - o_name?: string; - logo?: string; - category_id?: string; - }) { + setSelectedItem(item: SelectedTestItem) { patchState(store, { selectedItem: item }); }, })), @@ -339,6 +335,183 @@ describe('withStalkerPlayer', () => { expect(playback.origin).toBeUndefined(); }); + describe('temporary-link semantics', () => { + const CHANNEL = { + id: '10001', + cmd: 'ffrt3 http://cdn.example/live/10001.m3u8', + name: 'Static TV', + o_name: 'Static TV', + logo: 'static-tv.png', + category_id: '1001', + }; + + it('plays an unflagged ITV channel straight from its static cmd', async () => { + store.setSelectedContentType('itv'); + + const playback = await store.resolveItvPlayback({ + ...CHANNEL, + use_http_tmp_link: '0', + use_load_balancing: '0', + }); + + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + expect(playback.streamUrl).toBe( + 'http://cdn.example/live/10001.m3u8' + ); + expect(playback.isLive).toBe(true); + }); + + it.each(['use_http_tmp_link', 'use_load_balancing'] as const)( + 'mints a temporary link for an ITV channel with %s set', + async (flag) => { + store.setSelectedContentType('itv'); + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'ffmpeg http://cdn.example/tmp/10001.m3u8' }, + }); + + const playback = await store.resolveItvPlayback({ + ...CHANNEL, + [flag]: '1', + }); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + cmd: CHANNEL.cmd, + type: 'itv', + }), + }) + ); + expect(playback.streamUrl).toBe( + 'http://cdn.example/tmp/10001.m3u8' + ); + } + ); + + it('mints a temporary link for a flagged radio station with a playable cmd', async () => { + // Before the flags were read, a directly playable radio command + // always bypassed create_link — a proxied station then played a + // URL the portal never intended to serve. + store.setSelectedContentType('radio'); + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'http://cdn.example/tmp/jazz.mp3' }, + }); + + const playback = await store.resolveRadioPlayback({ + id: 'radio-3', + cmd: 'ifm https://stream.example/jazz.mp3', + name: 'Jazz FM', + o_name: 'Jazz FM', + logo: 'jazz.png', + category_id: '4001', + use_http_tmp_link: '1', + }); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + type: 'radio', + }), + }) + ); + expect(playback.streamUrl).toBe('http://cdn.example/tmp/jazz.mp3'); + }); + + it('mints a link for a flagged VOD row with a directly playable cmd', async () => { + // The VOD row reaches this method through + // `buildStalkerSelectedVodItem`, a whitelist — if it ever drops + // the flags again, this row reads as unflagged and the static + // path silently plays the portal's non-final URL. + store.setSelectedContentType('vod'); + store.setSelectedItem({ + id: '42', + cmd: 'ffrt3 http://cdn.example/movie.mkv', + title: 'Flagged Movie', + category_id: 'vod', + use_http_tmp_link: '1', + }); + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'http://cdn.example/tmp/movie.mkv?tok=1' }, + }); + + const playback = await store.resolveVodPlayback( + undefined, + 'Flagged Movie' + ); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + }), + }) + ); + expect(playback.streamUrl).toBe( + 'http://cdn.example/tmp/movie.mkv?tok=1' + ); + }); + + it('plays an unflagged VOD row from its static cmd', async () => { + store.setSelectedContentType('vod'); + store.setSelectedItem({ + id: '43', + cmd: 'ffrt3 http://cdn.example/movie.mkv', + title: 'Static Movie', + category_id: 'vod', + use_http_tmp_link: '0', + use_load_balancing: '0', + }); + + const playback = await store.resolveVodPlayback( + undefined, + 'Static Movie' + ); + + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + expect(playback.streamUrl).toBe('http://cdn.example/movie.mkv'); + }); + + it.each([ + ['static', { use_http_tmp_link: '0' }, undefined], + [ + 'minted', + { use_http_tmp_link: '1' }, + { js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=SECRET' } }, + ], + ])( + 'stores the portal cmd, never the %s stream URL, in recently viewed', + async (_label, flags, response) => { + // A temporary link dies after ~5 s, so a replayed one is worse + // than useless — the row has to keep the `cmd` and re-resolve. + store.setSelectedContentType('itv'); + if (response) { + dataService.sendIpcEvent.mockResolvedValueOnce(response); + } + + await store.resolveItvPlayback({ ...CHANNEL, ...flags }); + + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledWith( + PLAYLIST._id, + expect.objectContaining({ + id: '10001', + cmd: CHANNEL.cmd, + }) + ); + const [, persisted] = + playlistService.addPortalRecentlyViewed.mock.calls[0]; + expect(JSON.stringify(persisted)).not.toContain('SECRET'); + expect(JSON.stringify(persisted)).not.toContain('/tmp/'); + } + ); + }); + it('attaches the portal header set to radio playback resolved from the portal', async () => { const session = TestBed.inject(StalkerSessionService) as unknown as { getCachedToken: jest.Mock; 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 37b0d9e98..7d43ce4c1 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 @@ -32,14 +32,16 @@ import { fetchStalkerExpireDate, fetchStalkerMovieFileId, fetchStalkerPlaybackLink, - normalizeStalkerPlaybackCommand, + hasStalkerLinkFlagEvidence, shouldResolveMovieFileId, + type StalkerLinkFlagSource, } from '../utils'; -type StalkerPlayableItem = StalkerPortalItem & { - cmd?: string; - has_files?: unknown; -}; +type StalkerPlayableItem = StalkerPortalItem & + StalkerLinkFlagSource & { + cmd?: string; + has_files?: unknown; + }; /** * Playback/link/player concern methods. @@ -189,6 +191,7 @@ export function withStalkerPlayer() { storeState.selectedContentType(), cmd: cmdToUse, series: episodeNum, + linkFlags: item, } ); @@ -265,6 +268,7 @@ export function withStalkerPlayer() { storeState.selectedContentType(), cmd: item.cmd, forcedContentType: 'itv', + linkFlags: item, } ); @@ -318,26 +322,32 @@ export function withStalkerPlayer() { throw new Error('nothing_to_play'); } - let streamUrl = normalizeStalkerPlaybackCommand(item.cmd); - if ( - !streamUrl.startsWith('http://') && - !streamUrl.startsWith('https://') - ) { - streamUrl = await fetchStalkerPlaybackLink( - requestDeps, - { - playlist, - selectedContentType: - storeState.selectedContentType(), - cmd: item.cmd, - forcedContentType: 'radio', - } - ); - } - - if (!streamUrl) { - throw new Error('nothing_to_play'); - } + // Radio has always skipped `create_link` for a directly + // playable command. Handing the row to the shared decision + // adds the flags, so a station the portal proxies now gets + // its temporary link instead of a dead static URL — but a + // row carrying no flags at all must keep the old rule + // rather than start minting, or a portal whose radio + // `create_link` never worked would lose playback it has. + // ITV and VOD have no such history and stay conservative. + const radioLinkFlags = hasStalkerLinkFlagEvidence(item) + ? item + : { + ...item, + use_http_tmp_link: '0', + use_load_balancing: '0', + }; + const streamUrl = await fetchStalkerPlaybackLink( + requestDeps, + { + playlist, + selectedContentType: + storeState.selectedContentType(), + cmd: item.cmd, + forcedContentType: 'radio', + linkFlags: radioLinkFlags, + } + ); recordRecentlyViewed( item, @@ -384,7 +394,8 @@ export function withStalkerPlayer() { portalUrl: string, macAddress: string, cmd: string, - series?: number + series?: number, + linkFlags?: StalkerLinkFlagSource | null ) { return fetchStalkerPlaybackLink(requestDeps, { playlist: createRequestPlaylist( @@ -395,6 +406,7 @@ export function withStalkerPlayer() { storeState.selectedContentType(), cmd, series, + linkFlags, }); }, async getExpireDate() { diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts index e6dd2d051..1553b11cf 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/index.ts @@ -1,4 +1,6 @@ export * from './stalker-collection-persistence.utils'; +export * from './stalker-link-semantics.utils'; +export * from './stalker-playback-command.utils'; export * from './stalker-player-request.utils'; export * from './stalker-request.utils'; export * from './stalker-content-mappers'; diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts new file mode 100644 index 000000000..db1ffbf78 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.spec.ts @@ -0,0 +1,270 @@ +import { + isStalkerPortalFlagEnabled, + requiresStalkerTemporaryLink, + resolveStalkerStaticPlaybackUrl, +} from './stalker-link-semantics.utils'; + +describe('stalker-link-semantics', () => { + describe('isStalkerPortalFlagEnabled', () => { + it.each([ + ['1', true], + [1, true], + [true, true], + ['true', true], + ['0', false], + [0, false], + [false, false], + ['false', false], + ['', false], + [' ', false], + [null, false], + [undefined, false], + [Number.NaN, false], + ])('reads %p as %p', (value, expected) => { + expect(isStalkerPortalFlagEnabled(value)).toBe(expected); + }); + }); + + describe('requiresStalkerTemporaryLink', () => { + it('is false for a row that sets neither flag', () => { + expect( + requiresStalkerTemporaryLink({ + use_http_tmp_link: '0', + use_load_balancing: '0', + }) + ).toBe(false); + }); + + it('is false when the row carries no flags at all', () => { + expect(requiresStalkerTemporaryLink({})).toBe(false); + expect(requiresStalkerTemporaryLink(undefined)).toBe(false); + }); + + it.each(['use_http_tmp_link', 'use_load_balancing'] as const)( + 'is true when %s is set', + (flag) => { + expect(requiresStalkerTemporaryLink({ [flag]: '1' })).toBe(true); + } + ); + }); + + describe('resolveStalkerStaticPlaybackUrl', () => { + it('plays an unflagged absolute command and strips the solution prefix', () => { + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0', use_load_balancing: '0' }, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBe('http://cdn.example/live/42.m3u8'); + }); + + it('accepts a bare URL with no solution prefix', () => { + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'https://cdn.example/live/42.m3u8' + ) + ).toBe('https://cdn.example/live/42.m3u8'); + }); + + it.each(['use_http_tmp_link', 'use_load_balancing'] as const)( + 'defers to create_link when %s is set', + (flag) => { + expect( + resolveStalkerStaticPlaybackUrl( + { [flag]: '1' }, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + } + ); + + it.each([ + // The VOD has_files rewrite produces exactly this shape. + ['/media/file_42.mpg'], + ['?token=abc'], + ['ffrt4://ch/live/10001/index.m3u8'], + ['rtmp://cdn.example/live/42'], + [''], + [' '], + ])('defers to create_link for the unresolvable command %p', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it.each([ + ['ffrt3 http://localhost/ch/1234_'], + ['http://127.0.0.1:8080/ch/1234_'], + ['http://0.0.0.0/ch/1234_'], + ['http://[::1]/ch/1234_'], + ])('defers to create_link for the portal-local address %p', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it('gives no verdict when the caller has no row to read flags from', () => { + expect( + resolveStalkerStaticPlaybackUrl( + undefined, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + expect( + resolveStalkerStaticPlaybackUrl( + null, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + }); + + it('gives no verdict for a row carrying neither flag key', () => { + // A Favorite persisted before these flags were carried was + // stripped of them by `buildStalkerSelectedVodItem`'s whitelist, + // so it is indistinguishable from a row the portal marked + // unflagged. There is no migration for those rows, so absence has + // to read as "no evidence" rather than "no temporary link". + expect( + resolveStalkerStaticPlaybackUrl( + { id: '42', cmd: 'x' } as never, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: undefined, use_load_balancing: null }, + 'ffrt3 http://cdn.example/live/42.m3u8' + ) + ).toBeNull(); + }); + + it.each([ + ['http://127.0.0.2/ch/1234_'], + ['http://127.1.2.3:8080/ch/1234_'], + ['http://127.255.255.255/ch/1234_'], + ])('treats the whole 127.0.0.0/8 range as portal-local (%p)', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it.each([ + // `URL` keeps the brackets and normalizes the address, so these + // reach the guard as `[::]`, `[::1]` and `[::ffff:7f00:1]`. + ['http://[::]/ch/1234_'], + ['http://[::1]/ch/1234_'], + ['http://[0:0:0:0:0:0:0:1]/ch/1234_'], + ['http://[::ffff:127.0.0.1]/ch/1234_'], + ['http://[::ffff:7f00:1]/ch/1234_'], + ['http://[::ffff:127.255.255.255]/ch/1234_'], + ['http://[::ffff:0.0.0.0]/ch/1234_'], + ])('treats the IPv6 portal-local form %p as local', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it.each([ + ['ffrt3 http://localhost./ch/1234_'], + ['http://LocalHost./ch/1234_'], + ])('strips the DNS root dot before classifying %p', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it.each([ + ['HTTP://cdn.example/live/42.m3u8'], + ['ffrt3 HTTPS://cdn.example/live/42.m3u8'], + ['hTTp://cdn.example/live/42.m3u8'], + ])('accepts the case-insensitive scheme %p', (cmd) => { + // RFC 3986 makes the scheme case-insensitive. A case-sensitive + // test failed safe (it minted a link) but defeated the contract + // for a portal that spells it this way. + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toMatch(/cdn\.example\/live\/42\.m3u8$/); + }); + + it.each([ + ['http:///ch/1234_'], + ['ffrt3 https:///ch/1234_'], + ])('defers to create_link for the authority-less url %p', (cmd) => { + // `new URL('http:///ch/1')` reports the host as `ch` — a malformed + // command would otherwise reach the player as a nonsense address + // rather than going to the portal to be resolved. + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it('keeps a routable IPv6 host', () => { + // 2001:db8::1 is documentation space, but it is routable as far as + // this guard is concerned — only local placeholders are rejected. + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://[2001:db8::1]/live/42.m3u8' + ) + ).toBe('http://[2001:db8::1]/live/42.m3u8'); + }); + + it('keeps an IPv4-mapped address that is not loopback', () => { + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://[::ffff:203.0.113.7]/live/42.m3u8' + ) + ).not.toBeNull(); + }); + + it.each([ + // RFC 6761 §6.3 reserves the whole `.localhost` suffix. + ['http://stream.localhost/ch/1234_'], + ['ffrt3 http://a.b.localhost/ch/1234_'], + ['http://stream.localhost./ch/1234_'], + // Conventional /etc/hosts alias for 127.0.0.1. + ['http://localhost.localdomain/ch/1234_'], + ])('treats the localhost name %p as portal-local', (cmd) => { + expect( + resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd) + ).toBeNull(); + }); + + it('does not mistake a localhost-prefixed host for a localhost name', () => { + // Only the SUFFIX is reserved — `localhost.cdn.example` is an + // ordinary routable name and must keep playing statically. + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://localhost.cdn.example/live/42.m3u8' + ) + ).toBe('http://localhost.cdn.example/live/42.m3u8'); + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://notlocalhost/live/42.m3u8' + ) + ).toBe('http://notlocalhost/live/42.m3u8'); + }); + + it('does not mistake a non-loopback 127-lookalike for loopback', () => { + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://127.0.0.1.cdn.example/live/42.m3u8' + ) + ).toBe('http://127.0.0.1.cdn.example/live/42.m3u8'); + }); + + it('keeps a non-loopback host that merely looks local', () => { + expect( + resolveStalkerStaticPlaybackUrl( + { use_http_tmp_link: '0' }, + 'http://localhost.cdn.example/live/42.m3u8' + ) + ).toBe('http://localhost.cdn.example/live/42.m3u8'); + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts new file mode 100644 index 000000000..d06d7fbc4 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts @@ -0,0 +1,197 @@ +import { + isPlayableHttpUrl, + normalizeStalkerPlaybackCommand, +} from './stalker-playback-command.utils'; + +/** + * The two catalog flags that decide whether a row needs a temporary link. + * + * Reference behaviour (portal `player.js`, mirrored by Kodi's pvr.stalker): + * a client calls `create_link` only when the row asks for it — either because + * the portal proxies the stream through a per-session temporary URL + * (`use_http_tmp_link`) or because it picks a storage server per request + * (`use_load_balancing`). Every other row plays the static `cmd` that + * `get_all_channels` / `get_ordered_list` already returned. + * + * Portals send these as `'1'`/`'0'` strings, `1`/`0` numbers or booleans, so + * the values arrive untyped. + */ +export interface StalkerLinkFlagSource { + use_http_tmp_link?: unknown; + use_load_balancing?: unknown; +} + +/** + * Hosts that can only mean "the portal itself". A `cmd` such as + * `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, never an + * address the set-top box could open, so it always needs resolving. + */ +const PORTAL_LOCAL_HOSTNAMES = new Set([ + 'localhost', + // Conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems. + 'localhost.localdomain', + '0.0.0.0', + '::1', + '::', +]); + +/** + * RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost` + * for the loopback interface, and resolvers honour it — so `stream.localhost` + * would reach the player's own machine. + */ +const LOCALHOST_SUFFIX = '.localhost'; + +/** IPv4 reserves all of `127.0.0.0/8` for loopback, not just `127.0.0.1`. */ +const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +/** How `URL` normalizes an IPv4-mapped address: `::ffff:7f00:1`. */ +const IPV6_MAPPED_IPV4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/; + +/** + * Whether the host can only mean the machine resolving it — the portal when it + * wrote the command, the player if we took it literally. + * + * `URL.hostname` keeps IPv6 in brackets and normalizes the address, so + * `[0:0:0:0:0:0:0:1]` arrives as `[::1]` and an IPv4-mapped `127.0.0.1` as hex + * (`[::ffff:7f00:1]`) — both are matched here rather than relying on the + * portal to spell them the obvious way. + */ +function isPortalLocalHostname(hostname: string): boolean { + // A trailing dot is the DNS root and resolves identically, but `URL` keeps + // it for names (`localhost.`) while dropping it for IP literals — so an + // exact-name check has to strip it or `http://localhost./ch/1_` reads as a + // remote host. + const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, ''); + if ( + PORTAL_LOCAL_HOSTNAMES.has(host) || + host.endsWith(LOCALHOST_SUFFIX) || + IPV4_LOOPBACK.test(host) + ) { + return true; + } + + // Dotted IPv4-mapped form, for any engine that does not rewrite it to hex. + if (host.startsWith('::ffff:')) { + const tail = host.slice('::ffff:'.length); + if (IPV4_LOOPBACK.test(tail) || tail === '0.0.0.0') { + return true; + } + } + + const mapped = IPV6_MAPPED_IPV4.exec(host); + if (!mapped) { + return false; + } + + // The whole of 127.0.0.0/8 shares the 0x7f high byte (127.0.0.1 is + // 7f00:0001); `::ffff:0:0` is the mapped unspecified address. + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return high >>> 8 === 0x7f || (high === 0 && low === 0); +} + +/** + * Whether the row can speak for itself about temporary links. + * + * A stock portal returns both flags on every ITV/VOD row, so their PRESENCE + * is the provenance signal — and it is the only one available, because rows + * persisted into Favorites/Recently Viewed before these flags were carried + * were stripped of them by `buildStalkerSelectedVodItem`'s whitelist. Without + * this check a legacy snapshot would be indistinguishable from a row the + * portal genuinely marked unflagged, and would take the static path on a + * command that may still need resolving. There is no migration or provenance + * marker for those rows, so absence has to mean "no evidence", not "no". + */ +export function hasStalkerLinkFlagEvidence( + source: StalkerLinkFlagSource | null | undefined +): boolean { + return ( + source?.use_http_tmp_link != null || source?.use_load_balancing != null + ); +} + +/** Truthiness for portal flags, which arrive as strings, numbers or booleans. */ +export function isStalkerPortalFlagEnabled(value: unknown): boolean { + if (value === null || value === undefined) { + return false; + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return Number.isFinite(value) && value !== 0; + } + + const normalized = String(value).trim().toLowerCase(); + if (!normalized) { + return false; + } + + return normalized !== '0' && normalized !== 'false'; +} + +/** + * Whether the row explicitly asks the client to mint a temporary link. + */ +export function requiresStalkerTemporaryLink( + source: StalkerLinkFlagSource | null | undefined +): boolean { + return ( + isStalkerPortalFlagEnabled(source?.use_http_tmp_link) || + isStalkerPortalFlagEnabled(source?.use_load_balancing) + ); +} + +/** + * The playable URL for a row that does NOT need `create_link`, or `null` when + * the portal has to resolve it. + * + * `null` is returned for every shape a client cannot resolve on its own, which + * is deliberately wider than the flag check alone — the flags are the rule, + * these guards only ever push a row back onto today's `create_link` path and + * so cannot regress a portal that works now: + * + * - no row at all, or a row carrying neither flag key — no evidence, so no + * verdict (see {@link hasStalkerLinkFlagEvidence}: a row persisted before + * these flags were carried looks identical to one the portal marked + * unflagged); + * - either flag set — the portal asked for a temporary link; + * - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command — + * only `create_link` turns those into an address, and the VOD `has_files` + * rewrite produces exactly the first shape; + * - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL — + * or an HTTP command with no authority (`http:///ch/1`), which `URL` would + * quietly reinterpret as the host `ch`; + * - a portal-local host — a placeholder only the portal could resolve (see + * {@link isPortalLocalHostname}, which covers rather more than the obvious + * `localhost`). + */ +export function resolveStalkerStaticPlaybackUrl( + source: StalkerLinkFlagSource | null | undefined, + cmd: string +): string | null { + if ( + !hasStalkerLinkFlagEvidence(source) || + requiresStalkerTemporaryLink(source) + ) { + return null; + } + + const url = normalizeStalkerPlaybackCommand(cmd); + if (!isPlayableHttpUrl(url)) { + return null; + } + + try { + if (isPortalLocalHostname(new URL(url).hostname.toLowerCase())) { + return null; + } + } catch { + return null; + } + + return url; +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts new file mode 100644 index 000000000..eec784746 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.spec.ts @@ -0,0 +1,116 @@ +import { + hasHttpScheme, + isPlayableHttpUrl, + normalizeStalkerPlaybackCommand, + resolveStalkerPlaybackUrl, +} from './stalker-playback-command.utils'; + +const PORTAL = 'http://demo.example/stalker_portal/server/load.php'; + +describe('stalker-playback-command.utils', () => { + describe('hasHttpScheme', () => { + it.each([ + ['http://a/b', true], + ['https://a/b', true], + ['HTTP://a/b', true], + ['HtTpS://a/b', true], + ['ffrt4://a/b', false], + ['rtmp://a/b', false], + ['/media/1.mpg', false], + ['', false], + ])('reads %p as %p', (value, expected) => { + expect(hasHttpScheme(value)).toBe(expected); + }); + }); + + describe('isPlayableHttpUrl', () => { + it.each([ + ['http://cdn.example/a.ts', true], + ['HTTPS://cdn.example/a.ts', true], + ['http://[::1]/a.ts', true], + // `new URL` reinterprets the first path segment as the host here. + ['http:///ch/1', false], + ['https:///ch/1', false], + ['ffrt4://ch/1', false], + ['/media/1.mpg', false], + ])('reads %p as %p', (value, expected) => { + expect(isPlayableHttpUrl(value)).toBe(expected); + }); + }); + + describe('normalizeStalkerPlaybackCommand', () => { + it('splits the solution prefix off a lowercase url', () => { + expect( + normalizeStalkerPlaybackCommand('ffrt3 http://cdn.example/a.ts') + ).toBe('http://cdn.example/a.ts'); + }); + + it('splits the solution prefix off an uppercase scheme too', () => { + // RFC 3986 makes the scheme case-insensitive; a case-sensitive + // test returned the whole `ffrt3 HTTP://…` string as if it were + // the URL. + expect( + normalizeStalkerPlaybackCommand('ffrt3 HTTP://cdn.example/a.ts') + ).toBe('HTTP://cdn.example/a.ts'); + }); + + it('leaves a bare command untouched', () => { + expect( + normalizeStalkerPlaybackCommand('ffrt4://ch/live/1/index.m3u8') + ).toBe('ffrt4://ch/live/1/index.m3u8'); + }); + + it('keeps relative and query-only replies', () => { + expect( + normalizeStalkerPlaybackCommand('ffmpeg /media/file_1.mpg') + ).toBe('/media/file_1.mpg'); + expect(normalizeStalkerPlaybackCommand('ffmpeg ?token=abc')).toBe( + '?token=abc' + ); + }); + }); + + describe('resolveStalkerPlaybackUrl', () => { + it('returns an absolute reply as-is regardless of scheme case', () => { + expect( + resolveStalkerPlaybackUrl( + PORTAL, + '/media/source.mpg', + 'ffmpeg HTTPS://cdn.example/a.ts' + ) + ).toBe('HTTPS://cdn.example/a.ts'); + }); + + it('resolves a relative reply against the installation base', () => { + expect( + resolveStalkerPlaybackUrl( + PORTAL, + '/media/source.mpg', + '/media/video_7.mpg' + ) + ).toBe('http://demo.example/stalker_portal/media/video_7.mpg'); + }); + + it('appends a query-only reply to an uppercase-scheme command', () => { + // The original `cmd` is already an address, so the query belongs + // on it rather than on the portal base. + expect( + resolveStalkerPlaybackUrl( + PORTAL, + 'auto HTTP://cdn.example/live/9.m3u8', + '?token=xyz' + ) + ).toBe('HTTP://cdn.example/live/9.m3u8?token=xyz'); + }); + + it('appends a query-only reply to a relative command via the base', () => { + expect( + resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '?token=xyz') + ).toBe('http://demo.example/stalker_portal/ch/9?token=xyz'); + }); + + it('returns an empty string for an empty reply', () => { + expect(resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '')).toBe(''); + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts new file mode 100644 index 000000000..53487d4e6 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-playback-command.utils.ts @@ -0,0 +1,125 @@ +/** + * Pure `cmd` handling shared by every Stalker playback path. + * + * Kept apart from the request helpers so the link-semantics decision (which + * needs the normalizer) and the request helpers (which need the decision) do + * not import each other. + */ + +/** + * RFC 3986 makes the scheme case-insensitive and portals are inconsistent + * about it, so every scheme test here goes through this rather than + * `startsWith('http://')` — which would send `HTTP://…` down the + * "not a URL" branch of whichever check it hit. + */ +const HTTP_SCHEME = /^https?:\/\//i; + +/** + * The authority must be non-empty: `http:///ch/1` silently reinterprets the + * first path segment as the host (`URL` reports `ch`), so a malformed command + * would otherwise be handed to the player as a nonsense address instead of + * going to the portal to be resolved. + */ +const HTTP_URL_WITH_AUTHORITY = /^https?:\/\/[^/]/i; + +export function hasHttpScheme(value: string): boolean { + return HTTP_SCHEME.test(value); +} + +/** A command that is usable as an address on its own. */ +export function isPlayableHttpUrl(value: string): boolean { + return HTTP_URL_WITH_AUTHORITY.test(value); +} + +export function normalizeStalkerPlaybackCommand(value: string): string { + const trimmed = String(value ?? '').trim(); + if (!trimmed) { + return ''; + } + + const splitAt = trimmed.indexOf(' '); + if (splitAt > 0) { + const candidate = trimmed.slice(splitAt + 1).trim(); + if ( + hasHttpScheme(candidate) || + candidate.startsWith('/') || + candidate.startsWith('?') + ) { + return candidate; + } + } + + return trimmed; +} + +export function resolveStalkerPlaybackUrl( + portalUrl: string, + originalCmd: string, + responseCmd: string +): string { + const url = normalizeStalkerPlaybackCommand(responseCmd); + if (!url) { + return ''; + } + + if (hasHttpScheme(url)) { + return url; + } + + try { + const portalUrlObj = new URL(portalUrl); + // 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 = ''; + 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; + } + } + } + + if (url.startsWith('?')) { + const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd); + if (hasHttpScheme(normalizedCmd)) { + return `${normalizedCmd}${url}`; + } + + return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`; + } + + if (url.startsWith('/')) { + return `${portalUrlObj.origin}${basePath}${url}`; + } + } catch { + return url; + } + + return url; +} + +export function shouldResolveMovieFileId( + item: { has_files?: unknown } | null | undefined, + cmd: string +): boolean { + return ( + item?.has_files !== undefined && + !cmd.includes('://') && + cmd.includes('/media/') && + !cmd.includes('/media/file_') + ); +} 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 38e94137f..302f33ce5 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 @@ -1,10 +1,10 @@ import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from '../../stalker-session.service'; +import { shouldResolveMovieFileId } from './stalker-playback-command.utils'; import { fetchStalkerExpireDate, fetchStalkerMovieFileId, fetchStalkerPlaybackLink, - shouldResolveMovieFileId, } from './stalker-player-request.utils'; const PLAYLIST = { @@ -22,7 +22,10 @@ describe('stalker-player-request.utils', () => { let dataService: { sendIpcEvent: jest.Mock, unknown[]>; }; - let stalkerSession: Pick; + let stalkerSession: Pick< + StalkerSessionService, + 'makeAuthenticatedRequest' | 'ensureToken' + >; beforeEach(() => { dataService = { @@ -30,6 +33,7 @@ describe('stalker-player-request.utils', () => { }; stalkerSession = { makeAuthenticatedRequest: jest.fn(), + ensureToken: jest.fn().mockResolvedValue({ token: null }), }; }); @@ -180,6 +184,284 @@ describe('stalker-player-request.utils', () => { ); }); + describe('temporary-link semantics', () => { + const deps = () => ({ + dataService: dataService as never, + stalkerSession: stalkerSession as StalkerSessionService, + }); + + it('establishes the session before returning a static url on a full portal', async () => { + // Minting the link used to be what authenticated. The global + // collection detail sets playlist + item straight from a + // persisted row, so a cold-start VOD would otherwise play a + // same-host gated stream with no Bearer token. + const fullPortal = { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta; + (stalkerSession.ensureToken as jest.Mock).mockResolvedValue({ + token: 'TOKEN-WARM', + }); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: fullPortal, + selectedContentType: 'vod', + cmd: 'ffrt3 http://demo.example/movies/1.mkv', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe('http://demo.example/movies/1.mkv'); + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ _id: PLAYLIST._id }) + ); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('falls back to create_link for a portal-owned url with no session', async () => { + // Serving a same-host static URL without a token means serving a + // known 401. The request path both mints a URL carrying its own + // token and is the only path that can observe a failure and + // trigger the lazy portal repair. + (stalkerSession.ensureToken as jest.Mock).mockResolvedValue({ + token: null, + }); + // A full portal dispatches through the authenticated session, not + // the raw IPC bridge. + ( + stalkerSession.makeAuthenticatedRequest as jest.Mock + ).mockResolvedValue({ + js: { cmd: 'http://demo.example/tmp/1.mkv?tok=1' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta, + selectedContentType: 'vod', + cmd: 'ffrt3 http://demo.example/movies/1.mkv', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe('http://demo.example/tmp/1.mkv?tok=1'); + expect( + stalkerSession.makeAuthenticatedRequest + ).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + }) + ); + }); + + it('serves a foreign-host static url without contacting the portal', async () => { + // A CDN stream never needs the portal session, so it must not be + // pushed onto the request path — and must not wait on a handshake + // either: that is up to 15 s per request against a portal that may + // be offline while the CDN is reachable. + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta, + selectedContentType: 'vod', + cmd: 'ffrt3 http://cdn.example/movies/1.mkv', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe('http://cdn.example/movies/1.mkv'); + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('handshakes against a repaired endpoint, not the stale one', async () => { + // `executeStalkerRequest` applies the repair override on its first + // line, so the static path must too — handshaking against the + // configuration a repair has already proven broken would strand + // the session. + const stale = { + ...PLAYLIST, + portalUrl: 'http://demo.example/portal.php', + isFullStalkerPortal: true, + } as PlaylistMeta; + const repaired = { + ...stale, + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + } as PlaylistMeta; + // Portal-owned URL: only those reach the handshake now, since a + // foreign host never needs the session. + (stalkerSession.ensureToken as jest.Mock).mockResolvedValue({ + token: 'TOKEN-REPAIRED', + }); + + 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: stale, + selectedContentType: 'itv', + cmd: 'ffrt3 http://demo.example/live/42.m3u8', + linkFlags: { use_http_tmp_link: '0' }, + } + ); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ portalUrl: repaired.portalUrl }) + ); + }); + + it('skips the handshake for a simple portal', async () => { + // The command has to be PORTAL-OWNED, or the foreign-host early + // return would skip the handshake by itself and this would pass + // without saying anything about portal mode. `PLAYLIST` is a + // simple portal, so the skip here can only come from the mode. + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: PLAYLIST, + selectedContentType: 'itv', + cmd: 'ffrt3 http://demo.example/live/42.m3u8', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe('http://demo.example/live/42.m3u8'); + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('falls back to create_link when the handshake throws', async () => { + // A throwing handshake must not propagate — it leaves the session + // unusable, and a portal-owned URL then goes to `create_link` + // rather than being served as a known 401. The command has to be + // portal-owned for the handshake to run at all. + ( + stalkerSession.ensureToken as jest.Mock + ).mockRejectedValue(new Error('portal down')); + ( + stalkerSession.makeAuthenticatedRequest as jest.Mock + ).mockResolvedValue({ + js: { cmd: 'http://demo.example/tmp/1.mkv?tok=1' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: { + ...PLAYLIST, + isFullStalkerPortal: true, + } as PlaylistMeta, + selectedContentType: 'vod', + cmd: 'ffrt3 http://demo.example/movies/1.mkv', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(stalkerSession.ensureToken).toHaveBeenCalled(); + expect(streamUrl).toBe('http://demo.example/tmp/1.mkv?tok=1'); + }); + + it('plays the static cmd of an unflagged row without asking the portal', async () => { + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: PLAYLIST, + selectedContentType: 'itv', + cmd: 'ffrt3 http://cdn.example/live/42.m3u8', + linkFlags: { + use_http_tmp_link: '0', + use_load_balancing: '0', + }, + }); + + expect(streamUrl).toBe('http://cdn.example/live/42.m3u8'); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it.each(['use_http_tmp_link', 'use_load_balancing'] as const)( + 'mints a temporary link when %s is set', + async (flag) => { + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: 'http://cdn.example/tmp/42.m3u8?tok=1' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: PLAYLIST, + selectedContentType: 'itv', + cmd: 'ffrt3 http://cdn.example/live/42.m3u8', + linkFlags: { [flag]: '1' }, + }); + + expect(streamUrl).toBe('http://cdn.example/tmp/42.m3u8?tok=1'); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.CreateLink, + }), + }) + ); + } + ); + + it('mints a link when the caller supplies no flags', async () => { + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: 'http://cdn.example/tmp/42.m3u8' }, + }); + + await fetchStalkerPlaybackLink(deps(), { + playlist: PLAYLIST, + selectedContentType: 'itv', + cmd: 'ffrt3 http://cdn.example/live/42.m3u8', + }); + + expect(dataService.sendIpcEvent).toHaveBeenCalled(); + }); + + it('always mints a link for an episode, whose cmd addresses the series', async () => { + // `series` selects the episode server-side, so the parent row's + // static cmd is not an answer even when it is unflagged. + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: 'http://cdn.example/tmp/ep3.m3u8' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: PLAYLIST, + selectedContentType: 'series', + cmd: 'ffrt3 http://cdn.example/series/7.m3u8', + series: 3, + linkFlags: { + use_http_tmp_link: '0', + use_load_balancing: '0', + }, + }); + + expect(streamUrl).toBe('http://cdn.example/tmp/ep3.m3u8'); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ series: '3' }), + }) + ); + }); + + it('still mints a link for a relative unflagged VOD command', async () => { + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: '/media/video_77.mpg' }, + }); + + const streamUrl = await fetchStalkerPlaybackLink(deps(), { + playlist: PLAYLIST, + selectedContentType: 'vod', + cmd: '/media/file_42.mpg', + linkFlags: { use_http_tmp_link: '0' }, + }); + + expect(streamUrl).toBe( + 'http://demo.example/stalker_portal/media/video_77.mpg' + ); + }); + }); + it('returns a localized expire date string from account info', async () => { const expireDate = 1_713_139_200; dataService.sendIpcEvent.mockResolvedValue({ 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 a5649fa03..8739edd27 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 @@ -1,5 +1,6 @@ import { DataService } from '@iptvnator/services'; import { + isStalkerStreamCredentialSafe, PlaylistMeta, StalkerPortalActions, StalkerPortalItem, @@ -8,6 +9,12 @@ import { StalkerSessionService } from '../../stalker-session.service'; import { StalkerContentTypes } from '../../stalker-content-types'; import { StalkerContentType } from '../stalker-store.contracts'; import { + resolveStalkerStaticPlaybackUrl, + type StalkerLinkFlagSource, +} from './stalker-link-semantics.utils'; +import { resolveStalkerPlaybackUrl } from './stalker-playback-command.utils'; +import { + ensureStalkerSession, executeStalkerRequest, type StalkerPortalRepairApi, } from './stalker-request.utils'; @@ -29,108 +36,13 @@ export interface StalkerPlayerRequestDeps { portalRepair?: StalkerPortalRepairApi; } -export interface StalkerPlayableItemLike extends StalkerPortalItem { +export interface StalkerPlayableItemLike + extends StalkerPortalItem, + StalkerLinkFlagSource { cmd?: string; has_files?: unknown; } -export function normalizeStalkerPlaybackCommand(value: string): string { - const trimmed = String(value ?? '').trim(); - if (!trimmed) { - return ''; - } - - const splitAt = trimmed.indexOf(' '); - if (splitAt > 0) { - const candidate = trimmed.slice(splitAt + 1).trim(); - if ( - candidate.startsWith('http://') || - candidate.startsWith('https://') || - candidate.startsWith('/') || - candidate.startsWith('?') - ) { - return candidate; - } - } - - return trimmed; -} - -export function resolveStalkerPlaybackUrl( - portalUrl: string, - originalCmd: string, - responseCmd: string -): string { - const url = normalizeStalkerPlaybackCommand(responseCmd); - if (!url) { - return ''; - } - - if (url.startsWith('http://') || url.startsWith('https://')) { - return url; - } - - try { - const portalUrlObj = new URL(portalUrl); - // 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 = ''; - 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; - } - } - } - - if (url.startsWith('?')) { - const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd); - if ( - normalizedCmd.startsWith('http://') || - normalizedCmd.startsWith('https://') - ) { - return `${normalizedCmd}${url}`; - } - - return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`; - } - - if (url.startsWith('/')) { - return `${portalUrlObj.origin}${basePath}${url}`; - } - } catch { - return url; - } - - return url; -} - -export function shouldResolveMovieFileId( - item: Pick | null | undefined, - cmd: string -): boolean { - return ( - item?.has_files !== undefined && - !cmd.includes('://') && - cmd.includes('/media/') && - !cmd.includes('/media/file_') - ); -} - export async function fetchStalkerPlaybackLink( deps: StalkerPlayerRequestDeps, options: { @@ -139,8 +51,58 @@ export async function fetchStalkerPlaybackLink( cmd: string; series?: number; forcedContentType?: StalkerContentType; + /** + * The catalog row this `cmd` came from. Without it every playback + * mints a temporary link; with it, rows that set neither + * `use_http_tmp_link` nor `use_load_balancing` play their static + * `cmd` and never touch the portal. + */ + linkFlags?: StalkerLinkFlagSource | null; } ): Promise { + // An episode is selected server-side by the `series` parameter, so a + // series request has no static answer even when the parent row is + // unflagged — the static `cmd` addresses the series, not the episode. + if (options.series === undefined) { + const staticUrl = resolveStalkerStaticPlaybackUrl( + options.linkFlags, + options.cmd + ); + if (staticUrl) { + // Returning here skips the request that used to authenticate. + // Not every caller has a warm session: the global collection + // detail sets the playlist and the item straight from a persisted + // row, with no catalog load in between, so a VOD opened from + // Favorites on a cold start would play a same-host gated stream + // without a Bearer token. Warming at this single choke point + // covers ITV, VOD, radio and downloads alike. + // Classify BEFORE authenticating. A stream on a foreign host never + // needs the portal session, and warming it anyway would block + // playback behind a handshake worth up to 15 s per request against + // a slow or offline portal (`stalker.events.ts`) for a result that + // is then discarded — the CDN is reachable even when the portal is + // not. + if ( + !isStalkerStreamCredentialSafe( + options.playlist.portalUrl ?? '', + staticUrl + ) + ) { + return staticUrl; + } + + // Portal-owned: it may be gated on the Bearer token, and minting + // the link used to be what established the session. Without a + // usable one, serving this would be serving a known 401 — fall + // back to the request path instead, which both mints a URL that + // carries its own token and is the only path that can observe a + // failure and trigger the lazy portal repair. + if (await ensureStalkerSession(deps, options.playlist)) { + return staticUrl; + } + } + } + const contentType = options.forcedContentType ?? options.selectedContentType; const response = await executeStalkerRequest( 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 f07f4b622..7659936f6 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 @@ -38,6 +38,64 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist { } as Playlist; } +/** + * Establishes the portal session WITHOUT issuing a content request. + * + * Needed wherever playback returns a URL without calling the portal: minting + * a link used to be what authenticated, and tokens live in memory only + * (`StalkerSessionService.tokenCache`), so a cold start would otherwise hand + * a same-host gated stream headers with no `Authorization`. `ensureToken` + * also validates the identity the cached token was negotiated for, which the + * raw `getCachedToken()` the header builders use cannot. + * + * Cheap where it is not needed: a simple portal returns immediately, and a + * warm cache with a matching fingerprint resolves without a request. + * + * Best-effort on purpose — a static URL may point at a CDN that needs no + * credentials, so a failed handshake must not cost the user their playback. + * + * Returns whether the session is good enough to serve a stream that needs + * portal credentials: `true` for a portal that needs no token at all and for + * one that has a usable token, `false` for a full portal left without one. + * Callers use it to decide whether a portal-owned static URL can be trusted or + * whether they should fall back to the request path — which is also the path + * that can observe a failure and trigger the lazy repair. + */ +export async function ensureStalkerSession( + deps: StalkerRequestDeps, + playlist: PlaylistMeta | undefined, + logger?: { warn(...args: unknown[]): void } +): Promise { + if (!playlist) { + return false; + } + + // The repair override is applied here for the same reason + // `executeStalkerRequest` applies it on its first line: a completed repair + // may have moved the endpoint or the mode while the caller still holds the + // pre-repair row, and handshaking against the configuration a repair has + // already proven broken would strand the session. + const effective = deps.portalRepair + ? deps.portalRepair.applyOverride(playlist) + : playlist; + + // A token-free panel needs no session, so it can serve credentialed + // streams as well as it ever could. + if (!isFullStalkerPortalPlaylist(effective)) { + return true; + } + + try { + const { token } = await deps.stalkerSession.ensureToken( + toStalkerSessionPlaylist(effective) + ); + return Boolean(token); + } catch (error) { + logger?.warn('Could not establish the Stalker session', error); + return false; + } +} + /** * Routes one Stalker request according to the playlist's portal mode: * full portals go through the authenticated session (handshake + Bearer diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index ded5cb3c3..80c0f77a3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -262,8 +262,14 @@ export class StalkerCatalogDetailComponent implements OnDestroy { playlist: this.catalog.playlist(), downloadsService: this.downloadsService, fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id), - fetchLinkToPlay: (portalUrl, macAddress, cmd) => - this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd), + fetchLinkToPlay: (portalUrl, macAddress, cmd, linkFlags) => + this.catalog.fetchLinkToPlay( + portalUrl, + macAddress, + cmd, + undefined, + linkFlags + ), language: this.translateService.currentLang || this.translateService.defaultLang || diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts index 0fc8e061d..b0749ef81 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.spec.ts @@ -120,4 +120,106 @@ describe('startStalkerVodDownload', () => { expect(snapshot).not.toHaveProperty('rating'); expect(snapshot).not.toHaveProperty('tmdbId'); }); + + it('hands the movie row on so an unflagged CDN movie yields a permanent URL', async () => { + // The download row stores whatever URL comes back and retry replays + // it, so a row that needs no temporary link must be recognised as + // such — a 5 s link would survive only the first attempt. + const startDownload = jest.fn().mockResolvedValue({ success: true }); + const fetchLinkToPlay = jest + .fn() + .mockResolvedValue('https://cdn.example.test/movie.mpg'); + const data = { + id: '42', + cmd: 'ffrt3 https://cdn.example.test/movie.mpg', + use_http_tmp_link: '0', + use_load_balancing: '0', + info: { name: 'Static Movie' }, + }; + + await startStalkerVodDownload( + { + type: 'stalker', + playlistId: 'stalker-1', + cmd: data.cmd, + data, + } as unknown as VodDetailsItem, + { + playlist: { + id: 'stalker-1', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', + }, + downloadsService: { startDownload }, + fetchMovieFileId: jest.fn(), + fetchLinkToPlay, + } + ); + + expect(fetchLinkToPlay).toHaveBeenCalledWith( + 'https://stalker.example.test', + '00:1A:79:12:34:56', + data.cmd, + expect.objectContaining({ + use_http_tmp_link: '0', + use_load_balancing: '0', + }) + ); + expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://cdn.example.test/movie.mpg', + }) + ); + }); + + it('keeps minting a link for an unflagged movie on the portal host', async () => { + // A download cannot carry portal credentials — the main-process + // stored-header allowlist is User-Agent/Origin/Referer only. So a + // same-host static URL, which the portal may gate on the mac cookie + // or Bearer token, must still go through create_link and use the + // minted URL's own access token. + const startDownload = jest.fn().mockResolvedValue({ success: true }); + const fetchLinkToPlay = jest + .fn() + .mockResolvedValue('https://stalker.example.test/tmp/42?tok=1'); + const data = { + id: '42', + cmd: 'ffrt3 https://stalker.example.test/movies/42.mkv', + use_http_tmp_link: '0', + use_load_balancing: '0', + info: { name: 'Same Host Movie' }, + }; + + await startStalkerVodDownload( + { + type: 'stalker', + playlistId: 'stalker-1', + cmd: data.cmd, + data, + } as unknown as VodDetailsItem, + { + playlist: { + id: 'stalker-1', + portalUrl: 'https://stalker.example.test/portal.php', + macAddress: '00:1A:79:12:34:56', + }, + downloadsService: { startDownload }, + fetchMovieFileId: jest.fn(), + fetchLinkToPlay, + } + ); + + // No row handed over ⇒ the static shortcut is not offered. + expect(fetchLinkToPlay).toHaveBeenCalledWith( + 'https://stalker.example.test/portal.php', + '00:1A:79:12:34:56', + data.cmd, + undefined + ); + expect(startDownload).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://stalker.example.test/tmp/42?tok=1', + }) + ); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts index 1d571bba0..e677d1d62 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts @@ -10,7 +10,10 @@ type StalkerVodDetailsItem = Extract; import { normalizeStalkerEntityId, normalizeStalkerEntityIdAsNumber, + resolveStalkerStaticPlaybackUrl, + type StalkerLinkFlagSource, } from '@iptvnator/portal/stalker/data-access'; +import { isStalkerStreamCredentialSafe } from '@iptvnator/shared/interfaces'; /** * Starting a download of a Stalker VOD item. @@ -25,6 +28,9 @@ import { export interface DownloadVodData { id?: string | number; has_files?: unknown; + /** Temporary-link flags — see {@link StalkerLinkFlagSource}. */ + use_http_tmp_link?: unknown; + use_load_balancing?: unknown; title?: string; category_id?: string | number; info?: { @@ -61,7 +67,8 @@ export interface StalkerVodDownloadDeps { fetchLinkToPlay: ( portalUrl: string, macAddress: string, - cmd: string + cmd: string, + linkFlags?: StalkerLinkFlagSource | null ) => Promise; language?: string; } @@ -124,10 +131,30 @@ export async function startStalkerVodDownload( firstText(itemData?.info?.name, itemData?.title) ?? 'Unknown'; const cmdToUse = await resolveDownloadCmd(item, itemData, deps); + // A movie whose row needs no temporary link yields a permanent URL, which + // is what the download row stores — a 5 s link would only ever survive the + // first attempt. + // + // But a download cannot authenticate the way playback can: the stored + // header allowlist in the main process is User-Agent / Origin / Referer + // only (`download-request-headers.ts`), with no Cookie or Authorization. + // So the static shortcut is offered only for a URL that needs no portal + // credentials — i.e. one the shared classifier says is NOT portal-owned. + // A same-host movie keeps going through `create_link`, whose minted URL + // carries its own access token. + const staticCandidate = resolveStalkerStaticPlaybackUrl( + itemData, + cmdToUse + ); + const staticUrlIsSelfAuthenticating = + staticCandidate !== null && + !isStalkerStreamCredentialSafe(playlist.portalUrl, staticCandidate); + const url = await deps.fetchLinkToPlay( playlist.portalUrl, playlist.macAddress, - cmdToUse + cmdToUse, + staticUrlIsSelfAuthenticating ? itemData : undefined ); if (!url) { return; diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 4a9188a27..bb9863cd4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -10,6 +10,7 @@ import { import { buildStalkerSelectedVodItem, isStalkerSeriesFlag, + StalkerLinkFlagSource, StalkerStore, StalkerVodSource, } from '@iptvnator/portal/stalker/data-access'; @@ -251,9 +252,17 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< async fetchLinkToPlay( portalUrl: string, macAddress: string, - cmd: string + cmd: string, + series?: number, + linkFlags?: StalkerLinkFlagSource | null ): Promise { - return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd); + return this.stalkerStore.fetchLinkToPlay( + portalUrl, + macAddress, + cmd, + series, + linkFlags + ); } resolveVodPlayback( diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts index 57a9343cc..a758c289f 100644 --- a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts @@ -65,6 +65,33 @@ describe('isStalkerStreamCredentialSafe', () => { ).toBe(false); }); + it('treats a terminal DNS root dot as the same host', () => { + // `portal.example.` and `portal.example` resolve identically, but + // `URL` preserves the dot — comparing literally classified a + // portal-owned stream as third-party and stripped its credentials. + expect( + isStalkerStreamCredentialSafe( + PORTAL, + 'http://portal.example./live/ch1.ts' + ) + ).toBe(true); + expect( + isStalkerStreamCredentialSafe( + 'http://portal.example./portal.php', + 'http://portal.example/live/ch1.ts' + ) + ).toBe(true); + }); + + it('still rejects a different host that merely shares a suffix', () => { + expect( + isStalkerStreamCredentialSafe( + PORTAL, + 'http://evil.portal.example./live/ch1.ts' + ) + ).toBe(false); + }); + it('fails closed on missing or unparseable URLs', () => { expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false); expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false); diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts index ec8848748..d20428fcd 100644 --- a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts @@ -15,6 +15,16 @@ * credentials to a third party, and an https→http downgrade would replay a * TLS-obtained session in cleartext — both stay credential-free. */ +/** + * A terminal dot is the DNS root: `portal.example.` and `portal.example` + * resolve to the same host, but `URL` preserves the dot, so a literal + * comparison would call them different origins — classifying a portal-owned + * stream as third-party and stripping the credentials it needs. + */ +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/\.$/, ''); +} + export function isStalkerStreamCredentialSafe( portalUrl: string | undefined | null, streamUrl: string | undefined | null @@ -36,7 +46,10 @@ export function isStalkerStreamCredentialSafe( return false; } - if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) { + if ( + normalizeHostname(portal.hostname) !== + normalizeHostname(stream.hostname) + ) { return false; } From d2a83164ecbcb7dd29a33b7ed5529a72a17c3ea3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:06:47 +0200 Subject: [PATCH 3/3] feat(stalker): protocol-correct auth lifecycle (#1354) --- .changes/stalker-auth-lifecycle.md | 10 + CLAUDE.md | 10 + .../src/app/events/stalker.events.ts | 18 +- apps/stalker-mock-server/README.md | 2 +- .../src/app/handlers/auth-handlers.spec.ts | 11 +- .../src/app/handlers/get-profile.handler.ts | 9 +- apps/web-e2e/src/stalker.e2e.ts | 264 +++++- apps/web/src/assets/i18n/ar.json | 9 +- apps/web/src/assets/i18n/ary.json | 9 +- apps/web/src/assets/i18n/by.json | 9 +- apps/web/src/assets/i18n/de.json | 9 +- apps/web/src/assets/i18n/el.json | 9 +- apps/web/src/assets/i18n/en.json | 9 +- apps/web/src/assets/i18n/es.json | 9 +- apps/web/src/assets/i18n/fr.json | 9 +- apps/web/src/assets/i18n/hu.json | 9 +- apps/web/src/assets/i18n/it.json | 9 +- apps/web/src/assets/i18n/ja.json | 9 +- apps/web/src/assets/i18n/ko.json | 9 +- apps/web/src/assets/i18n/nl.json | 9 +- apps/web/src/assets/i18n/pl.json | 9 +- apps/web/src/assets/i18n/pt.json | 9 +- apps/web/src/assets/i18n/ru.json | 9 +- apps/web/src/assets/i18n/tr.json | 9 +- apps/web/src/assets/i18n/zh.json | 9 +- apps/web/src/assets/i18n/zhtw.json | 9 +- docs/architecture/stalker-portal.md | 217 ++++- .../stalker-portal-import.component.html | 17 +- .../stalker-portal-import.component.spec.ts | 183 +++- .../stalker-portal-import.component.ts | 81 +- .../stream-resolver.service.spec.ts | 217 ++++- .../lib/collection/stream-resolver.service.ts | 78 +- libs/portal/stalker/data-access/src/index.ts | 4 + .../src/lib/stalker-auth.api.spec.ts | 423 +++++++++ .../data-access/src/lib/stalker-auth.api.ts | 546 ++++++++++++ .../stalker-portal-discovery.service.spec.ts | 161 +++- .../lib/stalker-portal-discovery.service.ts | 128 ++- .../src/lib/stalker-portal-error.spec.ts | 55 ++ .../src/lib/stalker-portal-error.ts | 93 ++ .../lib/stalker-portal-repair.service.spec.ts | 92 +- .../src/lib/stalker-portal-repair.service.ts | 48 +- .../lib/stalker-response-classification.ts | 48 + .../src/lib/stalker-session-store.ts | 266 ++++++ .../src/lib/stalker-session.service.spec.ts | 641 ++++++++++++- .../src/lib/stalker-session.service.ts | 842 ++++++------------ .../src/lib/stalker-token-cache.ts | 91 ++ .../lib/stalker-watchdog.controller.spec.ts | 168 ++++ .../src/lib/stalker-watchdog.controller.ts | 294 ++++++ .../src/lib/playlists.service.spec.ts | 109 +++ libs/services/src/lib/playlists.service.ts | 46 + .../interfaces/src/lib/playlist.interface.ts | 17 + .../src/lib/stalker-auth-failure.util.spec.ts | 54 ++ .../src/lib/stalker-auth-failure.util.ts | 70 +- .../workspace-context-panel.component.html | 4 +- .../workspace-context-panel.component.ts | 27 +- tools/eslint/max-lines-baseline.mjs | 1 - 56 files changed, 4807 insertions(+), 709 deletions(-) create mode 100644 .changes/stalker-auth-lifecycle.md create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-session-store.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts diff --git a/.changes/stalker-auth-lifecycle.md b/.changes/stalker-auth-lifecycle.md new file mode 100644 index 000000000..3b62dc566 --- /dev/null +++ b/.changes/stalker-auth-lifecycle.md @@ -0,0 +1,10 @@ +--- +type: feature +area: stalker +--- + +Stalker portals that ask for a login and password now work: the import dialog +gained username/password fields and the app completes the portal's do_auth +step. When a portal refuses access, its own explanation is shown instead of a +generic error, saved sessions resume without re-authenticating, and the +keep-alive ping follows the portal's cadence. diff --git a/CLAUDE.md b/CLAUDE.md index a98df166d..39352c198 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1111,6 +1111,16 @@ engine` (restart required) or - Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog"). - Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read. +**Stalker Session Authentication**: + +- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`. +- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. +- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. +- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections. +- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. +- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting. +- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle"). + **Favorites and Recently Viewed**: - Per-playlist favorites and global favorites diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 84b64d985..056cc73a1 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -6,6 +6,8 @@ import axios, { AxiosRequestConfig } from 'axios'; import { ipcMain } from 'electron'; import { + classifyStalkerAuthFailureBody, + createStalkerAuthFailureMarker, PortalDebugEvent, STALKER_REQUEST, buildStalkerIdentityRequestContext, @@ -111,6 +113,18 @@ ipcMain.handle( throw httpError; } + // The portal answers auth failures with HTTP 200 and a plain + // text/html body ("Authorization failed.", "Access denied.", + // "Unauthorized request."). Classify them here so the renderer + // receives a structured marker instead of pattern-matching raw + // response text. + const authFailureBody = classifyStalkerAuthFailureBody( + response.data + ); + const responseData = authFailureBody + ? createStalkerAuthFailureMarker(authFailureBody) + : response.data; + // Return the response data if ( params.action === 'create_link' && @@ -136,12 +150,12 @@ ipcMain.handle( durationMs: Date.now() - startedAt, status: 'success', request: debugRequest, - response: response.data, + response: responseData, }; emitPortalDebugEvent(debugEvent); } - return response.data; + return responseData; } catch (error) { if (payload.requestId) { const debugEvent: PortalDebugEvent = { diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index fa72541fc..ed222f3ce 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -82,7 +82,7 @@ actually get wrong: | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | | `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | | `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing | -| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work | +| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) | | `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials | | `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | diff --git a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts index 37d551acd..f33f57b58 100644 --- a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts +++ b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts @@ -11,10 +11,9 @@ import { handleHandshake } from './handshake.handler'; /** * Handler-level coverage for the authentication actions. * - * The e2e suite drives the app against this server, but the login-required - * flow cannot be reached from the client yet (its `do_auth` path is dormant - * and sends empty credentials), so the scenario is pinned down here rather - * than only asserted in prose. + * The e2e suite drives the app through this flow end to end; these handler + * tests pin the SERVER half of the contract independently, so a client-side + * regression and a mock-side one cannot mask each other. * * Handlers are called directly instead of through the dispatcher: the * dispatcher pulls in every content handler and therefore the faker-based @@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => { })['token'] as string; expect(token).toMatch(/^[0-9A-F]{32}$/); - // The app sends auth_second_step=1 on its very first profile request, - // so that parameter alone must not satisfy the scenario. + // A client could mislabel its first profile as the second auth step; + // that parameter alone must not satisfy the scenario. expect( invoke( handleGetProfile, diff --git a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts index 463fb8466..ef5ff3b95 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts @@ -33,10 +33,11 @@ export function handleGetProfile( return; } - // Gate on the actual do_auth completion, not on auth_second_step: the app - // sends auth_second_step=1 on its very first get_profile, so a parameter - // check would let the login-required flow be bypassed without ever - // exercising status 2 -> do_auth -> profile retry. + // Gate on the actual do_auth completion, not on auth_second_step. The + // client now sends `auth_second_step=0` first and `1` only on the retry + // after do_auth, so a parameter check would happen to work — but it would + // also silently pass for any client that mislabels its first profile, + // which is exactly the protocol mistake this scenario exists to catch. if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) { res.json({ js: { diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 07597d02b..15d236808 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -48,6 +48,11 @@ import { * `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local * all-project run shows a lone auth failure that passes on rerun, this is why; * `--project=chromium` reproduces CI exactly. + * + * Most tests here tolerate that reset anyway — they re-authenticate, or assert + * that a NEW token appears. The token-reuse test cannot: its assertion IS that + * the portal session survives, so it uses per-project MACs held outside + * `OWNED_MACS`. See `AUTH_REUSE_MACS`. */ test.describe.configure({ mode: 'serial' }); @@ -82,6 +87,13 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; */ const STATIC_CMD_MAC = '00:1A:79:00:00:0A'; +/** + * Login-required scenario MAC — get_profile answers status 2 until do_auth + * completes with non-empty credentials (empty credentials return {js:false}, + * as the operator billing script would). + */ +const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08'; + /** * Dedicated MACs for the full-portal authentication tests. Mock state is keyed * by MAC, so keeping these distinct from the content scenarios above means an @@ -90,6 +102,25 @@ const STATIC_CMD_MAC = '00:1A:79:00:00:0A'; */ const AUTH_FLOW_MAC = '00:1A:79:AD:00:01'; const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03'; + +/** + * The token-reuse test needs the portal session to SURVIVE untouched between + * its import and its reload — that persistence is the whole assertion. Every + * other test here is reset-tolerant (they either re-authenticate or assert a + * new token), so they can share `OWNED_MACS`, which each `beforeEach` clears. + * + * `mode: 'serial'` only serializes within one project; the browser projects + * still run concurrently, so a sibling project's reset would rotate this + * session mid-test. Hence one MAC per project, and deliberately NOT in + * `OWNED_MACS`: nothing may reset them. Leftover state from an earlier run is + * harmless — the import negotiates and adopts its own token first. + */ +const AUTH_REUSE_MACS: Record = { + chromium: '00:1A:79:AD:01:01', + firefox: '00:1A:79:AD:01:02', + webkit: '00:1A:79:AD:01:03', +}; +const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04'; /** * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for * it, so no token is ever adopted and content requests fail permanently. @@ -138,6 +169,7 @@ const OWNED_MACS = [ EMBEDDED_SERIES_MAC, LEGACY_PAGINATION_MAC, STATIC_CMD_MAC, + LOGIN_REQUIRED_MAC, AUTH_FLOW_MAC, AUTH_REAUTH_MAC, AUTH_REJECTED_MAC, @@ -212,9 +244,21 @@ async function addStalkerPortal( */ async function addFullStalkerPortal( page: Page, - options: { name?: string; mac: string; expectContent?: boolean } + options: { + name?: string; + mac: string; + expectContent?: boolean; + username?: string; + password?: string; + } ): Promise { - const { name = 'Full Stalker Portal', mac, expectContent = true } = options; + const { + name = 'Full Stalker Portal', + mac, + expectContent = true, + username, + password, + } = options; await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); @@ -224,6 +268,12 @@ async function addFullStalkerPortal( await setInputValue(dialog.locator('input#title'), name); await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); await setInputValue(dialog.locator('input#macAddress'), mac); + if (username !== undefined) { + await setInputValue(dialog.locator('input#username'), username); + } + if (password !== undefined) { + await setInputValue(dialog.locator('input#password'), password); + } const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); await expect(addButton).toBeEnabled({ timeout: 10_000 }); @@ -991,12 +1041,171 @@ test.describe('@stalker full portal authentication', () => { .toBe(true); }); - test('never surfaces the portal plain-text auth failure as content', async ({ + test('completes the documented login flow behind get_profile status 2', async ({ + page, + }) => { + const requests = recordPortalRequests(page); + // The second auth step must only be claimed on the retry AFTER + // do_auth — a client that always sends auth_second_step=1 works + // against the mock but violates the documented protocol. + const profileSteps: string[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + if (url.searchParams.get('action') !== 'get_profile') { + return; + } + profileSteps.push(url.searchParams.get('auth_second_step') ?? ''); + }); + + await addFullStalkerPortal(page, { + mac: LOGIN_REQUIRED_MAC, + username: 'user', + password: 'secret', + }); + + // The mock only adopts the token (and answers content) after do_auth + // completed with non-empty credentials, so rendered categories prove + // the whole status 2 -> do_auth -> profile retry chain ran. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 30_000, + }); + + const actions = requests.map((entry) => entry.action); + expect(actions).toContain('do_auth'); + expect(actions.indexOf('get_profile')).toBeLessThan( + actions.indexOf('do_auth') + ); + expect(actions.lastIndexOf('get_profile')).toBeGreaterThan( + actions.indexOf('do_auth') + ); + + expect(profileSteps[0]).toBe('0'); + expect(profileSteps).toContain('1'); + + // Content only flows once the token is adopted, which the mock does + // only after do_auth completed. Look AFTER the last get_profile: + // discovery's classification probe is itself a token-less + // `get_genres`, so the first CONTENT_ACTIONS hit is that probe. + const contentRequest = requests + .slice(actions.lastIndexOf('get_profile') + 1) + .find((entry) => CONTENT_ACTIONS.includes(entry.action)); + expect(contentRequest).toBeDefined(); + expect(contentRequest?.token).toBeTruthy(); + }); + + test('explains a login-required portal and recovers once credentials are entered', async ({ + page, + }) => { + // First attempt without credentials: the import must fail with the + // portal's actual reason, not a generic "check URL and MAC" error. + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), 'Login Portal'); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue( + dialog.locator('input#macAddress'), + LOGIN_REQUIRED_MAC + ); + + const addButton = dialog.getByRole('button', { + name: 'Add', + exact: true, + }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + + await expect( + page.getByText(/requires a login and password/i) + ).toBeVisible({ timeout: 15_000 }); + // The dialog stays open so the user can add the credentials. + await expect(dialog).toBeVisible(); + + // Second attempt with credentials in the same dialog succeeds. + await setInputValue(dialog.locator('input#username'), 'user'); + await setInputValue(dialog.locator('input#password'), 'secret'); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 30_000, + }); + }); + + test('reuses the persisted token after a reload instead of re-running get_profile', async ({ + page, + }, testInfo) => { + const requests = recordPortalRequests(page); + const mac = + AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC; + + await addFullStalkerPortal(page, { mac }); + + await expect + .poll( + () => + requests.some( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token + ), + { timeout: 30_000 } + ) + .toBe(true); + + const sessionToken = requests.find( + (entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token + )?.token; + expect(sessionToken).toBeTruthy(); + + const requestCountBeforeReload = requests.length; + await page.reload(); + + // Wait on the UI rather than the request log: a reload restores the + // route, boots the app and re-authenticates before the first content + // request goes out, and rendered categories prove that whole chain + // finished (the portal only answers content for an adopted token). + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 60_000, + }); + + const afterReload = requests.slice(requestCountBeforeReload); + + // Content came back under the SAME token, negotiated by re-presenting + // the persisted one in the handshake. + expect( + afterReload.filter( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token === sessionToken + ).length + ).toBeGreaterThan(0); + + // The handshake re-presented the persisted token... + const reloadHandshake = afterReload.find( + (entry) => entry.action === 'handshake' + ); + expect(reloadHandshake?.token).toBe(sessionToken); + + // ...and because the portal returned it unchanged (idempotent + // handshake, still-adopted session), the profile round trip was + // skipped entirely. + expect( + afterReload.filter((entry) => entry.action === 'get_profile') + ).toHaveLength(0); + }); + + test('refuses a rejected portal at import and never renders its plain-text failure', async ({ page, request, }) => { - const requests = recordPortalRequests(page); - // A MAC outside the Infomir OUI makes the strict endpoint answer // get_profile with a bare {status:1}, so no token is ever adopted and // every content request keeps returning the plain-text failure. Unlike @@ -1014,25 +1223,38 @@ test.describe('@stalker full portal authentication', () => { ).json(); expect(failureBody.payload).toBe('Authorization failed.'); - await addFullStalkerPortal(page, { - mac: AUTH_REJECTED_MAC, - expectContent: false, + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), 'Rejected Portal'); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue( + dialog.locator('input#macAddress'), + AUTH_REJECTED_MAC + ); + + const addButton = dialog.getByRole('button', { + name: 'Add', + exact: true, }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); - // The app must have actually hit the failing portal... - await expect - .poll( - () => - requests.filter((entry) => - CONTENT_ACTIONS.includes(entry.action) - ).length, - { timeout: 30_000 } - ) - .toBeGreaterThan(0); + // `status: 1` means the portal refused this device, so the import must + // stop there instead of persisting a portal that can never load. (It + // used to be treated as success whenever the portal sent no msg text, + // which imported a dead source.) + await expect(page.getByText(/refused access/i)).toBeVisible({ + timeout: 15_000, + }); + await expect(dialog).toBeVisible(); + await expect(page).not.toHaveURL(/stalker/); - // ...and must never render the raw portal response as content. A - // portal answers auth failures with HTTP 200 + plain text, so an app - // that trusts the status code would happily paint these strings. + // And the raw portal response is never painted as UI. A portal answers + // auth failures with HTTP 200 + plain text, so an app that trusts the + // status code would happily render these strings. await expect(page.locator('body')).not.toContainText( 'Authorization failed.' ); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d384fc23e..d583836f4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -195,7 +195,13 @@ "SERVER_URL": "رابط الخادم", "URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)", "ADD": "إضافة", - "VALIDATING": "جارٍ التحقق من البوابة..." + "VALIDATING": "جارٍ التحقق من البوابة...", + "CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور", + "AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.", + "LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.", + "LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.", + "PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.", + "PORTAL_MESSAGE": "أفادت البوابة: {{message}}" }, "FILTER_BY_NAME": "التصفية حسب الاسم", "FILTER_AND_SORT": "تصفية وفرز", @@ -957,6 +963,7 @@ "TITLE": "لم يتم تحديد فئة", "DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى" }, + "STALKER_LOGIN_REQUIRED": "تتطلب البوابة اسم مستخدم وكلمة مرور. أعد استيراد البوابة واملأ حقلي اسم المستخدم وكلمة المرور.", "PLAYLIST_SETTINGS": "إعدادات القائمة", "HOME": "الرئيسية", "DELETE": "حذف" diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f686a3dab..961d20ad3 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -195,7 +195,13 @@ "SERVER_URL": "رابط الخادم", "URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)", "ADD": "زيد", - "VALIDATING": "جاري التحقق من البوابة..." + "VALIDATING": "جاري التحقق من البوابة...", + "CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور", + "AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.", + "LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.", + "LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.", + "PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.", + "PORTAL_MESSAGE": "البوابة قالت: {{message}}" }, "FILTER_BY_NAME": "فلتر بالاسم", "FILTER_AND_SORT": "فلتر وترتيب", @@ -957,6 +963,7 @@ "TITLE": "ما كاين حتى فئة متختارة", "DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى" }, + "STALKER_LOGIN_REQUIRED": "البوابة كتطلب اسم المستخدم وكلمة المرور. عاود استيراد البوابة وعمّر خانات اسم المستخدم وكلمة المرور.", "PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل", "HOME": "الرئيسية", "DELETE": "حذف" diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7e31c2f4f..97630f1e7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL сервера", "URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).", "ADD": "Дадаць", - "VALIDATING": "Праверка партала..." + "VALIDATING": "Праверка партала...", + "CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль", + "AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.", + "LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.", + "LOGIN_REJECTED": "Партал адхіліў лагін і пароль.", + "PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.", + "PORTAL_MESSAGE": "Партал паведаміў: {{message}}" }, "FILTER_BY_NAME": "Фільтраваць па імені", "FILTER_AND_SORT": "Фільтр і сартаванне", @@ -957,6 +963,7 @@ "TITLE": "Катэгорыя не выбрана", "DESCRIPTION": "Калі ласка, выберыце катэгорыю, каб убачыць кантэнт" }, + "STALKER_LOGIN_REQUIRED": "Партал патрабуе лагін і пароль. Імпартуйце партал яшчэ раз і запоўніце палі імя карыстальніка і пароля.", "PLAYLIST_SETTINGS": "Налады плэйліста", "HOME": "На галоўную", "DELETE": "Выдаліць" diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 39e022ce4..f4989975e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -195,7 +195,13 @@ "SERVER_URL": "Server-URL", "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)", "ADD": "Hinzufügen", - "VALIDATING": "Portal wird überprüft …" + "VALIDATING": "Portal wird überprüft …", + "CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt", + "AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.", + "LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.", + "LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.", + "PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.", + "PORTAL_MESSAGE": "Das Portal meldet: {{message}}" }, "FILTER_BY_NAME": "Nach Namen filtern", "FILTER_AND_SORT": "Filtern & Sortieren", @@ -957,6 +963,7 @@ "TITLE": "Keine Kategorie ausgewählt", "DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen" }, + "STALKER_LOGIN_REQUIRED": "Das Portal erfordert Benutzername und Passwort. Importieren Sie das Portal erneut und füllen Sie die Felder Benutzername und Passwort aus.", "PLAYLIST_SETTINGS": "Playlist-Einstellungen", "HOME": "Startseite", "DELETE": "Löschen" diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a2107398a..0f4ca122b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -195,7 +195,13 @@ "SERVER_URL": "Διεύθηνση URL σέρβερ", "URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)", "ADD": "Προσθήκη", - "VALIDATING": "Επικύρωση πύλης..." + "VALIDATING": "Επικύρωση πύλης...", + "CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης", + "AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.", + "LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.", + "LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.", + "PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.", + "PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}" }, "FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα", "FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση", @@ -957,6 +963,7 @@ "TITLE": "Δεν έχει επιλεγεί κατηγορία", "DESCRIPTION": "Επιλέξτε μια κατηγορία για να δείτε το περιεχόμενο" }, + "STALKER_LOGIN_REQUIRED": "Η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Εισαγάγετε ξανά την πύλη και συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης.", "PLAYLIST_SETTINGS": "Ρυθμίσεις λίστας αναπαραγωγής", "HOME": "Αρχικό", "DELETE": "Διαγραφή" diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 39110d498..a86135fd8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -195,7 +195,13 @@ "SERVER_URL": "Server URL", "URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)", "ADD": "Add", - "VALIDATING": "Validating portal..." + "VALIDATING": "Validating portal...", + "CREDENTIALS_HINT": "Only needed when the portal asks for a login and password", + "AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.", + "LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.", + "LOGIN_REJECTED": "The portal rejected the login and password.", + "PORTAL_REFUSED": "The portal refused access for this device.", + "PORTAL_MESSAGE": "The portal reported: {{message}}" }, "FILTER_BY_NAME": "Filter by name", "FILTER_AND_SORT": "Filter & Sort", @@ -957,6 +963,7 @@ "TITLE": "No category selected", "DESCRIPTION": "Please select a category to see the content" }, + "STALKER_LOGIN_REQUIRED": "The portal requires a login and password. Import the portal again and fill in the username and password fields.", "PLAYLIST_SETTINGS": "Playlist Settings", "HOME": "Home", "DELETE": "Delete" diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index dca10e3d1..c936ccc3e 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL del servidor", "URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).", "ADD": "Agregar", - "VALIDATING": "Validando portal…" + "VALIDATING": "Validando portal…", + "CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña", + "AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.", + "LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.", + "LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.", + "PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.", + "PORTAL_MESSAGE": "El portal informó: {{message}}" }, "FILTER_BY_NAME": "Filtrar por nombre", "FILTER_AND_SORT": "Filtrar y ordenar", @@ -957,6 +963,7 @@ "TITLE": "Ninguna categoría seleccionada", "DESCRIPTION": "Selecciona una categoría para ver el contenido" }, + "STALKER_LOGIN_REQUIRED": "El portal requiere usuario y contraseña. Vuelve a importar el portal y completa los campos de nombre de usuario y contraseña.", "PLAYLIST_SETTINGS": "Configuración de lista de reproducción", "HOME": "Hogar", "DELETE": "Borrar" diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 7199e0e6f..9bc0421ef 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL du serveur", "URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)", "ADD": "Ajouter", - "VALIDATING": "Validation du portail…" + "VALIDATING": "Validation du portail…", + "CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe", + "AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.", + "LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.", + "LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.", + "PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.", + "PORTAL_MESSAGE": "Le portail a signalé : {{message}}" }, "FILTER_BY_NAME": "Filtrer par nom", "FILTER_AND_SORT": "Filtrer et trier", @@ -957,6 +963,7 @@ "TITLE": "Aucune catégorie sélectionnée", "DESCRIPTION": "Veuillez sélectionner une catégorie pour voir le contenu" }, + "STALKER_LOGIN_REQUIRED": "Le portail nécessite un identifiant et un mot de passe. Importez à nouveau le portail et renseignez les champs nom d'utilisateur et mot de passe.", "PLAYLIST_SETTINGS": "Paramètres de la liste", "HOME": "Accueil", "DELETE": "Supprimer" diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 363518a0f..ee09db2be 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -195,7 +195,13 @@ "SERVER_URL": "Kiszolgáló URL-címe", "URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)", "ADD": "Hozzáadás", - "VALIDATING": "Portál ellenőrzése…" + "VALIDATING": "Portál ellenőrzése…", + "CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér", + "AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.", + "LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.", + "LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.", + "PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.", + "PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}" }, "FILTER_BY_NAME": "Szűrés név alapján", "FILTER_AND_SORT": "Szűrés és rendezés", @@ -957,6 +963,7 @@ "TITLE": "Nincs kiválasztott kategória", "DESCRIPTION": "A tartalom megjelenítéséhez válasszon egy kategóriát." }, + "STALKER_LOGIN_REQUIRED": "A portál felhasználónevet és jelszót igényel. Importálja újra a portált, és töltse ki a felhasználónév és a jelszó mezőt.", "PLAYLIST_SETTINGS": "Lejátszási lista beállításai", "HOME": "Kezdőlap", "DELETE": "Törlés" diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 802e180a9..5212c6bfe 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL server", "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)", "ADD": "Aggiungi", - "VALIDATING": "Validazione del portale..." + "VALIDATING": "Validazione del portale...", + "CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password", + "AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.", + "LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.", + "LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.", + "PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.", + "PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}" }, "FILTER_BY_NAME": "Filtra per nome", "FILTER_AND_SORT": "Filtra e ordina", @@ -957,6 +963,7 @@ "TITLE": "Nessuna categoria selezionata", "DESCRIPTION": "Seleziona una categoria per vedere i contenuti" }, + "STALKER_LOGIN_REQUIRED": "Il portale richiede nome utente e password. Importa di nuovo il portale e compila i campi nome utente e password.", "PLAYLIST_SETTINGS": "Impostazioni Playlist", "HOME": "Home", "DELETE": "Elimina" diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2f3b08d41..49e89011e 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -195,7 +195,13 @@ "SERVER_URL": "サーバーURL", "URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)", "ADD": "追加", - "VALIDATING": "ポータルを検証中..." + "VALIDATING": "ポータルを検証中...", + "CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要", + "AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。", + "LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。", + "LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。", + "PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。", + "PORTAL_MESSAGE": "ポータルからの報告:{{message}}" }, "FILTER_BY_NAME": "名前でフィルター", "FILTER_AND_SORT": "フィルターと並び替え", @@ -957,6 +963,7 @@ "TITLE": "カテゴリーが選択されていません", "DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください" }, + "STALKER_LOGIN_REQUIRED": "ポータルにはユーザー名とパスワードが必要です。ポータルを再インポートして、ユーザー名とパスワードの欄を入力してください。", "PLAYLIST_SETTINGS": "プレイリスト設定", "HOME": "ホーム", "DELETE": "削除" diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 59b734ff5..999697dee 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -195,7 +195,13 @@ "SERVER_URL": "서버 URL", "URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).", "ADD": "추가하다", - "VALIDATING": "포털을 검증하는 중..." + "VALIDATING": "포털을 검증하는 중...", + "CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요", + "AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.", + "LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.", + "LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.", + "PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.", + "PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}" }, "FILTER_BY_NAME": "이름으로 필터", "FILTER_AND_SORT": "필터 및 정렬", @@ -957,6 +963,7 @@ "TITLE": "선택된 카테고리가 없습니다", "DESCRIPTION": "콘텐츠를 보려면 카테고리를 선택하세요" }, + "STALKER_LOGIN_REQUIRED": "포털에 사용자 이름과 비밀번호가 필요합니다. 포털을 다시 가져온 후 사용자 이름과 비밀번호 필드를 입력하세요.", "PLAYLIST_SETTINGS": "재생목록 설정", "HOME": "홈", "DELETE": "삭제" diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 3927b34cc..0038dcc28 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -195,7 +195,13 @@ "SERVER_URL": "Server URL", "URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)", "ADD": "Toevoegen", - "VALIDATING": "Portaal valideren..." + "VALIDATING": "Portaal valideren...", + "CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt", + "AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.", + "LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.", + "LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.", + "PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.", + "PORTAL_MESSAGE": "Het portaal meldde: {{message}}" }, "FILTER_BY_NAME": "Filter op naam", "FILTER_AND_SORT": "Filteren & sorteren", @@ -957,6 +963,7 @@ "TITLE": "Geen categorie geselecteerd", "DESCRIPTION": "Selecteer een categorie om de inhoud te bekijken" }, + "STALKER_LOGIN_REQUIRED": "Het portaal vereist een gebruikersnaam en wachtwoord. Importeer het portaal opnieuw en vul de velden gebruikersnaam en wachtwoord in.", "PLAYLIST_SETTINGS": "Afspeellijst instellingen", "HOME": "Home", "DELETE": "Verwijderen" diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2f4d4e800..40670643a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL serwera", "URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)", "ADD": "Dodaj", - "VALIDATING": "Walidacja portalu..." + "VALIDATING": "Walidacja portalu...", + "CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła", + "AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.", + "LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.", + "LOGIN_REJECTED": "Portal odrzucił login i hasło.", + "PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.", + "PORTAL_MESSAGE": "Portal zgłosił: {{message}}" }, "FILTER_BY_NAME": "Filtruj według nazwy", "FILTER_AND_SORT": "Filtruj i sortuj", @@ -957,6 +963,7 @@ "TITLE": "Nie wybrano kategorii", "DESCRIPTION": "Wybierz kategorię, aby zobaczyć treści" }, + "STALKER_LOGIN_REQUIRED": "Portal wymaga loginu i hasła. Zaimportuj portal ponownie i wypełnij pola nazwy użytkownika i hasła.", "PLAYLIST_SETTINGS": "Ustawienia listy odtwarzania", "HOME": "Strona główna", "DELETE": "Usuń" diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index cbf5b10dc..376dcd436 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL do servidor", "URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)", "ADD": "Adicionar", - "VALIDATING": "Validando portal..." + "VALIDATING": "Validando portal...", + "CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha", + "AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.", + "LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.", + "LOGIN_REJECTED": "O portal rejeitou o login e a senha.", + "PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.", + "PORTAL_MESSAGE": "O portal informou: {{message}}" }, "FILTER_BY_NAME": "Filtrar por nome", "FILTER_AND_SORT": "Filtrar e ordenar", @@ -957,6 +963,7 @@ "TITLE": "Nenhuma categoria selecionada", "DESCRIPTION": "Selecione uma categoria para ver o conteúdo" }, + "STALKER_LOGIN_REQUIRED": "O portal exige login e senha. Importe o portal novamente e preencha os campos de nome de usuário e senha.", "PLAYLIST_SETTINGS": "Configurações da playlist", "HOME": "Início", "DELETE": "Excluir" diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 685bf964c..955ecd3dc 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -195,7 +195,13 @@ "SERVER_URL": "URL сервера", "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).", "ADD": "Добавить", - "VALIDATING": "Проверка портала…" + "VALIDATING": "Проверка портала…", + "CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль", + "AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.", + "LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.", + "LOGIN_REJECTED": "Портал отклонил логин и пароль.", + "PORTAL_REFUSED": "Портал отказал в доступе этому устройству.", + "PORTAL_MESSAGE": "Портал сообщил: {{message}}" }, "FILTER_BY_NAME": "Фильтровать по имени", "FILTER_AND_SORT": "Фильтр и сортировка", @@ -957,6 +963,7 @@ "TITLE": "Категория не выбрана", "DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого" }, + "STALKER_LOGIN_REQUIRED": "Портал требует логин и пароль. Импортируйте портал заново и заполните поля имени пользователя и пароля.", "PLAYLIST_SETTINGS": "Настройки плейлиста", "HOME": "На главную", "DELETE": "Удалить" diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 99718c5fb..ea99b075a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -195,7 +195,13 @@ "SERVER_URL": "Sunucu URL'si", "URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)", "ADD": "Ekle", - "VALIDATING": "Portal doğrulanıyor..." + "VALIDATING": "Portal doğrulanıyor...", + "CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir", + "AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.", + "LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.", + "LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.", + "PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.", + "PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}" }, "FILTER_BY_NAME": "İsime göre filtrele", "FILTER_AND_SORT": "Filtrele ve Sırala", @@ -957,6 +963,7 @@ "TITLE": "Kategori seçilmedi", "DESCRIPTION": "İçeriği görmek için lütfen bir kategori seçin" }, + "STALKER_LOGIN_REQUIRED": "Portal, kullanıcı adı ve parola gerektiriyor. Portalı yeniden içe aktarın ve kullanıcı adı ile parola alanlarını doldurun.", "PLAYLIST_SETTINGS": "Oynatma Listesi Ayarları", "HOME": "Ana Sayfa", "DELETE": "Sil" diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index bf74b355b..d69b1134d 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -195,7 +195,13 @@ "SERVER_URL": "服务器 URL", "URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)", "ADD": "添加", - "VALIDATING": "正在验证门户…" + "VALIDATING": "正在验证门户…", + "CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写", + "AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。", + "LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。", + "LOGIN_REJECTED": "门户拒绝了该登录名和密码。", + "PORTAL_REFUSED": "门户拒绝了此设备的访问。", + "PORTAL_MESSAGE": "门户返回信息:{{message}}" }, "FILTER_BY_NAME": "按名称筛选", "FILTER_AND_SORT": "筛选与排序", @@ -957,6 +963,7 @@ "TITLE": "未选择分类", "DESCRIPTION": "请选择一个分类以查看内容" }, + "STALKER_LOGIN_REQUIRED": "门户需要登录名和密码。请重新导入该门户并填写用户名和密码字段。", "PLAYLIST_SETTINGS": "播放列表设置", "HOME": "首页", "DELETE": "删除" diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 866127200..ecd350563 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -195,7 +195,13 @@ "SERVER_URL": "伺服器網址", "URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)", "ADD": "新增", - "VALIDATING": "正在驗證入口網站..." + "VALIDATING": "正在驗證入口網站...", + "CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫", + "AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。", + "LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。", + "LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。", + "PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。", + "PORTAL_MESSAGE": "入口網站回報:{{message}}" }, "FILTER_BY_NAME": "依名稱篩選", "FILTER_AND_SORT": "篩選與排序", @@ -957,6 +963,7 @@ "TITLE": "未選擇類別", "DESCRIPTION": "請選擇一個類別以查看內容" }, + "STALKER_LOGIN_REQUIRED": "入口網站需要登入名稱與密碼。請重新匯入入口網站並填寫使用者名稱與密碼欄位。", "PLAYLIST_SETTINGS": "播放清單設定", "HOME": "首頁", "DELETE": "刪除" diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index bbc78ac74..bc9b50bd0 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -83,9 +83,9 @@ Two portal modes exist, persisted per playlist as the wider phrase set (`Invalid token`, `Auth failed`, bare `unauthorized`), since a panel fills those in deliberately. While a full portal is the active playlist, `StalkerSessionService` keeps a **watchdog** running — - periodic authenticated `watchdog/get_events` pings (currently every 25 s; - the protocol default expects 120 s, tracked for a later PR) whose failures - are non-fatal. + periodic authenticated `watchdog/get_events` pings at the cadence the + portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog" + below) whose failures are non-fatal. - **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle at all — requests carry only the `mac=` cookie. @@ -235,6 +235,181 @@ blank fields are not generated or forwarded to `get_profile`. metadata is independent from M3U playlist EPG metadata and must not depend on M3U-specific EPG fields. +## Session Authentication Lifecycle + +Full portals authenticate through `StalkerSessionService` +(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which +is a facade over three focused modules: + +- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth` + requests and the `authenticate()` orchestration. +- `stalker-watchdog.controller.ts` — the periodic `get_events` keep-alive. +- `stalker-token-cache.ts` — the in-run token and pending-auth state, tagged + with the identity fingerprint each session was negotiated for. +- `stalker-session-store.ts` — the session persisted on the playlist row. +- `stalker-response-classification.ts` + `stalker-portal-error.ts` — failure + detection and the typed `StalkerPortalError` the UI layers render. + +### Handshake and token persistence + +The handshake token is **idempotent** (Stalker 4.9.35 `stb.class.php`): +re-presenting the MAC's current session token returns it unchanged, and tokens +have no TTL — they are only invalidated when another device runs `get_profile` +on the same MAC. `ensureToken()` exploits this: when the in-memory cache is +cold it re-presents the persisted `Playlist.stalkerToken` (from the playlist +object, falling back to the stored row via `PlaylistsService`, since store +metas do not carry payload fields). A token that comes back unchanged is an +already-adopted session, so the `get_profile` round trip is skipped entirely — +unless the handshake also set `not_valid`, which vetoes the shortcut: adopting +a token the portal just called dead would be unrecoverable, since the reuse +path writes no replacement back and every later start would re-present it. +A renegotiated session is written back best-effort +(`PlaylistsService.updateStalkerSession`) so the next app start can reuse it. +The handshake's `not_valid` flag is propagated into the follow-up +`get_profile` as `not_valid_token`. + +Reuse is gated on a session fingerprint (`stalkerSessionFingerprint`) covering +the **portal endpoint (origin AND path), the device identity and the account +credentials**, stored +next to the token as `Playlist.stalkerSessionIdentity` and used for the +in-run cache as well, so an edit applies without a restart. All three halves +are load-bearing: `ensureToken()` re-presents tokens in a handshake, so an +endpoint edit would otherwise disclose the previous portal's bearer token to +another portal — and origin alone is not enough, since discovery deliberately +preserves tenant base paths, so `/tenant-a/…` and `/tenant-b/…` on one host +are different portals; an identity edit must not inherit the old session; and for a +status-2 portal the login decides which account the token represents. A token +with no recorded fingerprint (written before this existed) counts as +unverified and is never re-presented — such a row owes a full profile anyway, +and the write-back then records the fingerprint. + +Because that reuse skips the only response carrying the watchdog cadence, the +cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` / +`stalkerTimeslot`, payload fields like `stalkerToken` itself — no schema +change) and re-applied on the reuse path. The import dialog persists what its +own `get_profile` advertised, which for a portal whose token never goes stale +is the only profile the app ever sees. + +The skip is therefore conditional on the cadence being **known**: a playlist +imported before the cadence was persisted has a reusable token and no +cadence, and skipping would strand it on the 120 s default permanently, since +the profile is the only thing that could teach it. Such a playlist runs one +profile, persists what it learns, and skips from then on. What gets persisted +is the *effective* cadence (the 120 s default when the portal advertises +none), so stored absence keeps meaning exactly one thing — never profiled — +rather than sending a portal that advertises nothing back through a profile on +every start. + +### `get_profile` status decoding + +`authenticate()` decodes `js.status` the way the stock middleware means it: + +- full profile / `status: 0` — OK; `watchdog_timeout` and `timeslot` are read + for the watchdog cadence. +- `status: 1` — blocked (device conflict, malformed MAC, disabled account). + `msg`/`block_msg` carry the portal's own explanation; they are + markup-stripped, combined, and thrown as `StalkerPortalError('blocked')`. +- `status: 2` — login/password required. The client runs `do_auth` + (`login`, `password`, plus `device_id`/`device_id2` when configured) and + retries `get_profile` with `auth_second_step=1`. Only that retry claims the + second auth step — the initial request sends `auth_second_step=0`. Missing + credentials throw `StalkerPortalError('login-required')`; a `{js: false}` + verdict (the operator billing script refused) throws `'login-rejected'`. + +Credentials come from the import dialog's username/password fields and are +persisted on the playlist, so runtime re-authentication can repeat `do_auth` +after the portal drops the session. + +### Plain-text failure bodies + +Auth failures are **HTTP 200 + a text/html body**, never a 401/403. The three +exact bodies (`Authorization failed.` — stale/missing token, optionally with a +numeric debug suffix; `Access denied.` — blocked account; +`Unauthorized request.` — missing mac cookie) are classified at the transport +boundary: the Electron main process +(`apps/electron-backend/src/app/events/stalker.events.ts`) converts them into +a structured `{ stalkerAuthFailure }` marker +(`libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`) — returned, +not thrown, because `ipcRenderer.invoke` strips custom properties from +rejections. The PWA proxy path still delivers the raw string; the renderer +classifier accepts both shapes plus the legacy `{ js: '' }` envelope. +`makeAuthenticatedRequest` retries once with fresh authentication and +otherwise throws `StalkerPortalError('auth-failed')` carrying the body. + +### Error surfacing + +`StalkerPortalError.portalText` holds the portal's own words. The import +dialog shows them in its failure snackbar (with kind-specific i18n headlines, +`HOME.STALKER_PORTAL.*`); the workspace context panel replaces the generic +"could not load categories" hint with the portal text (or the login-required +guidance) when category loading failed with a portal refusal +(`stalkerCategoryErrorDescription` in `workspace-context-panel.component.ts`). + +Both renderers are kind-agnostic — they append `portalText` whenever it is +present — so the obligation sits entirely on the throw sites: **every** exit +out of the status-2 branch carries the text, not just the terminal `blocked` +one. A login refusal is precisely where the portal says something actionable +("wrong password", "subscription expired"), and dropping it leaves the user +with a generic line while the useful sentence sits unread in the payload. +Each exit reads the response IN HAND: after the `auth_second_step=1` retry the +text is the retry's, since quoting the first profile back would describe a +request that already succeeded. `do_auth` itself answers a bare `{js: false}`, +so a rejection there keeps the profile's text — the one that asked for the +login. + +### Abandoning an authentication + +`authenticate()` takes an optional `AbortSignal` and checks it before every +portal call. Endpoint discovery gives each confirmation attempt its own +controller and aborts it when the attempt exceeds its budget, before moving to +the next candidate. + +This matters because `get_profile` — not the handshake — is what adopts a +token for the MAC portal-side. Without the check, a timed-out attempt could +still send its `get_profile` after a later candidate had authenticated, +invalidating that healthy candidate's token and making discovery report a +working portal as refused. + +Cancellation is deliberately cooperative rather than a socket-level abort +threaded to axios: once a request is on the wire the server processes it +regardless of what the client does, so tearing the socket down would not +prevent the adoption. Only not sending the request does, which is exactly what +the between-calls check guarantees. + +That leaves the window where the timer fires while a `get_profile` is already +dispatched — which the check cannot cover, but sequencing can: discovery +**drains** the abandoned attempt (bounded by one request budget) before probing +the next candidate, instead of racing it. The request cannot be un-sent, but +nothing forces us to have a competing session in flight while it lands. + +**A drain that times out stops discovery.** Draining is bounded, and the bound +has to mean something: an attempt still unsettled after its own 65 s budget +plus the 15 s drain is one no transport can recall — the PWA `fetch()` takes no +signal at all, and the Electron main process runs its HTTP request to +completion. Advancing anyway would stake the next candidate's freshly issued +session on that request never landing. So the rejection carries +`abandonedInFlight` and the candidate loop returns instead of probing on, +preferring an honest "could not confirm this portal" the user can retry over a +session that looks established and dies later. It costs nothing in the normal +case: an aborted attempt settles as soon as its in-flight request errors out, +so the drain returns at once and the loop continues. + +The budget itself covers the longest real flow: a status-2 portal costs four +sequential requests (handshake, profile, `do_auth`, profile retry) and the +Electron transport allows each 15 s, so a two-request budget would have failed +valid but slow login portals. + +### Watchdog + +The portal expects `get_events` every `watchdog_timeout` seconds — **120 by +default**, echoed in the profile together with a per-user `timeslot` jitter +that offsets the first periodic ping. `StalkerWatchdogController` starts with +an immediate `init=1` ping on activation, applies the profile cadence when a +profile is decoded (clamped to 30–3600 s against garbage), and otherwise uses +the documented 120 s default. Failing to ping never invalidates the session — +it only affects the portal's admin-panel "online" reporting — so ping failures +are logged and never retried or escalated. + ## Request Transport and `cmd` Encoding A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw @@ -390,9 +565,15 @@ Every static return therefore warms first, through one primitive — `ensureToken` performs handshake + `get_profile` with no link minted, and validates the identity the cached token was negotiated for — which the raw -`getCachedToken()` the header builders use cannot. It is cheap where it is not -needed: a simple portal returns immediately and a warm cache with a matching -fingerprint resolves without a request. +`getCachedToken()` cannot. It is cheap where it is not needed: a simple portal +returns immediately and a warm cache with a matching fingerprint resolves +without a request. + +The store's player feature reads `getCachedToken()` for its header set, which +is safe there because it runs immediately after the warm above populated the +cache for that same playlist. `StreamResolverService` cannot make that +assumption — a direct-URL favorite reaches it with nothing warmed — so it goes +through `ensureToken` instead; see "Playback Header Contract" below. The classification happens BEFORE the handshake, not after: a **foreign-host** static URL never needs the session at all, and warming it anyway would stall @@ -563,6 +744,30 @@ Two stream profiles exist, selected by one shared predicate: never reach a third-party host; direct stream URLs carry their access token in the URL minted by `create_link`. +**The token is bound to the endpoint the headers claim.** Both header inputs — +the portal coordinates and the Bearer token — must describe the same portal, or +a session negotiated for one host is presented to another. In +`StreamResolverService` that is structural: the token is resolved from the same +`headerPlaylist` object the headers are built from, never from the row it was +derived from. The live case is a completed lazy repair, which moves the +endpoint in the override but not in the stored row. Resolving from the override +also keys the session cache the way `ensureStalkerSession()` and +`executeStalkerRequest()` already do, so the collection route reuses their +session instead of handshaking again for a second fingerprint. + +For the same reason `headerPlaylist` is built by applying the override to the +row rather than folding in the two resolved coordinates: a repair rewrites the +portal **mode** as well as the URL, and the token resolver is mode-aware. A +playlist repaired from simple to full would otherwise keep its stale +`isFullStalkerPortal: false` here — the `create_link` request having already +run under the repaired mode and adopted a token — and the same-host gated +stream would go out with no Bearer header on the very playback the repair +existed to rescue. + +That resolution is skipped for a foreign host, whose profile carries no token +anyway — obtaining one would only stall playback behind a handshake, exactly +the trade the static branch avoids by classifying first. + The Electron main process keeps a fallback header context per resolved `create_link` URL (`stalker-playback-context.service.ts`) for external-player launches that arrive without renderer headers. It classifies streams with the diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html index e7eb3e63f..57beb00fe 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html @@ -101,16 +101,27 @@ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }} - + + {{ + 'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate + }} + diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index d966e25fd..906afa722 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -2,13 +2,19 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access'; +import { + stalkerSessionFingerprint, + StalkerPortalDiscoveryService, + StalkerPortalError, +} from '@iptvnator/portal/stalker/data-access'; +import { Playlist } from '@iptvnator/shared/interfaces'; import { StalkerPortalImportComponent } from './stalker-portal-import.component'; describe('StalkerPortalImportComponent identity handling', () => { let component: StalkerPortalImportComponent; let portalDiscovery: { discover: jest.Mock }; let store: { dispatch: jest.Mock }; + let snackBar: { open: jest.Mock }; beforeEach(() => { portalDiscovery = { @@ -23,6 +29,7 @@ describe('StalkerPortalImportComponent identity handling', () => { store = { dispatch: jest.fn(), }; + snackBar = { open: jest.fn() }; TestBed.configureTestingModule({ providers: [ @@ -33,7 +40,7 @@ describe('StalkerPortalImportComponent identity handling', () => { { provide: Store, useValue: store }, { provide: MatSnackBar, - useValue: { open: jest.fn() }, + useValue: snackBar, }, { provide: TranslateService, @@ -72,7 +79,10 @@ describe('StalkerPortalImportComponent identity handling', () => { deviceId2: 'DEVICE-ID-2', signature1: 'SIGNATURE-1', signature2: 'SIGNATURE-2', - } + }, + // Credentials ride along for portals that answer get_profile + // with status 2 (login/password required). + { credentials: { username: '', password: '' } } ); const playlist = store.dispatch.mock.calls[0][0].playlist; @@ -115,7 +125,8 @@ describe('StalkerPortalImportComponent identity handling', () => { expect(portalDiscovery.discover).toHaveBeenCalledWith( 'https://portal.example.com/stalker_portal/c', '00:1A:79:AA:BB:CC', - {} + {}, + { credentials: { username: '', password: '' } } ); const playlist = store.dispatch.mock.calls[0][0].playlist; @@ -131,6 +142,170 @@ describe('StalkerPortalImportComponent identity handling', () => { expect(playlist.signature2).toBeUndefined(); }); + it('passes the entered login and password into discovery', async () => { + component.form.patchValue({ + _id: 'playlist-login', + title: 'Login Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + username: 'user', + password: 'secret', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + expect(portalDiscovery.discover).toHaveBeenCalledWith( + expect.any(String), + '00:1A:79:00:00:08', + {}, + { credentials: { username: 'user', password: 'secret' } } + ); + // Persisted so runtime re-auth can repeat do_auth after the portal + // drops the session. + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.username).toBe('user'); + expect(playlist.password).toBe('secret'); + }); + + it('persists the cadence and the identity the token was negotiated for', async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + watchdogTimeoutSeconds: 90, + timeslotSeconds: 11, + }); + component.form.patchValue({ + _id: 'playlist-cadence', + title: 'Cadence Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.stalkerWatchdogTimeout).toBe(90); + expect(playlist.stalkerTimeslot).toBe(11); + // Without this a later start would re-present the token under an + // edited identity. + expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String)); + }); + + it('records credentials in the imported session fingerprint', async () => { + // Otherwise the first runtime ensureToken() computes a fingerprint + // WITH the credentials, mismatches the imported one, and discards the + // session the import just established — silently defeating reuse for + // exactly the login portals this flow exists for. + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + }); + component.form.patchValue({ + _id: 'playlist-login-fp', + title: 'Login Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + username: 'user', + password: 'secret', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.stalkerSessionIdentity).toBe( + stalkerSessionFingerprint({ + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:08', + username: 'user', + password: 'secret', + } as Playlist) + ); + }); + + it('records the effective cadence when the portal advertises none', async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + }); + component.form.patchValue({ + _id: 'playlist-default-cadence', + title: 'Quiet Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + // Stored absence has to keep meaning "never profiled", or every + // later start would re-profile such a portal. + expect(playlist.stalkerWatchdogTimeout).toBe(120); + expect(playlist.stalkerTimeslot).toBe(0); + }); + + it("relays the portal's own refusal instead of a generic error", async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + error: new StalkerPortalError('login-required'), + }); + component.form.patchValue({ + _id: 'playlist-refused', + title: 'Login Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.STALKER_PORTAL.LOGIN_REQUIRED', + undefined, + expect.any(Object) + ); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it("appends the portal's explanation to a blocked refusal", async () => { + portalDiscovery.discover.mockResolvedValue({ + status: 'auth-rejected', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + error: new StalkerPortalError( + 'blocked', + 'device conflict - device_id mismatch' + ), + }); + component.form.patchValue({ + _id: 'playlist-blocked', + title: 'Blocked Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + // The translate mock returns keys, so both the kind headline and the + // portal-message wrapper must be present. + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE', + undefined, + expect.any(Object) + ); + }); + it('classifies the offline fallback on the normalized URL, not the raw query', async () => { // A query merely MENTIONING /server/load.php must not make a // panel-style /c URL look canonical and abort the offline import. diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 5a0b67222..86888fb92 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -13,11 +13,15 @@ import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { + asStalkerPortalError, legacyTransformStalkerPortalUrl, normalizeStalkerPortalInputUrl, + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, StalkerPortalDiscoveryService, StalkerPortalIdentity, normalizeStalkerPortalIdentity, + stalkerSessionFingerprint, + type StalkerPortalErrorKind, } from '@iptvnator/portal/stalker/data-access'; import { createRandomId, @@ -129,18 +133,37 @@ export class StalkerPortalImportComponent { const discovery = await this.portalDiscovery.discover( originalUrl, formValue.macAddress ?? '', - stalkerIdentity + stalkerIdentity, + { + credentials: { + username: formValue.username ?? '', + password: formValue.password ?? '', + }, + } ); let portalUrl: string; let isFullStalkerPortal: boolean; let stalkerToken: string | undefined; let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined; + // The import profile is the only get_profile some portals ever + // see: later starts reuse the token and skip it, so the cadence + // it advertises has to be persisted here or the watchdog would + // stay on the 120 s default forever. Effective values, so stored + // absence keeps meaning "never profiled". + let stalkerWatchdogTimeout: number | undefined; + let stalkerTimeslot: number | undefined; if (discovery.status === 'resolved') { portalUrl = discovery.portalUrl; isFullStalkerPortal = discovery.isFullStalkerPortal; stalkerToken = discovery.token; + if (stalkerToken) { + stalkerWatchdogTimeout = + discovery.watchdogTimeoutSeconds ?? + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS; + stalkerTimeslot = discovery.timeslotSeconds ?? 0; + } if (discovery.accountInfo) { stalkerAccountInfo = { @@ -167,10 +190,13 @@ export class StalkerPortalImportComponent { '[StalkerImport] Authentication failed:', discovery.error ); + // The portal explains its own refusals — a demanded login, a + // rejected one, a device conflict — so relay those words + // instead of the generic "check URL and MAC". this.snackBar.open( - 'Failed to authenticate with portal. Please check URL and MAC address.', + this.buildAuthErrorMessage(discovery.error), undefined, - { duration: 5000 } + { duration: 8000 } ); return; } else if ( @@ -219,6 +245,25 @@ export class StalkerPortalImportComponent { portalUrl, isFullStalkerPortal, stalkerToken, + // What this token was negotiated for: endpoint, identity AND + // credentials. Reuse is refused when any of them no longer + // matches — and the credentials must be included here, or the + // first runtime `ensureToken()` would compute a fingerprint + // WITH them, mismatch this one, and throw away the session + // the import just established. + ...(stalkerToken + ? { + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress: formValue.macAddress ?? '', + username: formValue.username ?? '', + password: formValue.password ?? '', + ...this.toPlaylistIdentityFields(stalkerIdentity), + } as Playlist), + } + : {}), + stalkerWatchdogTimeout, + stalkerTimeslot, stalkerAccountInfo, ...this.toPlaylistIdentityFields(stalkerIdentity), } as Playlist; @@ -230,6 +275,36 @@ export class StalkerPortalImportComponent { } } + /** + * Turns an authentication failure into a message the user can act on. + * The portal explains refusals itself (`msg`/`block_msg`, or one of the + * documented plain-text bodies); its own words are appended verbatim. + */ + private buildAuthErrorMessage(error: unknown): string { + const portalError = asStalkerPortalError(error); + const keyByKind: Record = { + 'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED', + 'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED', + blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED', + 'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED', + }; + const base = this.translate.instant( + portalError + ? keyByKind[portalError.kind] + : 'HOME.STALKER_PORTAL.AUTH_FAILED' + ); + + if (portalError?.portalText) { + const detail = this.translate.instant( + 'HOME.STALKER_PORTAL.PORTAL_MESSAGE', + { message: portalError.portalText } + ); + return `${base} ${detail}`; + } + + return base; + } + private toPlaylistIdentityFields(identity: StalkerPortalIdentity): { stalkerSerialNumber?: string; stalkerDeviceId1?: string; diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 5d10d789b..a5451750f 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -4,7 +4,10 @@ import { XtreamApiService, XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; -import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { + StalkerPortalRepairService, + StalkerSessionService, +} from '@iptvnator/portal/stalker/data-access'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { DataService, @@ -25,6 +28,7 @@ describe('StreamResolverService', () => { let dataService: { sendIpcEvent: jest.Mock }; let stalkerSession: { getCachedToken: jest.Mock; + ensureToken: jest.Mock; makeAuthenticatedRequest: jest.Mock; }; let epgBridge: Partial; @@ -44,8 +48,8 @@ describe('StreamResolverService', () => { }; stalkerSession = { getCachedToken: jest.fn(() => null), - makeAuthenticatedRequest: jest.fn(), ensureToken: jest.fn().mockResolvedValue({ token: null }), + makeAuthenticatedRequest: jest.fn(), }; epgBridge = { getChannelPrograms: jest.fn(), @@ -792,7 +796,10 @@ describe('StreamResolverService', () => { isFullStalkerPortal: true, } satisfies Partial) ); - stalkerSession.getCachedToken.mockReturnValue('TOKEN77'); + // Playback goes through ensureToken, never the raw cache accessor: + // that one skips the endpoint/identity/credential check, so an + // edited playlist would put the old account's token in the headers. + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN77' }); stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' }, }); @@ -808,7 +815,7 @@ describe('StreamResolverService', () => { stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg', } satisfies UnifiedCollectionItem); - expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1'); + expect(stalkerSession.ensureToken).toHaveBeenCalled(); expect(playback.headers?.['Cookie']).toContain( 'mac=00:11:22:33:44:55' ); @@ -818,6 +825,202 @@ describe('StreamResolverService', () => { expect(playback.origin).toBe('https://stalker.example.com'); }); + it('binds the playback token to the endpoint the headers claim', async () => { + // A completed repair moved the endpoint. The headers are built from + // that moved endpoint, so the session must be negotiated for it too — + // authenticating against the pre-repair row would send a token minted + // for one host to another (and key the session cache differently from + // every other consumer, forcing a redundant handshake). + const stored = { + _id: 'stalker-1', + portalUrl: 'https://old.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial; + const repaired = + 'https://new.example.com/stalker_portal/server/load.php'; + playlistsService.getPlaylistById.mockReturnValue(of(stored)); + jest.spyOn( + TestBed.inject(StalkerPortalRepairService), + 'applyOverride' + ).mockImplementation( + (playlist: any) => ({ ...playlist, portalUrl: repaired }) as any + ); + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN88' }); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://new.example.com:8080/live/88.ts' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::88', + name: 'Moved Portal Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '88', + stalkerCmd: 'ffrt3 http://old.example.com/media/88.mpg', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ portalUrl: repaired }) + ); + expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN88'); + expect(playback.origin).toBe('https://new.example.com'); + }); + + it('carries a repaired simple→full mode into the playback headers', async () => { + // A lazy repair rewrites the portal MODE as well as the URL. The + // create_link request runs under the repaired mode and adopts a token, + // so reading the stored row's stale `isFullStalkerPortal: false` when + // resolving that token would drop the Bearer header from the very + // playback the repair was meant to rescue. + const stored = { + _id: 'stalker-1', + portalUrl: 'https://portal.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial; + playlistsService.getPlaylistById.mockReturnValue(of(stored)); + jest.spyOn( + TestBed.inject(StalkerPortalRepairService), + 'applyOverride' + ).mockImplementation( + (playlist: any) => + ({ + ...playlist, + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + }) as any + ); + stalkerSession.ensureToken.mockResolvedValue({ token: 'REPAIRED77' }); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://portal.example.com:8080/live/95.ts' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::95', + name: 'Repaired Portal Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '95', + stalkerCmd: 'ffrt3 http://portal.example.com/media/95.mpg', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalledWith( + expect.objectContaining({ isFullStalkerPortal: true }) + ); + expect(playback.headers?.['Authorization']).toBe('Bearer REPAIRED77'); + }); + + it('authenticates a cold session for a direct-URL radio favorite', async () => { + // A direct-URL radio favorite skips create_link entirely, so on a + // cold session nothing has authenticated and the in-memory cache is + // empty — the Bearer-gated stream would 403 in the audio player. + // Re-presenting the persisted token costs one idempotent handshake. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: + 'https://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + stalkerToken: 'PERSISTED77', + } satisfies Partial) + ); + stalkerSession.getCachedToken.mockReturnValue(null); + stalkerSession.ensureToken.mockResolvedValue({ + token: 'PERSISTED77', + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::99', + name: 'Gated Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '99', + radio: 'true', + stalkerCmd: 'https://stalker.example.com/radio/99.mp3', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).toHaveBeenCalled(); + // The fast path must stay a fast path: no create_link round trip. + expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled(); + expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77'); + }); + + it('falls back to create_link when the cold session cannot be established', async () => { + // A portal-owned static stream with no usable session would be served + // knowing it will 401, so playback is not blocked — it takes the + // `create_link` route instead, which mints a URL carrying its own + // token and is the only path that can trigger the lazy portal repair. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: + 'https://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Partial) + ); + stalkerSession.getCachedToken.mockReturnValue(null); + stalkerSession.ensureToken.mockRejectedValue( + new Error('handshake refused') + ); + stalkerSession.makeAuthenticatedRequest.mockResolvedValue({ + js: { cmd: 'ffmpeg https://stalker.example.com/radio/99-tmp.mp3' }, + }); + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-1::99', + name: 'Gated Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '99', + radio: 'true', + stalkerCmd: 'https://stalker.example.com/radio/99.mp3', + } satisfies UnifiedCollectionItem); + + expect(playback.streamUrl).toBe( + 'https://stalker.example.com/radio/99-tmp.mp3' + ); + // No session, so no Bearer — the minted URL carries its own token. + expect(playback.headers?.['Authorization']).toBeUndefined(); + }); + + it('does not authenticate a simple portal for playback headers', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-2', + portalUrl: 'https://simple.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + stalkerSession.getCachedToken.mockReturnValue(null); + + await service.resolvePlayback({ + uid: 'stalker::stalker-2::99', + name: 'Simple Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-2', + playlistName: 'Stalker', + stalkerId: '99', + radio: 'true', + stalkerCmd: 'https://simple.example.com/radio/99.mp3', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.ensureToken).not.toHaveBeenCalled(); + }); + it('keeps Stalker collection playback from a foreign CDN credential-free', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ @@ -1027,10 +1230,12 @@ describe('StreamResolverService', () => { _id: 'stalker-1', portalUrl: 'https://new.example.com/portal.php', macAddress: 'AA:BB:CC:00:00:99', - isFullStalkerPortal: false, + isFullStalkerPortal: true, } satisfies Partial) ); - stalkerSession.getCachedToken.mockReturnValue('TOKEN-NEW'); + // A full portal, so the Bearer assertion below is meaningful: only a + // portal with a session has a token to attach at all. + stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN-NEW' }); const playback = await service.resolvePlayback({ uid: 'stalker::stalker-1::93', diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 2d265e795..eedfd3730 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -8,6 +8,7 @@ import { Channel, EpgItem, EpgProgram, + isFullStalkerPortalPlaylist, Playlist, isStalkerStreamCredentialSafe, ResolvedPortalPlayback, @@ -505,7 +506,7 @@ export class StreamResolverService { * them to the stream origin; foreign hosts get the credential-free * profile from the shared classifier). */ - private buildStalkerPlayback( + private async buildStalkerPlayback( item: UnifiedCollectionItem, playlist: Playlist | undefined, resolved: { @@ -514,24 +515,51 @@ export class StreamResolverService { streamUrl: string; isLive?: boolean; } - ): ResolvedPortalPlayback { + ): Promise { // The item may carry portal/mac overrides for playlists that no // longer exist; the builder only reads header-relevant fields. + // + // The override is applied to the ROW, not just folded in as the two + // resolved coordinates: a repair rewrites the portal MODE as well as + // the URL, and a playlist repaired from simple to full would otherwise + // keep its stale `isFullStalkerPortal: false` here. The request that + // just ran adopted a token under the repaired mode, but the mode-aware + // token resolver below would read the stale flag and hand back none — + // emitting a same-host gated stream without the Bearer header on the + // very playback the repair existed to rescue. const headerPlaylist = { - ...(playlist ?? {}), + ...(playlist ? this.portalRepair.applyOverride(playlist) : {}), macAddress: resolved.macAddress, portalUrl: resolved.portalUrl, } as Playlist; - const token = this.stalkerSession.getCachedToken(item.playlistId); + const crossOriginStream = isCrossOriginStalkerStream( + headerPlaylist, + resolved.streamUrl + ); + // Classified before authenticating, for the same reason the static + // branch classifies first: the header builder gives a foreign host the + // credential-free profile, so a token obtained here would be discarded + // — after stalling playback behind a handshake against a portal that + // may be slow or offline while the CDN is perfectly reachable. + // + // When it IS needed, the token is resolved from `headerPlaylist` + // rather than the row it came from: those are the exact coordinates + // the headers claim, so the bearer token and the MAC cookie cannot end + // up bound to a different endpoint than the one they are sent to. A + // repair override that moved the endpoint is the live case — it + // reaches `resolved.portalUrl` but not the raw row, and every other + // session consumer (`ensureStalkerSession`, `executeStalkerRequest`) + // already authenticates against the override, so this also stops the + // resolver from keying the session cache differently and re-shaking. + const token = + playlist && !crossOriginStream + ? await this.resolveStalkerPlaybackToken(headerPlaylist) + : null; const headers = buildStalkerExternalPlaybackHeaders( headerPlaylist, token, resolved.streamUrl ); - const crossOriginStream = isCrossOriginStalkerStream( - headerPlaylist, - resolved.streamUrl - ); const portalOrigin = getStalkerPortalOrigin(headerPlaylist); return { @@ -550,6 +578,40 @@ export class StreamResolverService { }; } + /** + * Resolves the Bearer token a full portal's stream needs. + * + * A direct-URL radio favorite skips `create_link` entirely, so on a cold + * session nothing has authenticated yet and the in-memory cache is empty — + * a same-host Bearer-gated stream would then 403 in the built-in audio + * player. `ensureToken()` re-presents the persisted token instead, which + * is a single idempotent handshake rather than a full re-auth. Failures + * stay non-fatal: many portals do not gate the stream itself. + */ + private async resolveStalkerPlaybackToken( + playlist: Playlist | undefined + ): Promise { + // The shared mode contract, not the raw flag: a legacy row with an + // absent flag but a canonical URL IS a full portal, and reading the + // property directly would skip authentication for it — a restored + // older backup opens a direct-URL radio favorite with no Bearer. + if (!playlist || !isFullStalkerPortalPlaylist(playlist)) { + return null; + } + + // Always through ensureToken, never the raw cache accessor: that one + // skips the endpoint/identity/credential check, so after an edit this + // playback path would put the previous account's token into the + // stream headers. ensureToken returns the cached token when it is + // still valid for this playlist, so a warm session costs nothing. + try { + const { token } = await this.stalkerSession.ensureToken(playlist); + return token; + } catch { + return null; + } + } + private buildStalkerRadioChannel( item: UnifiedCollectionItem, playback: ResolvedPortalPlayback diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 1b5f1df69..f76f8d307 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -2,6 +2,10 @@ export * from './lib/models'; export * from './lib/stores'; export * from './lib/stalker-account-info.service'; export * from './lib/stalker-content-types'; +export * from './lib/stalker-portal-error'; +export * from './lib/stalker-response-classification'; +export * from './lib/stalker-session-store'; +export * from './lib/stalker-watchdog.controller'; export * from './lib/stalker-itv-cache.service'; export * from './lib/stalker-live-playback.utils'; export * from './lib/stalker-portal-discovery.service'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts new file mode 100644 index 000000000..43b31b6a2 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts @@ -0,0 +1,423 @@ +import type { DataService } from '@iptvnator/services'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { StalkerAuthApi } from './stalker-auth.api'; + +/** + * Client-side mirror of the mock server's `auth-handlers.spec.ts`: the same + * documented flows — status 2 → do_auth → profile retry, blocked profiles, + * idempotent token reuse, not_valid propagation — asserted against the + * requests the client actually sends. + */ +describe('StalkerAuthApi', () => { + const portalUrl = + 'https://portal.example.com/stalker_portal/server/load.php'; + const macAddress = '00:1A:79:AA:BB:CC'; + + let sendIpcEvent: jest.Mock; + let api: StalkerAuthApi; + + const logger = { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + digest: jest.fn( + async () => new Uint8Array(20).fill(1).buffer + ), + }, + }, + }); + + sendIpcEvent = jest.fn(); + api = new StalkerAuthApi( + { sendIpcEvent } as unknown as DataService, + logger + ); + }); + + function callsByAction(action: string) { + return sendIpcEvent.mock.calls.filter( + (call) => call[1].params.action === action + ); + } + + it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1', not_valid: 0 }, + }) + .mockResolvedValueOnce({ + js: { status: 2, template: 'auth', info: 'Login required' }, + }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ + js: { status: 0, watchdog_timeout: 120, timeslot: 15 }, + }); + + const result = await api.authenticate( + portalUrl, + macAddress, + { deviceId1: 'DEV-1', deviceId2: 'DEV-2' }, + { credentials: { username: 'user', password: 'secret' } } + ); + + expect(result.token).toBe('TOKEN-1'); + expect(result.watchdogTimeoutSeconds).toBe(120); + expect(result.timeslotSeconds).toBe(15); + + // First profile request is NOT the second auth step. + const profiles = callsByAction('get_profile'); + expect(profiles).toHaveLength(2); + expect(profiles[0][1].params.auth_second_step).toBe('0'); + // The retry after do_auth is. + expect(profiles[1][1].params.auth_second_step).toBe('1'); + + // do_auth carries the credentials and the device identity. + const doAuth = callsByAction('do_auth'); + expect(doAuth).toHaveLength(1); + expect(doAuth[0][1].params).toEqual( + expect.objectContaining({ + login: 'user', + password: 'secret', + device_id: 'DEV-1', + device_id2: 'DEV-2', + }) + ); + }); + + it('throws login-required when the portal wants credentials and none are stored', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 2 } }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'login-required', + }); + // Empty credentials are never sent to the billing script. + expect(callsByAction('do_auth')).toHaveLength(0); + }); + + it('throws login-rejected when do_auth answers {js:false}', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 2 } }) + .mockResolvedValueOnce({ js: false }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'wrong' }, + }) + ).rejects.toMatchObject({ kind: 'login-rejected' }); + }); + + it('throws login-rejected when the profile still demands a login after do_auth', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 2 } }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ js: { status: 2 } }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'secret' }, + }) + ).rejects.toMatchObject({ kind: 'login-rejected' }); + }); + + it('carries the portal explanation out of every login refusal', async () => { + // The actionable sentence ("wrong password", "subscription expired") + // rides along with the refusal, and the dialog renders it after the + // generic line. Each exit must read the response IN HAND: the retry + // explains its own rejection, and quoting the first profile back + // would describe a request that already succeeded. + const handshake = { js: { token: 'TOKEN-1', random: 'r1' } }; + const credentials = { username: 'user', password: 'secret' }; + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ + js: { status: 2, msg: 'Enter your subscriber login' }, + }); + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + kind: 'login-required', + portalText: 'Enter your subscriber login', + }); + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } }) + .mockResolvedValueOnce({ js: false }); + await expect( + api.authenticate(portalUrl, macAddress, {}, { credentials }) + ).rejects.toMatchObject({ + kind: 'login-rejected', + portalText: 'Login needed', + }); + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ + js: { status: 2, block_msg: 'Subscription expired' }, + }); + await expect( + api.authenticate(portalUrl, macAddress, {}, { credentials }) + ).rejects.toMatchObject({ + kind: 'login-rejected', + // The retry's text, not the first profile's. + portalText: 'Subscription expired', + }); + }); + + it('decodes a blocked profile into the portal explanation', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ + js: { + status: 1, + msg: 'device conflict - device_id mismatch', + block_msg: 'Your STB is damaged.
Call the provider.', + }, + }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + kind: 'blocked', + portalText: + 'device conflict - device_id mismatch — Your STB is damaged. Call the provider.', + }); + }); + + it('treats a bare {status:1} profile as refused', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: 1 } }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ kind: 'blocked' }); + }); + + it('propagates the handshake not_valid flag into not_valid_token', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'NEW-TOKEN', random: 'r1', not_valid: 1 }, + }) + .mockResolvedValueOnce({ js: { status: 0 } }); + + await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STALE-TOKEN', + }); + + const profile = callsByAction('get_profile')[0][1]; + expect(profile.params.not_valid_token).toBe('1'); + }); + + it('re-presents a stored token and skips get_profile when it comes back unchanged', async () => { + sendIpcEvent.mockResolvedValueOnce({ + js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 0 }, + }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STORED-TOKEN', + skipProfileWhenReused: true, + }); + + expect(result).toEqual({ + token: 'STORED-TOKEN', + reusedStoredToken: true, + }); + expect(callsByAction('handshake')[0][1].params.token).toBe( + 'STORED-TOKEN' + ); + expect(callsByAction('get_profile')).toHaveLength(0); + }); + + it('runs the full profile when the portal replaces a stale stored token', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'FRESH-TOKEN', random: 'r1', not_valid: 1 }, + }) + .mockResolvedValueOnce({ js: { status: 0 } }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STALE-TOKEN', + skipProfileWhenReused: true, + }); + + expect(result.token).toBe('FRESH-TOKEN'); + expect(result.reusedStoredToken).toBe(false); + expect(callsByAction('get_profile')).toHaveLength(1); + }); + + it('does not reuse an echoed token the portal flagged not_valid', async () => { + // `not_valid: 1` is the portal saying the presented token is not a + // live session. Adopting it because the string matched would be + // unrecoverable: nothing writes a replacement back, so every later + // start would re-present the same dead token. + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 1 }, + }) + .mockResolvedValueOnce({ js: { status: 0 } }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + storedToken: 'STORED-TOKEN', + skipProfileWhenReused: true, + }); + + expect(result.reusedStoredToken).toBe(false); + expect(callsByAction('get_profile')).toHaveLength(1); + expect(callsByAction('get_profile')[0][1].params.not_valid_token).toBe( + '1' + ); + }); + + it('decodes a stringified status 2 into the login flow', async () => { + // Portals routinely stringify numeric fields — the same reason + // watchdog_timeout/timeslot accept strings. A strict === would read + // "2" as a healthy profile and skip do_auth entirely. + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: '2' } }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ js: { status: '0' } }); + + const result = await api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'secret' }, + }); + + expect(result.token).toBe('TOKEN-1'); + expect(callsByAction('do_auth')).toHaveLength(1); + expect( + callsByAction('get_profile')[1][1].params.auth_second_step + ).toBe('1'); + }); + + it('decodes a stringified status 1 as a refusal', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ js: { status: '1' } }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ kind: 'blocked' }); + }); + + it('never sends get_profile once the caller abandoned the attempt', async () => { + // The harm this prevents: `get_profile` is what adopts a token for + // the MAC portal-side, so an abandoned discovery attempt reaching it + // after another candidate authenticated would invalidate that + // healthy candidate's token. + const abandon = new AbortController(); + sendIpcEvent.mockImplementationOnce(async () => { + abandon.abort(); + return { js: { token: 'TOKEN-1', random: 'r1' } }; + }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(callsByAction('handshake')).toHaveLength(1); + expect(callsByAction('get_profile')).toHaveLength(0); + }); + + it('does not even handshake when already abandoned', async () => { + const abandon = new AbortController(); + abandon.abort(); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('rechecks the abort after the async prehash, not just before the call', async () => { + // getProfile awaits generatePrehash(); an abort landing during that + // await would otherwise still let the adopting request go out. + const abandon = new AbortController(); + const digest = globalThis.crypto.subtle.digest as jest.Mock; + digest.mockImplementation(async () => { + abandon.abort(); + return new Uint8Array(20).fill(1).buffer; + }); + + await expect( + api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', { + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('stops the status-2 login flow when abandoned mid-way', async () => { + const abandon = new AbortController(); + sendIpcEvent + .mockResolvedValueOnce({ js: { token: 'TOKEN-1', random: 'r1' } }) + .mockImplementationOnce(async () => { + abandon.abort(); + return { js: { status: 2 } }; + }); + + await expect( + api.authenticate(portalUrl, macAddress, {}, { + credentials: { username: 'user', password: 'secret' }, + signal: abandon.signal, + }) + ).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' }); + + expect(callsByAction('do_auth')).toHaveLength(0); + }); + + it('classifies a transport auth-failure marker during get_profile', async () => { + sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'TOKEN-1', random: 'r1' }, + }) + .mockResolvedValueOnce({ + stalkerAuthFailure: 'Unauthorized request.', + }); + + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + kind: 'auth-failed', + failureBody: 'Unauthorized request.', + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts new file mode 100644 index 000000000..fa5f6e020 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts @@ -0,0 +1,546 @@ +import type { DataService } from '@iptvnator/services'; +import { + extractStalkerAuthFailureBody, + STALKER_REQUEST, +} from '@iptvnator/shared/interfaces'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { + normalizeStalkerPortalIdentity, + type StalkerPortalIdentity, +} from './stalker-identity.utils'; +import { + combineStalkerPortalMessages, + StalkerPortalError, +} from './stalker-portal-error'; + +export interface StalkerHandshakeResponse { + js: { + token: string; + not_valid?: number; + random?: string; + }; +} + +export interface StalkerProfileResponse { + js: { + id?: string; + name?: string; + mac?: string; + status?: number; + msg?: string; + block_msg?: string; + /** Watchdog cadence in seconds, echoed by the portal (default 120). */ + watchdog_timeout?: number | string; + /** Per-user jitter in seconds so watchdog pings do not align. */ + timeslot?: number | string; + account_info?: { + login?: string; + expire_date?: number; + tariff_plan_name?: string; + status?: number; + }; + }; +} + +interface StalkerAuthConfirmationResponse { + js?: boolean; +} + +export interface StalkerPortalCredentials { + username?: string; + password?: string; +} + +export interface StalkerAuthenticateOptions { + /** + * Previously persisted session token. The handshake is idempotent: it is + * re-presented, and the portal returns it unchanged while it is still the + * MAC's session token. + */ + storedToken?: string; + /** Login/password for portals that answer `get_profile` with status 2. */ + credentials?: StalkerPortalCredentials; + /** + * Skip the `get_profile` round trip when the stored token came back + * unchanged — an unchanged token is already an adopted session. + */ + skipProfileWhenReused?: boolean; + /** + * Abandons the flow between portal calls. + * + * This is what a timed-out discovery attempt needs: `get_profile` is the + * call that adopts a token for the MAC portal-side, so an abandoned + * attempt reaching it AFTER another candidate authenticated would + * invalidate that healthy candidate's token. Checking the signal before + * each call stops the request from being sent, which is the only thing + * that prevents it — a request already on the wire is processed by the + * server whether or not the client tears the socket down. + */ + signal?: AbortSignal; +} + +export interface StalkerAuthenticationResult { + token: string; + accountInfo?: StalkerProfileResponse['js']['account_info']; + /** + * Raw envelope so callers can apply their own failure checks — endpoint + * discovery rejects a candidate whose `get_profile` refuses even though + * the handshake succeeded. Absent when the stored token was reused and + * no profile was fetched. + */ + profileResponse?: StalkerProfileResponse; + /** True when the stored token was accepted and `get_profile` was skipped. */ + reusedStoredToken?: boolean; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; +} + +interface StalkerHandshakeOutcome { + token: string; + random: string; + /** The portal flagged the presented stored token as no longer valid. */ + notValid: boolean; +} + +/** + * SHA1 hash using native Web Crypto API + * Produces correct 40-character hex hash matching real Stalker clients + */ +async function sha1(str: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates SHA1 prehash from MAC address + * This must match what real Stalker clients send + */ +async function generatePrehash(macAddress: string): Promise { + // Use MAC address with colons, uppercase - this is what most clients use + const str = macAddress.toUpperCase(); + return (await sha1(str)).toUpperCase(); +} + +/** + * Generates a random string for metrics + */ +function generateRandom(): string { + const chars = 'abcdef0123456789'; + let result = ''; + for (let i = 0; i < 40; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function toFiniteNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +/** Raised when a caller abandoned the flow between portal calls. */ +export class StalkerAuthAbortedError extends Error { + constructor() { + super('Stalker authentication was aborted'); + this.name = 'StalkerAuthAbortedError'; + } +} + +function assertNotAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new StalkerAuthAbortedError(); + } +} + +/** + * Throws when a response is one of the portal's plain-text auth-failure + * bodies (already classified by the transport, or still raw on legacy paths). + */ +function assertNotAuthFailure(response: unknown): void { + const failureBody = extractStalkerAuthFailureBody(response); + if (failureBody) { + throw new StalkerPortalError('auth-failed', failureBody, failureBody); + } +} + +/** + * Raw Stalker authentication requests plus the documented login flow. + * + * Protocol (Stalker 4.9.35): `handshake` issues an idempotent token; + * `get_profile` turns it into a session and decodes as: full profile = OK, + * `status: 1` = blocked (see `msg`/`block_msg`), `status: 2` = login/password + * required → `do_auth`, then `get_profile` again with `auth_second_step=1`. + */ +export class StalkerAuthApi { + constructor( + private readonly dataService: DataService, + private readonly logger: ReturnType + ) {} + + async performHandshake( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + storedToken?: string, + signal?: AbortSignal + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const prehash = await generatePrehash(macAddress); + + const params: Record = { + type: 'stb', + action: 'handshake', + // Re-presenting a persisted token is how a real client resumes a + // session: an idempotent portal returns it unchanged. + token: storedToken ?? '', + prehash, + JsHttpRequest: '1-xml', + }; + + try { + // Re-checked here rather than only at the call site: the prehash + // above is async, so an abort can land while it is being + // computed and the request would still go out. + assertNotAborted(signal); + const response = + await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), + } + ); + + assertNotAuthFailure(response); + + if (response?.js?.token) { + return { + token: response.js.token, + random: response.js.random || generateRandom(), + notValid: Number(response.js.not_valid ?? 0) === 1, + }; + } + + this.logger.error('No token in response'); + throw new Error('Handshake failed: No token received'); + } catch (error) { + this.logger.error('Handshake error:', error); + throw error; + } + } + + /** + * Gets account profile information to validate the portal and check + * subscription. `authSecondStep` is set only on the retry after `do_auth`; + * `notValidToken` propagates the handshake's `not_valid` flag. + */ + async getProfile( + portalUrl: string, + macAddress: string, + token: string, + identity: StalkerPortalIdentity, + handshakeRandom: string, + options: { + authSecondStep?: boolean; + notValidToken?: boolean; + signal?: AbortSignal; + } = {} + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + + // Build metrics JSON matching working app + const metrics: Record = { + mac: macAddress, + model: 'MAG250', + type: 'STB', + random: handshakeRandom, + ...(normalizedIdentity.serialNumber + ? { sn: normalizedIdentity.serialNumber } + : {}), + }; + + // Generate prehash for get_profile (same as handshake) + const prehash = await generatePrehash(macAddress); + + const params: Record = { + type: 'stb', + action: 'get_profile', + hd: '1', + not_valid_token: options.notValidToken ? '1' : '0', + video_out: 'hdmi', + auth_second_step: options.authSecondStep ? '1' : '0', + num_banks: '2', + metrics: JSON.stringify(metrics), + ...(normalizedIdentity.serialNumber + ? { sn: normalizedIdentity.serialNumber } + : {}), + ...(normalizedIdentity.deviceId1 + ? { device_id: normalizedIdentity.deviceId1 } + : {}), + ...(normalizedIdentity.deviceId2 + ? { device_id2: normalizedIdentity.deviceId2 } + : {}), + ...(normalizedIdentity.signature1 + ? { signature: normalizedIdentity.signature1 } + : {}), + ...(normalizedIdentity.signature2 + ? { signature2: normalizedIdentity.signature2 } + : {}), + prehash: prehash, + stb_type: '', + JsHttpRequest: '1-xml', + }; + + try { + // The last possible moment before the call that adopts the MAC's + // token portal-side — the prehash above is async, so the caller's + // pre-check can be stale by now. + assertNotAborted(options.signal); + const response = + await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params, + token, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), + } + ); + + assertNotAuthFailure(response); + return response; + } catch (error) { + this.logger.error('Get profile error:', error); + throw error; + } + } + + /** + * Performs `do_auth` — the login/password step behind `get_profile` + * status 2. Returns the portal's bare boolean verdict. + */ + async doAuth( + portalUrl: string, + macAddress: string, + token: string, + credentials: StalkerPortalCredentials, + identity: StalkerPortalIdentity = {}, + signal?: AbortSignal + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const params: Record = { + type: 'stb', + action: 'do_auth', + login: credentials.username ?? '', + password: credentials.password ?? '', + ...(normalizedIdentity.deviceId1 + ? { device_id: normalizedIdentity.deviceId1 } + : {}), + ...(normalizedIdentity.deviceId2 + ? { device_id2: normalizedIdentity.deviceId2 } + : {}), + JsHttpRequest: '1-xml', + }; + + try { + assertNotAborted(signal); + const response = + await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params, + token, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), + } + ); + + assertNotAuthFailure(response); + + // do_auth returns { js: true } on success + return response?.js === true; + } catch (error) { + this.logger.error('do_auth error:', error); + throw error; + } + } + + /** + * Full authentication flow: handshake → (optional token reuse) → + * get_profile → (status 2 → do_auth → get_profile with + * auth_second_step=1) → decoded profile. + */ + async authenticate( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + options: StalkerAuthenticateOptions = {} + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + + assertNotAborted(options.signal); + const handshake = await this.performHandshake( + portalUrl, + macAddress, + normalizedIdentity, + options.storedToken, + options.signal + ); + + // An unchanged stored token is already an adopted session — the + // profile round trip is what token persistence saves. `not_valid` + // vetoes that shortcut: it is the portal saying the presented token + // is not a live session, and adopting it anyway would be + // unrecoverable — nothing writes a new token back, so every later + // start would re-present the same dead one. + if ( + options.skipProfileWhenReused && + options.storedToken && + !handshake.notValid && + handshake.token === options.storedToken + ) { + return { token: handshake.token, reusedStoredToken: true }; + } + + // The abandoned-attempt guard that matters: this is the call that + // adopts the token for the MAC portal-side. + assertNotAborted(options.signal); + const profile = await this.getProfile( + portalUrl, + macAddress, + handshake.token, + normalizedIdentity, + handshake.random, + { + authSecondStep: false, + notValidToken: handshake.notValid, + signal: options.signal, + } + ); + + // The envelope the login flow actually settled on: after a status-2 + // `do_auth` that is the RETRIED profile, not the first one. + const profileResponse = await this.resolveProfile( + profile, + portalUrl, + macAddress, + handshake, + normalizedIdentity, + options + ); + const resolved = profileResponse?.js; + + return { + token: handshake.token, + accountInfo: resolved?.account_info, + profileResponse, + reusedStoredToken: false, + watchdogTimeoutSeconds: toFiniteNumber(resolved?.watchdog_timeout), + timeslotSeconds: toFiniteNumber(resolved?.timeslot), + }; + } + + /** + * Decodes `js.status` and drives the status-2 login flow. Throws a + * `StalkerPortalError` carrying the portal's own `msg`/`block_msg` text + * when the portal refused the session. + */ + private async resolveProfile( + profile: StalkerProfileResponse, + portalUrl: string, + macAddress: string, + handshake: StalkerHandshakeOutcome, + identity: StalkerPortalIdentity, + options: StalkerAuthenticateOptions + ): Promise { + let settled = profile; + let js = profile?.js; + + // Portals routinely stringify numeric fields (the same reason + // `watchdog_timeout`/`timeslot` are typed `number | string`), so a + // strict `=== 2` would read `"2"` as a healthy profile and skip the + // whole login flow. + if (toFiniteNumber(js?.status) === 2) { + // The portal's own sentence travels WITH the refusal — "wrong + // password", "subscription expired", "contact your provider" — + // and reading it is the whole reason these errors carry + // `portalText`. It is read at each exit rather than once up + // front because `js` advances to the retry's response below: the + // second refusal explains itself, and quoting the first one back + // would describe a request that already succeeded. + const loginText = () => + combineStalkerPortalMessages(js?.msg, js?.block_msg); + const username = options.credentials?.username?.trim() ?? ''; + const password = options.credentials?.password ?? ''; + if (!username || !password) { + throw new StalkerPortalError('login-required', loginText()); + } + + assertNotAborted(options.signal); + const accepted = await this.doAuth( + portalUrl, + macAddress, + handshake.token, + { username, password }, + identity, + options.signal + ); + if (!accepted) { + // `do_auth` answers a bare `{js: false}`, so the only text + // available here is the profile's — which is the one that + // asked for the login in the first place. + throw new StalkerPortalError('login-rejected', loginText()); + } + + assertNotAborted(options.signal); + const retried = await this.getProfile( + portalUrl, + macAddress, + handshake.token, + identity, + handshake.random, + { + authSecondStep: true, + notValidToken: handshake.notValid, + signal: options.signal, + } + ); + settled = retried; + js = retried?.js; + if (toFiniteNumber(js?.status) === 2) { + throw new StalkerPortalError('login-rejected', loginText()); + } + } + + const portalText = combineStalkerPortalMessages( + js?.msg, + js?.block_msg + ); + + if (toFiniteNumber(js?.status) === 1 || portalText) { + this.logger.error('Profile error:', portalText ?? 'status 1'); + throw new StalkerPortalError('blocked', portalText); + } + + return settled; + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts index 38471a5ca..a03d54a1d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts @@ -101,7 +101,12 @@ describe('StalkerPortalDiscoveryService', () => { expect(authenticate).toHaveBeenCalledWith( 'http://ministra.example/server/load.php', MAC, - { serialNumber: 'SN1' } + { serialNumber: 'SN1' }, + // Import credentials ride along for portals that answer + // get_profile with status 2 (login/password required). + // A per-attempt abort signal: a timed-out authentication must + // not send its get_profile behind the next candidate's back. + { credentials: undefined, signal: expect.any(AbortSignal) } ); }); @@ -190,7 +195,10 @@ describe('StalkerPortalDiscoveryService', () => { expect(authenticate).toHaveBeenCalledWith( 'http://gated.example/portal.php', MAC, - {} + {}, + // A per-attempt abort signal: a timed-out authentication must + // not send its get_profile behind the next candidate's back. + { credentials: undefined, signal: expect.any(AbortSignal) } ); }); @@ -343,4 +351,153 @@ describe('StalkerPortalDiscoveryService', () => { isFullStalkerPortal: false, }); }); + + it('aborts a timed-out authentication before advancing to the next candidate', async () => { + jest.useFakeTimers(); + try { + mockProbes({ + 'http://slow.example/portal.php': { + resolve: 'Authorization failed.', + }, + }); + + let captured: AbortSignal | undefined; + authenticate.mockImplementation( + (_url, _mac, _identity, options) => { + captured = options?.signal; + // Hangs past the auth budget, like the real symptom. + return new Promise(() => undefined); + } + ); + + const discovery = service.discover( + 'http://slow.example/c', + MAC + ); + // Let the probe resolve so the auth attempt actually starts. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.advanceTimersByTime(65_000); + await Promise.resolve(); + + // The abandoned attempt is cancelled, so its get_profile never + // goes out to adopt the MAC's token behind a later candidate. + expect(captured?.aborted).toBe(true); + + // …and discovery does not RACE it: a request already dispatched + // cannot be un-sent, so the run drains it (bounded) instead of + // probing the next candidate while it may still land. + jest.advanceTimersByTime(15_000); + // Outcome shape is unchanged: a timed-out confirmation is still + // reported as a refusal of that endpoint. + await expect(discovery).resolves.toMatchObject({ + status: 'auth-rejected', + portalUrl: 'http://slow.example/portal.php', + }); + } finally { + jest.useRealTimers(); + } + }); + + it('drains an abandoned attempt instead of probing the next candidate', async () => { + jest.useFakeTimers(); + try { + // Two candidates both answer "auth required", so a resolved first + // attempt would stop the run — only a timed-out one advances. + mockProbes({ + 'http://slow.example/portal.php': { + resolve: 'Authorization failed.', + }, + 'http://slow.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + + let settleFirst: (() => void) | undefined; + authenticate + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + settleFirst = () => reject(new Error('late')); + }) + ) + .mockResolvedValue({ token: 'SECOND' }); + + const discovery = service.discover('http://slow.example/c', MAC); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + + jest.advanceTimersByTime(65_000); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + + // The first attempt is still on the wire: the second candidate + // must NOT have been authenticated yet, or its token could be + // invalidated by the first one's late get_profile. + expect(authenticate).toHaveBeenCalledTimes(1); + + // Once it settles, the run continues immediately. + settleFirst?.(); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + expect(authenticate).toHaveBeenCalledTimes(2); + await expect(discovery).resolves.toMatchObject({ + status: 'resolved', + }); + } finally { + jest.useRealTimers(); + } + }); + + it('stops discovery when the drain deadline expires with the attempt still live', async () => { + jest.useFakeTimers(); + try { + // Cancellation is cooperative: neither transport can pull a + // request off the wire. An attempt still unsettled 80 s in may + // yet land its `get_profile`, which adopts the MAC's token + // portal-side — so probing on would stake the next candidate's + // freshly issued session on a request nobody can recall. + mockProbes({ + 'http://hung.example/portal.php': { + resolve: 'Authorization failed.', + }, + 'http://hung.example/server/load.php': { + resolve: 'Authorization failed.', + }, + }); + + // Never settles — not even after the drain. + authenticate + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValue({ token: 'SECOND' }); + + const discovery = service.discover('http://hung.example/c', MAC); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + + jest.advanceTimersByTime(65_000); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + jest.advanceTimersByTime(15_000); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + + await expect(discovery).resolves.toMatchObject({ + status: 'auth-rejected', + abandonedInFlight: true, + }); + // The second candidate was never authenticated. + expect(authenticate).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts index 0764d747a..009c1c8e5 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts @@ -3,6 +3,7 @@ import { DataService } from '@iptvnator/services'; import { STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; import { + StalkerPortalCredentials, StalkerProfileResponse, StalkerSessionService, } from './stalker-session.service'; @@ -26,6 +27,13 @@ export interface StalkerPortalEndpointResolution { token?: string; /** Account block from the classification `get_profile` (full portals only). */ accountInfo?: StalkerProfileResponse['js']['account_info']; + /** + * Watchdog cadence the confirming `get_profile` advertised. Persisted at + * import because a later start reuses the token and skips the only + * response that carries it. + */ + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; } /** @@ -37,6 +45,16 @@ export interface StalkerPortalDiscoveryRejection { status: 'auth-rejected'; portalUrl: string; error?: unknown; + /** + * The abandoned attempt was STILL in flight when the drain deadline + * expired, so discovery must not advance. Cancellation is cooperative — + * neither transport can pull a request off the wire (the PWA `fetch()` + * takes no signal, and the Electron main process runs its HTTP request to + * completion) — so an attempt this far past its deadline may still land a + * `get_profile`, which adopts the MAC's token portal-side and would + * invalidate the session a later candidate had just established. + */ + abandonedInFlight?: boolean; } /** No candidate answered like a Stalker portal (host down or not a portal). */ @@ -51,15 +69,42 @@ export type StalkerPortalDiscoveryOutcome = /** Per-request guard so a hanging host cannot stall discovery forever. */ const PROBE_TIMEOUT_MS = 20_000; -/** authenticate() is two sequential requests; give it a matching budget. */ -const AUTH_TIMEOUT_MS = 45_000; +/** + * `authenticate()` is up to FOUR sequential requests once a portal answers + * `get_profile` with status 2 — handshake, profile, `do_auth`, profile retry — + * and the Electron transport allows each non-`create_link` call 15 s. A budget + * that only covered two would abort a valid but slow login-required portal + * before its final profile and report it as `auth-rejected`. + */ +const AUTH_TIMEOUT_MS = 4 * 15_000 + 5_000; -function withTimeout(promise: Promise, timeoutMs: number): Promise { +/** + * How long to wait for an abandoned attempt to settle before probing the next + * candidate. + * + * Aborting cannot un-send a request: if its `get_profile` was already + * dispatched, the portal adopts that token regardless of what the client does + * to its socket. What we CAN do is refuse to race it — advancing while it is + * still in flight is what lets it invalidate the token the next candidate + * negotiates. Bounded by one request budget so a genuinely hung host cannot + * stall discovery forever; past that the risk is accepted rather than hanging. + */ +const ABANDONED_DRAIN_MS = 15_000; + +function withTimeout( + promise: Promise, + timeoutMs: number, + onTimeout?: () => void +): Promise { return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('Stalker portal probe timed out')), - timeoutMs - ); + const timer = setTimeout(() => { + // Abandon the underlying operation BEFORE advancing: the timer + // only rejects this wrapper, and a late `get_profile` would adopt + // the MAC's token portal-side, invalidating whatever the next + // candidate just negotiated. + onTimeout?.(); + reject(new Error('Stalker portal probe timed out')); + }, timeoutMs); promise.then( (value) => { clearTimeout(timer); @@ -95,7 +140,8 @@ export class StalkerPortalDiscoveryService { async discover( rawUrl: string, macAddress: string, - identity: StalkerPortalIdentity = {} + identity: StalkerPortalIdentity = {}, + options: { credentials?: StalkerPortalCredentials } = {} ): Promise { const candidates = buildStalkerEndpointCandidates(rawUrl); let authRejection: StalkerPortalDiscoveryRejection | null = null; @@ -114,11 +160,15 @@ export class StalkerPortalDiscoveryService { const outcome = await this.confirmFullPortal( candidate, macAddress, - identity + identity, + options.credentials ); if (outcome.status === 'resolved') { return outcome; } + if (outcome.abandonedInFlight) { + return authRejection ?? outcome; + } authRejection = authRejection ?? outcome; continue; } @@ -158,11 +208,17 @@ export class StalkerPortalDiscoveryService { const outcome = await this.confirmFullPortal( candidate, macAddress, - identity + identity, + options.credentials ); if (outcome.status === 'resolved') { return outcome; } + // An attempt still on the wire outranks further probing: + // see `abandonedInFlight`. + if (outcome.abandonedInFlight) { + return authRejection ?? outcome; + } // The endpoint is real but refused our credentials; // remember the first such endpoint in case no later // candidate resolves. @@ -184,18 +240,25 @@ export class StalkerPortalDiscoveryService { private async confirmFullPortal( candidate: string, macAddress: string, - identity: StalkerPortalIdentity + identity: StalkerPortalIdentity, + credentials?: StalkerPortalCredentials ): Promise< StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection > { + // Cooperative cancellation: `authenticate()` checks this before each + // portal call, so a timed-out attempt never sends the `get_profile` + // that would adopt the MAC's token behind the next candidate's back. + const abandon = new AbortController(); + // Kept so a timed-out attempt can be drained rather than raced. + const pending = this.stalkerSession.authenticate( + candidate, + macAddress, + identity, + { credentials, signal: abandon.signal } + ); try { - const auth = await withTimeout( - this.stalkerSession.authenticate( - candidate, - macAddress, - identity - ), - AUTH_TIMEOUT_MS + const auth = await withTimeout(pending, AUTH_TIMEOUT_MS, () => + abandon.abort() ); // A handshake can hand out a token whose `get_profile` still // answers a structured denial (`{js:{error:'Invalid token'}}`); @@ -215,12 +278,41 @@ export class StalkerPortalDiscoveryService { isFullStalkerPortal: true, token: auth.token, accountInfo: auth.accountInfo, + watchdogTimeoutSeconds: auth.watchdogTimeoutSeconds, + timeslotSeconds: auth.timeslotSeconds, }; } catch (error) { + // Do not advance while the abandoned attempt may still be on the + // wire: its `get_profile` adopts the MAC's token portal-side, so + // racing it is exactly what invalidates the next candidate's + // freshly issued session. + // + // Draining is the normal case and usually returns at once — an + // aborted attempt settles as soon as its in-flight request errors + // out. The deadline exists for the attempt that does not settle, + // and reaching it is reported rather than swallowed: continuing + // would stake a working candidate's session on a request nobody + // can recall. + const DRAINED = Symbol('drained'); + const outcome = await Promise.race([ + pending.then( + () => DRAINED, + () => DRAINED + ), + new Promise((resolve) => + setTimeout(resolve, ABANDONED_DRAIN_MS) + ), + ]); + if (outcome !== DRAINED) { + this.logger.warn( + 'Abandoned Stalker authentication is still in flight after the drain deadline; stopping discovery rather than racing it' + ); + } return { status: 'auth-rejected', portalUrl: candidate, error, + ...(outcome === DRAINED ? {} : { abandonedInFlight: true }), }; } } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts new file mode 100644 index 000000000..f09ea54fd --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts @@ -0,0 +1,55 @@ +import { + asStalkerPortalError, + combineStalkerPortalMessages, + StalkerPortalError, + stripStalkerPortalMarkup, +} from './stalker-portal-error'; + +describe('stripStalkerPortalMarkup', () => { + it('strips the markup a real block_msg carries', () => { + expect( + stripStalkerPortalMarkup( + 'Your STB is damaged.
Call the provider.' + ) + ).toBe('Your STB is damaged. Call the provider.'); + }); + + it('collapses whitespace and trims', () => { + expect(stripStalkerPortalMarkup(' a \n b ')).toBe('a b'); + }); +}); + +describe('combineStalkerPortalMessages', () => { + it('joins msg and block_msg', () => { + expect( + combineStalkerPortalMessages('device conflict', 'STB damaged') + ).toBe('device conflict — STB damaged'); + }); + + it('drops empty and duplicated parts', () => { + expect(combineStalkerPortalMessages('', undefined)).toBeUndefined(); + expect(combineStalkerPortalMessages('same', 'same')).toBe('same'); + }); +}); + +describe('asStalkerPortalError', () => { + it('recognizes real instances', () => { + const error = new StalkerPortalError('blocked', 'text'); + expect(asStalkerPortalError(error)).toBe(error); + }); + + it('recognizes a structurally equivalent object across chunk boundaries', () => { + const shaped = { + name: 'StalkerPortalError', + kind: 'login-required', + message: 'refused', + }; + expect(asStalkerPortalError(shaped)?.kind).toBe('login-required'); + }); + + it('rejects ordinary errors and non-errors', () => { + expect(asStalkerPortalError(new Error('nope'))).toBeNull(); + expect(asStalkerPortalError('Access denied.')).toBeNull(); + expect(asStalkerPortalError(null)).toBeNull(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts new file mode 100644 index 000000000..d457b834d --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts @@ -0,0 +1,93 @@ +import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces'; + +/** + * Why a Stalker portal refused the session. + * + * - `login-required` — `get_profile` answered `status: 2`: the portal wants + * `do_auth` with a login/password, and none are stored for the playlist. + * - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing + * script refused the credentials), or the profile still demanded a login + * after a successful `do_auth`. + * - `blocked` — `get_profile` answered `status: 1`: the account is blocked or + * the device identity conflicts. `msg`/`block_msg` explain why. + * - `auth-failed` — a request came back as one of the plain-text bodies + * (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and + * re-authentication did not recover it. + */ +export type StalkerPortalErrorKind = + | 'login-required' + | 'login-rejected' + | 'blocked' + | 'auth-failed'; + +/** + * `block_msg` routinely carries markup ("Your STB is damaged.
Call the + * provider."); strip it before the text reaches a snackbar or error view. + */ +export function stripStalkerPortalMarkup(text: string): string { + return text + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Combines the portal's own `msg`/`block_msg` explanation into one plain-text + * line, or returns undefined when the portal sent none. + */ +export function combineStalkerPortalMessages( + msg: string | undefined, + blockMsg: string | undefined +): string | undefined { + const parts = [msg, blockMsg] + .map((part) => stripStalkerPortalMarkup(part ?? '')) + .filter((part) => part.length > 0); + // A duplicated msg/block_msg pair should not be echoed twice. + const unique = parts.filter( + (part, index) => parts.indexOf(part) === index + ); + return unique.length > 0 ? unique.join(' — ') : undefined; +} + +/** + * A portal-explained authentication failure. `portalText` carries the server's + * own words (already markup-stripped) when it sent any — the UI shows them + * verbatim instead of a generic "unable to load" message. + */ +export class StalkerPortalError extends Error { + constructor( + readonly kind: StalkerPortalErrorKind, + readonly portalText?: string, + readonly failureBody?: StalkerAuthFailureBody + ) { + super( + `Stalker portal refused the session (${kind})` + + (portalText ? `: ${portalText}` : '') + ); + this.name = 'StalkerPortalError'; + } +} + +/** + * Recognizes a StalkerPortalError across chunk boundaries: `instanceof` is + * checked first, but a structurally equivalent object (same name + kind) is + * accepted too so lazy-loaded consumers never mis-classify one. + */ +export function asStalkerPortalError( + error: unknown +): StalkerPortalError | null { + if (error instanceof StalkerPortalError) { + return error; + } + + if ( + typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'StalkerPortalError' && + typeof (error as { kind?: unknown }).kind === 'string' + ) { + return error as StalkerPortalError; + } + + return null; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index fd99719f3..27e066a7f 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -35,6 +35,7 @@ describe('StalkerPortalRepairService', () => { /** When set, the atomic write fails AFTER the transform verified. */ let persistError: Error | null; let setCachedToken: jest.Mock; + let adoptDiscoveredSession: jest.Mock; let clearCachedToken: jest.Mock; let refreshActiveWatchdogPlaylist: jest.Mock; @@ -61,6 +62,7 @@ describe('StalkerPortalRepairService', () => { return of(next); }); setCachedToken = jest.fn(); + adoptDiscoveredSession = jest.fn(); clearCachedToken = jest.fn(); refreshActiveWatchdogPlaylist = jest.fn(); @@ -81,6 +83,7 @@ describe('StalkerPortalRepairService', () => { provide: StalkerSessionService, useValue: { setCachedToken, + adoptDiscoveredSession, clearCachedToken, refreshActiveWatchdogPlaylist, }, @@ -210,12 +213,13 @@ describe('StalkerPortalRepairService', () => { portalUrl: 'http://ministra.example/server/load.php', isFullStalkerPortal: true, }); - // The classification handshake already produced a token, - // tagged with the playlist as its identity source. - expect(setCachedToken).toHaveBeenCalledWith( + // The classification handshake already produced a session; it is + // adopted whole (token + cadence), tagged with the REPAIRED + // configuration as its identity source. + expect(adoptDiscoveredSession).toHaveBeenCalledWith( 'portal-1', - 'TOKEN1', - expect.objectContaining({ _id: 'portal-1' }) + expect.objectContaining({ _id: 'portal-1' }), + expect.objectContaining({ token: 'TOKEN1' }) ); // A repaired ACTIVE playlist must re-sync the watchdog now: a // simple→full flip has to start the keepalive mid-session. @@ -282,6 +286,82 @@ describe('StalkerPortalRepairService', () => { expect(writtenRow).toBeNull(); }); + it('discards a repair whose credentials changed while probing', async () => { + // Discovery can run for tens of seconds; a login saved meanwhile + // means the outcome was negotiated for an account the row no + // longer belongs to, so committing it would adopt the wrong + // session. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://panel.example/server/load.php', + isFullStalkerPortal: true, + token: 'WRONG-ACCOUNT', + }); + // The row the transform sees carries the NEW login. + persistedRow = { + ...MISCLASSIFIED, + username: 'edited-mid-probe', + } as Playlist; + + expect( + await service.repairPortal({ + ...MISCLASSIFIED, + username: 'original', + }) + ).toBeNull(); + expect(writtenRow).toBeNull(); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); + }); + + it('adopts the cadence the repair confirmation discovered', async () => { + // Caching the token alone satisfies the retry, so NO + // authentication path would ever apply the profile outcome — the + // repaired playlist would keep the default cadence until the + // token failed or the app restarted. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://panel.example/server/load.php', + isFullStalkerPortal: true, + token: 'REPAIRED', + watchdogTimeoutSeconds: 60, + timeslotSeconds: 9, + }); + + await service.repairPortal(MISCLASSIFIED); + + expect(adoptDiscoveredSession).toHaveBeenCalledWith( + MISCLASSIFIED._id, + expect.objectContaining({ + portalUrl: 'http://panel.example/server/load.php', + }), + { + token: 'REPAIRED', + watchdogTimeoutSeconds: 60, + timeslotSeconds: 9, + } + ); + }); + + it("forwards the playlist's stored credentials to discovery", async () => { + // A login/password portal answers status 2 during confirmation; + // without the credentials the probe reports `login-required` and + // the source could never be repaired. + discover.mockResolvedValue({ status: 'unreachable' }); + + await service.repairPortal({ + ...MISCLASSIFIED, + username: 'user', + password: 'secret', + }); + + expect(discover).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Object), + { credentials: { username: 'user', password: 'secret' } } + ); + }); + it('probes at most once per playlist per session', async () => { discover.mockResolvedValue({ status: 'unreachable' }); @@ -438,7 +518,7 @@ describe('StalkerPortalRepairService', () => { expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); expect(writtenRow).toBeNull(); - expect(setCachedToken).not.toHaveBeenCalled(); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index f72bff60c..617bddf77 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -322,6 +322,11 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { playlist.portalUrl ?? '', isFullStalkerPortalPlaylist(playlist), stalkerIdentityFingerprint(playlist), + // Credentials are part of the discovery outcome now: a probe that + // failed on a wrong login must be retried once the login is + // corrected, instead of staying declined until the app restarts. + playlist.username ?? '', + playlist.password ?? '', ]); } @@ -336,7 +341,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { const outcome = await this.discovery.discover( playlist.portalUrl ?? '', playlist.macAddress ?? '', - getStalkerPortalIdentityFromPlaylist(playlist) + getStalkerPortalIdentityFromPlaylist(playlist), + { + // A login/password portal answers `get_profile` with status 2 + // during confirmation. Without the stored credentials the + // probe reports `login-required` and such a source could + // never be repaired, even though the playlist holds a + // working login. + credentials: { + username: playlist.username, + password: playlist.password, + }, + } ); if (outcome.status !== 'resolved') { @@ -416,14 +432,22 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { ?.set(this.repairSourceFingerprint(playlist), override); if (outcome.isFullStalkerPortal && outcome.token) { - // The classification handshake already authenticated; reuse its - // token so the retry does not immediately handshake again. The - // playlist itself is the identity source — a repair never - // changes WHO the session belongs to, only WHERE it talks. - this.stalkerSession.setCachedToken( + // The classification handshake already authenticated; adopt the + // whole session so the retry does not handshake again AND the + // cadence that profile advertised is applied — caching the token + // alone would satisfy the retry and leave the repaired playlist + // pinging on the default until restart. The identity source is + // the REPAIRED configuration: a repair never changes WHO the + // session belongs to, only WHERE it talks, and the session is + // bound to that endpoint. + this.stalkerSession.adoptDiscoveredSession( playlist._id, - outcome.token, - playlist + toStalkerSessionPlaylist(this.applyOverride(playlist)), + { + token: outcome.token, + watchdogTimeoutSeconds: outcome.watchdogTimeoutSeconds, + timeslotSeconds: outcome.timeslotSeconds, + } ); } else if (!outcome.isFullStalkerPortal) { this.stalkerSession.clearCachedToken(playlist._id); @@ -483,7 +507,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { row.portalUrl === playlist.portalUrl && isFullStalkerPortalPlaylist(row) === sourceMode && stalkerIdentityFingerprint(row) === - stalkerIdentityFingerprint(playlist) + stalkerIdentityFingerprint(playlist) && + // Credentials too, matching repairSourceFingerprint(): discovery + // can run for tens of seconds, and a login saved meanwhile means + // the outcome was negotiated for an account the row no longer + // belongs to — committing it would adopt the wrong session. + (row.username ?? '') === (playlist.username ?? '') && + (row.password ?? '') === (playlist.password ?? '') ); } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts new file mode 100644 index 000000000..15b7c2101 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts @@ -0,0 +1,48 @@ +import { + isStalkerAuthFailureMessage, + isStalkerAuthFailureResponse, +} from '@iptvnator/shared/interfaces'; + +/** + * Checks whether a response or thrown error indicates a Stalker authorization + * failure. + * + * Every signal here is structural. `isStalkerAuthFailureResponse` covers the + * complete set of portal auth failures — the plain-text bodies, the transport + * marker Electron mints from them, and the JSON-envelope forms — and is + * shared with endpoint discovery and the lazy repair, so a phrase one layer + * classifies as an auth failure cannot be ignored by another: the session + * would otherwise keep an expired token and every later request fails. + * + * What is deliberately gone: the old `JSON.stringify(response)` regex sweep. + * It matched the phrase anywhere in an arbitrary payload — including inside + * legitimate catalog data — and existed only because the raw body reached the + * renderer unclassified. The transports classify now, so the shapes are known. + */ +export function isStalkerAuthorizationFailure( + responseOrError: unknown +): boolean { + if (!responseOrError) { + return false; + } + + const response = responseOrError as Record; + const message = response?.['message']; + + if ( + isStalkerAuthFailureResponse(responseOrError) || + isStalkerAuthFailureMessage(message) + ) { + return true; + } + + // HTTP auth codes survive the IPC boundary only as message text + // (`HTTP Error 401: …`): the custom `status` property is stripped by + // ipcRenderer, so the numeric code must be read from the message. + if (typeof message === 'string' && /HTTP Error 40[13]\b/.test(message)) { + return true; + } + + // Same code on a response object that never crossed IPC. + return response?.['status'] === 401 || response?.['status'] === 403; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts new file mode 100644 index 000000000..01f311404 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -0,0 +1,266 @@ +import { firstValueFrom } from 'rxjs'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import type { PlaylistsService } from '@iptvnator/services'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller'; +import { stalkerIdentityFingerprint } from './stalker-identity.utils'; +import type { StalkerAuthenticationResult } from './stalker-auth.api'; + +/** + * Everything a Stalker session is bound to: the endpoint it was negotiated + * against, the device identity, and the account credentials. + * + * All three halves matter, and each was a real defect when missing: + * + * - **Endpoint** — `ensureToken()` re-presents tokens in a handshake, so a + * playlist repointed at another portal would disclose the previous one's + * bearer token to it. Origin alone is not enough: discovery deliberately + * preserves tenant base paths (`/tenant-a/…` vs `/tenant-b/…`), which are + * different portals on one host. + * - **Identity** — an edited MAC/serial must not inherit the old session. + * - **Credentials** — for a status-2 portal the login decides which account + * the token represents, so changing it must not keep serving the previous + * account's session. + * + * Used for BOTH the in-run cache and the persisted session: an edit applies + * without waiting for a restart. + */ +export function stalkerSessionFingerprint( + playlist: Pick< + Playlist, + | 'portalUrl' + | 'macAddress' + | 'username' + | 'password' + | 'stalkerSerialNumber' + | 'stalkerDeviceId1' + | 'stalkerDeviceId2' + | 'stalkerSignature1' + | 'stalkerSignature2' + > +): string { + return JSON.stringify([ + portalEndpoint(playlist.portalUrl), + stalkerIdentityFingerprint(playlist as Playlist), + playlist.username ?? '', + playlist.password ?? '', + ]); +} + +/** + * Origin AND path, minus query/fragment — the endpoint a session belongs to. + * + * No attempt is made to treat different handler paths for the same portal + * (`…/portal.php` vs `…/server/load.php`) as equivalent. Both sides of the + * comparison read the SAME persisted `portalUrl`, so the same portal always + * yields the same key; the value changes only on a real edit or repair, and + * both of those re-authenticate anyway. An equivalence rule would add + * machinery whose only failure mode is aliasing two genuinely different + * endpoints — the exact thing this key exists to prevent. + */ +function portalEndpoint(portalUrl: string | undefined): string { + if (!portalUrl) { + return ''; + } + try { + const parsed = new URL(portalUrl); + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; + } catch { + // Unparseable: fall back to the raw string so a change is still a + // change — never to a constant, which would alias every endpoint. + return portalUrl; + } +} + +/** + * Session facts persisted with the playlist between app starts. + * + * `identityFingerprint` is what makes the token safe to re-present: it records + * the identity the session was negotiated for, so an edited MAC or serial + * cannot inherit the previous session. + */ +export interface PersistedStalkerSession { + token?: string; + identityFingerprint?: string; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; +} + +/** + * Reads and writes the Stalker session persisted on the playlist row. + * + * Kept out of the session service because it is the only part that needs the + * persistence stack, which is resolved lazily: the service is constructed + * during bootstrap, and pulling `PlaylistsService` in eagerly would both + * widen its dependency graph and risk a DI cycle. + */ +export class StalkerSessionStore { + constructor( + private readonly resolvePlaylistsService: () => PlaylistsService, + private readonly logger: ReturnType + ) {} + + /** Reads a playlist row through the lazily-resolved persistence stack. */ + async readRow(playlistId: string): Promise { + const row = await firstValueFrom( + this.resolvePlaylistsService().getPlaylistById(playlistId) + ); + return (row as Playlist) ?? undefined; + } + + /** + * Reads the session persisted with the playlist: the token, the identity + * it was negotiated for, and the watchdog cadence the portal advertised. + * + * The token is only offered for reuse when its identity still matches — + * re-presenting one minted for an edited MAC would pair a new identity + * with an old session, exactly what the in-memory cache guards against. + */ + async read( + playlist: Playlist, + fingerprint: string + ): Promise { + const fromPlaylist: PersistedStalkerSession = { + token: playlist.stalkerToken || undefined, + identityFingerprint: playlist.stalkerSessionIdentity, + watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, + timeslotSeconds: playlist.stalkerTimeslot, + }; + + // Shortcut only when the object carries the cadence too. Taking it on + // the token alone would silently drop a persisted cadence for any + // playlist shape that copies the token without the timing fields. + const stored = + fromPlaylist.token && + fromPlaylist.watchdogTimeoutSeconds !== undefined + ? fromPlaylist + : await this.readFromRow(playlist, fromPlaylist); + + // A token with NO recorded fingerprint is unverified, not trusted: + // playlists written before the fingerprint existed carry one, and + // re-presenting it after an endpoint or identity edit is exactly the + // disclosure the fingerprint prevents. Such a row has no cadence + // either, so it already owes a full profile — refusing the token + // costs it nothing, and the write-back then records the fingerprint. + if (stored.token && stored.identityFingerprint !== fingerprint) { + return { ...stored, token: undefined }; + } + + return stored; + } + + private async readFromRow( + playlist: Playlist, + fallback: PersistedStalkerSession + ): Promise { + try { + const stored = await this.readRow(playlist._id); + return { + // The row is authoritative, but a meta that carried a token + // the row has not seen yet must not lose it. + token: stored?.stalkerToken || fallback.token, + identityFingerprint: + stored?.stalkerSessionIdentity ?? + fallback.identityFingerprint, + watchdogTimeoutSeconds: stored?.stalkerWatchdogTimeout, + timeslotSeconds: stored?.stalkerTimeslot, + }; + } catch (error) { + this.logger.debug('Persisted session lookup failed:', error); + return fallback; + } + } + + /** + * Propagates a successful authentication into session-side state: the + * watchdog cadence, and the write-back. + * + * Reusing a stored token skips the `get_profile` that carries the + * cadence, so the persisted cadence is applied instead — otherwise a + * portal advertising a non-default `watchdog_timeout` would sit on the + * 120 s fallback for the whole session. + */ + applyAuthenticationOutcome( + playlistId: string, + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string, + watchdog: { + applyProfileTiming: ( + playlistId: string, + timing: { + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; + } + ) => void; + } + ): void { + if (result.reusedStoredToken) { + watchdog.applyProfileTiming(playlistId, { + watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds, + timeslotSeconds: stored.timeslotSeconds, + }); + return; + } + + watchdog.applyProfileTiming(playlistId, { + watchdogTimeoutSeconds: result.watchdogTimeoutSeconds, + timeslotSeconds: result.timeslotSeconds, + }); + this.write(playlistId, result, stored, fingerprint); + } + + /** + * Writes a renegotiated session back, best effort. + * + * The EFFECTIVE cadence is stored, not the raw one: a portal that + * advertises nothing must still leave a value behind, or "no cadence + * stored" would keep meaning "never profiled" and every start would + * re-profile it. Stored absence therefore means exactly one thing — this + * playlist has never completed a profile. + * + * Returns the cadence that was applied, so the caller can drive the + * watchdog with the same numbers. + */ + write( + playlistId: string, + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string + ): void { + const watchdogTimeout = + result.watchdogTimeoutSeconds ?? + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS; + const timeslot = result.timeslotSeconds ?? 0; + const changed = + result.token !== stored.token || + fingerprint !== stored.identityFingerprint || + watchdogTimeout !== stored.watchdogTimeoutSeconds || + timeslot !== stored.timeslotSeconds; + + if (!changed) { + return; + } + + try { + void firstValueFrom( + this.resolvePlaylistsService().updateStalkerSession( + playlistId, + { + stalkerToken: result.token, + stalkerSessionIdentity: fingerprint, + stalkerWatchdogTimeout: watchdogTimeout, + stalkerTimeslot: timeslot, + } + ) + ).catch((error) => { + this.logger.debug('Session write-back failed:', error); + }); + } catch (error) { + // Persistence stack unavailable (e.g. isolated tests, or a DI + // cycle at this point in bootstrap). The session still works for + // this run; only the cross-restart reuse is lost. + this.logger.debug('Session write-back unavailable:', error); + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index bb4540b2a..2b2846b64 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -2,6 +2,9 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; +import { StalkerPortalError } from './stalker-portal-error'; +import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller'; +import { stalkerSessionFingerprint } from './stalker-session-store'; import { STALKER_SERIAL_NUMBER, StalkerProfileResponse, @@ -186,8 +189,10 @@ describe('StalkerSessionService identity-tagged token cache', () => { ); const oldAuth = service.ensureToken(playlistA); - for (let i = 0; i < 5; i += 1) { - await Promise.resolve(); + // ensureToken reads the persisted session before the handshake, so + // wait for the transport call rather than a fixed microtask count. + while (pendingResolvers.length === 0) { + await new Promise((resolve) => setTimeout(resolve)); } const editedIdentity = { @@ -198,10 +203,10 @@ describe('StalkerSessionService identity-tagged token cache', () => { // Settle the OLD identity's handshake + profile. pendingResolvers[0]({ js: { token: 'TOKEN-OLD', random: 'r' } }); - for (let i = 0; i < 10; i += 1) { - await Promise.resolve(); + while (pendingResolvers.length < 2) { + await new Promise((resolve) => setTimeout(resolve)); } - pendingResolvers[1]?.({ js: {} }); + pendingResolvers[1]({ js: {} }); await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' }); // The edited identity re-enters and negotiates its OWN session. @@ -285,13 +290,19 @@ describe('StalkerSessionService identity-tagged token cache', () => { service.setCachedToken('portal-1', 'DEAD', playlistA); sendIpcEvent.mockResolvedValue('Authorization failed.'); + // The typed refusal replaced the generic "Authorization failed after + // retry": callers need the portal's own reason to show the user. await expect( service.makeAuthenticatedRequest( playlistA, { action: 'get_events' }, false ) - ).rejects.toThrow('Authorization failed after retry'); + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'auth-failed', + failureBody: 'Authorization failed.', + }); // Leaving the dead token cached would hand it to the next caller. expect(service.getCachedToken('portal-1')).toBeNull(); @@ -374,6 +385,10 @@ describe('StalkerSessionService identity payloads', () => { let service: StalkerSessionService; let dataService: { sendIpcEvent: jest.Mock }; + let playlistsService: { + getPlaylistById: jest.Mock; + updateStalkerSession: jest.Mock; + }; beforeEach(() => { Object.defineProperty(globalThis, 'crypto', { @@ -390,11 +405,16 @@ describe('StalkerSessionService identity payloads', () => { dataService = { sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }), }; + playlistsService = { + getPlaylistById: jest.fn().mockReturnValue(of(undefined)), + updateStalkerSession: jest.fn().mockReturnValue(of(null)), + }; TestBed.configureTestingModule({ providers: [ StalkerSessionService, { provide: DataService, useValue: dataService }, + { provide: PlaylistsService, useValue: playlistsService }, ], }); @@ -484,13 +504,19 @@ describe('StalkerSessionService identity payloads', () => { stalkerSignature2: 'SIGNATURE-2', } as Playlist); - expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, { - serialNumber: 'CUSTOMSN123', - deviceId1: 'DEVICE-ID-1', - deviceId2: 'DEVICE-ID-2', - signature1: 'SIGNATURE-1', - signature2: 'SIGNATURE-2', - }); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + { + serialNumber: 'CUSTOMSN123', + deviceId1: 'DEVICE-ID-1', + deviceId2: 'DEVICE-ID-2', + signature1: 'SIGNATURE-1', + signature2: 'SIGNATURE-2', + }, + // No cadence stored for this playlist, so the profile must run. + expect.objectContaining({ skipProfileWhenReused: false }) + ); }); it('treats the legacy default serial number as absent during ensureToken', async () => { @@ -507,7 +533,12 @@ describe('StalkerSessionService identity payloads', () => { } as Playlist); expect(result.serialNumber).toBeUndefined(); - expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {}); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ skipProfileWhenReused: false }) + ); }); it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => { @@ -536,8 +567,9 @@ describe('StalkerSessionService identity payloads', () => { const refresh = service.refreshAccountProfile(playlist); // Two handshakes must never overlap: on strict portals the second - // would invalidate the first one's token. - await Promise.resolve(); + // would invalidate the first one's token. (Macrotask flush: the + // persisted-token lookup adds awaits before authenticate fires.) + await new Promise((resolve) => setTimeout(resolve)); expect(authenticate).toHaveBeenCalledTimes(1); releaseFirst({ token: 'session-token' }); @@ -574,7 +606,7 @@ describe('StalkerSessionService identity payloads', () => { const first = service.refreshAccountProfile(playlist); const second = service.refreshAccountProfile(playlist); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve)); expect(authenticate).toHaveBeenCalledTimes(1); releases[0]({ token: 'session-token' }); @@ -708,6 +740,581 @@ describe('StalkerSessionService identity payloads', () => { expect(accountInfo).toEqual({ login: 'user-2' }); }); + it('re-presents the token persisted on the playlist during ensureToken', async () => { + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'STORED-TOKEN' }); + + await service.ensureToken({ + _id: 'playlist-7', + portalUrl, + macAddress, + isFullStalkerPortal: true, + stalkerToken: 'STORED-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress, + } as Playlist), + // With the cadence present the playlist is self-sufficient, so no + // row read is needed. + stalkerWatchdogTimeout: 120, + } as Playlist); + + expect(playlistsService.getPlaylistById).not.toHaveBeenCalled(); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ + storedToken: 'STORED-TOKEN', + skipProfileWhenReused: true, + }) + ); + }); + + it('falls back to the stored playlist row for the persisted token', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-8', + stalkerToken: 'ROW-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint({ + portalUrl, + macAddress, + } as Playlist), + stalkerWatchdogTimeout: 120, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'ROW-TOKEN', reusedStoredToken: true }); + + await service.ensureToken({ + _id: 'playlist-8', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.getPlaylistById).toHaveBeenCalledWith( + 'playlist-8' + ); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: 'ROW-TOKEN' }) + ); + // A reused token was not renegotiated — nothing to write back. + expect(playlistsService.updateStalkerSession).not.toHaveBeenCalled(); + }); + + it('writes a newly negotiated token back to the playlist', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-9', stalkerToken: 'OLD-TOKEN' } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'NEW-TOKEN', + reusedStoredToken: false, + }); + + await service.ensureToken({ + _id: 'playlist-9', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-9', + expect.objectContaining({ stalkerToken: 'NEW-TOKEN' }) + ); + }); + + it('applies the persisted watchdog cadence when the token is reused', async () => { + // Reuse skips the get_profile that carries the cadence, so without + // the persisted values the watchdog would sit on its 120 s default + // for the whole session — for a portal that advertised 60 s that is + // slower than before this feature existed. + const watchdog = ( + service as unknown as { + watchdog: { applyProfileTiming: jest.Mock }; + } + ).watchdog; + jest.spyOn(watchdog, 'applyProfileTiming'); + + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-12', + stalkerToken: 'REUSED', + stalkerWatchdogTimeout: 60, + stalkerTimeslot: 7, + } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'REUSED', + reusedStoredToken: true, + }); + + await service.ensureToken({ + _id: 'playlist-12', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(watchdog.applyProfileTiming).toHaveBeenCalledWith( + 'playlist-12', + { watchdogTimeoutSeconds: 60, timeslotSeconds: 7 } + ); + }); + + it('profiles a legacy token-only playlist and refuses its unverified token', async () => { + // A playlist written before this change has a token but no recorded + // fingerprint, so nothing proves which endpoint/identity it belongs + // to — re-presenting it after an edit is the disclosure the + // fingerprint exists to prevent. It owes a full profile anyway (no + // cadence), so refusing the token costs nothing and the write-back + // then records the fingerprint. + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-16', stalkerToken: 'LEGACY' } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ + token: 'LEGACY', + reusedStoredToken: false, + watchdogTimeoutSeconds: 60, + timeslotSeconds: 5, + }); + + await service.ensureToken({ + _id: 'playlist-16', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ + storedToken: undefined, + skipProfileWhenReused: false, + }) + ); + // ...and the learned cadence is persisted, so the next start skips. + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-16', + expect.objectContaining({ + stalkerWatchdogTimeout: 60, + stalkerTimeslot: 5, + }) + ); + }); + + it('records the effective cadence when the portal advertises none', async () => { + // Otherwise "no cadence stored" would keep meaning "never profiled" + // and such a portal would be re-profiled on every single start. + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-17', stalkerToken: 'LEGACY' } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'LEGACY', + reusedStoredToken: false, + }); + + await service.ensureToken({ + _id: 'playlist-17', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-17', + expect.objectContaining({ + stalkerWatchdogTimeout: + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, + stalkerTimeslot: 0, + }) + ); + }); + + it('refuses a cached and persisted session after the login changed', async () => { + // For a status-2 portal the login decides WHICH account the token + // represents, so serving the old session would keep the user on the + // previous account indefinitely. + const before = { + _id: 'playlist-login-change', + portalUrl, + macAddress, + isFullStalkerPortal: true, + username: 'old-user', + password: 'old-pass', + } as Playlist; + service.setCachedToken(before._id, 'OLD-ACCOUNT-TOKEN', before); + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...before, + stalkerToken: 'OLD-ACCOUNT-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(before), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'NEW', reusedStoredToken: false }); + + const result = await service.ensureToken({ + ...before, + username: 'new-user', + password: 'new-pass', + } as Playlist); + + // Neither the in-run cache nor the persisted token is reused. + expect(result.token).toBe('NEW'); + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ + storedToken: undefined, + credentials: { username: 'new-user', password: 'new-pass' }, + }) + ); + }); + + it('refuses a persisted token across tenants on the SAME host', async () => { + // Discovery deliberately preserves tenant base paths, so two portals + // can share an origin. An origin-only key would send tenant A's + // bearer to tenant B. + const tenantA = { + _id: 'playlist-tenant', + portalUrl: 'https://panel.example.com/tenant-a/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...tenantA, + stalkerToken: 'TENANT-A-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(tenantA), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + ...tenantA, + portalUrl: 'https://panel.example.com/tenant-b/server/load.php', + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + 'https://panel.example.com/tenant-b/server/load.php', + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + + it('keeps the session for the same endpoint spelled with a trailing slash', async () => { + // Normalisation must not turn a cosmetic difference into a forced + // re-authentication. + const portal = { + _id: 'playlist-slash', + portalUrl: 'https://panel.example.com/tenant-a/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + expect( + stalkerSessionFingerprint({ + ...portal, + portalUrl: 'https://panel.example.com/tenant-a/server/load.php/', + } as Playlist) + ).toBe(stalkerSessionFingerprint(portal)); + }); + + it('refuses a persisted token when the playlist was repointed at another host', async () => { + // ensureToken re-presents persisted tokens in a handshake, so an + // identity-only check would disclose the previous portal's bearer + // token to an unrelated server. + const original = { + _id: 'playlist-moved', + portalUrl: 'https://old.example.com/stalker_portal/server/load.php', + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...original, + stalkerToken: 'OLD-HOST-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(original), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + ...original, + portalUrl: 'https://new.example.com/stalker_portal/server/load.php', + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + 'https://new.example.com/stalker_portal/server/load.php', + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + + it('refuses a persisted token minted for a different identity', async () => { + // The playlist was edited after the token was issued. Re-presenting + // it would pair the new identity with the old session — the same bug + // class the in-memory cache guards against, just across a restart. + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-identity', + stalkerToken: 'OLD-IDENTITY-TOKEN', + stalkerSessionIdentity: 'not-the-current-fingerprint', + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'FRESH', reusedStoredToken: false }); + + await service.ensureToken({ + _id: 'playlist-identity', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: undefined }) + ); + }); + + it('reuses a persisted token whose identity still matches', async () => { + const playlist = { + _id: 'playlist-identity-ok', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...playlist, + stalkerToken: 'SAME-IDENTITY-TOKEN', + stalkerSessionIdentity: stalkerSessionFingerprint(playlist), + stalkerWatchdogTimeout: 60, + } as Playlist) + ); + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ + token: 'SAME-IDENTITY-TOKEN', + reusedStoredToken: true, + }); + + await service.ensureToken(playlist); + + expect(authenticate).toHaveBeenCalledWith( + portalUrl, + macAddress, + {}, + expect.objectContaining({ storedToken: 'SAME-IDENTITY-TOKEN' }) + ); + }); + + it('reads the stored row when a token arrives without its cadence', async () => { + // A playlist shape that copies the token but not the timing fields + // must not silently downgrade the portal to the 120 s default. + const watchdog = ( + service as unknown as { + watchdog: { applyProfileTiming: jest.Mock }; + } + ).watchdog; + jest.spyOn(watchdog, 'applyProfileTiming'); + + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-15', + stalkerToken: 'REUSED', + stalkerWatchdogTimeout: 45, + stalkerTimeslot: 3, + } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'REUSED', + reusedStoredToken: true, + }); + + await service.ensureToken({ + _id: 'playlist-15', + portalUrl, + macAddress, + isFullStalkerPortal: true, + // Token present, cadence absent — the shortcut must not fire. + stalkerToken: 'REUSED', + } as Playlist); + + expect(playlistsService.getPlaylistById).toHaveBeenCalledWith( + 'playlist-15' + ); + expect(watchdog.applyProfileTiming).toHaveBeenCalledWith( + 'playlist-15', + { watchdogTimeoutSeconds: 45, timeslotSeconds: 3 } + ); + }); + + it('persists a freshly decoded watchdog cadence with the token', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ _id: 'playlist-13' } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'NEW-TOKEN', + reusedStoredToken: false, + watchdogTimeoutSeconds: 90, + timeslotSeconds: 12, + }); + + await service.ensureToken({ + _id: 'playlist-13', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-13', + expect.objectContaining({ + stalkerToken: 'NEW-TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 12, + // The identity the session was negotiated for, so a later + // start can refuse to reuse it after an identity edit. + stalkerSessionIdentity: expect.any(String), + }) + ); + }); + + it('rewrites the session when only the cadence changed', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'playlist-14', + stalkerToken: 'SAME', + stalkerWatchdogTimeout: 120, + } as Playlist) + ); + jest.spyOn(service, 'authenticate').mockResolvedValue({ + token: 'SAME', + reusedStoredToken: false, + watchdogTimeoutSeconds: 45, + }); + + await service.ensureToken({ + _id: 'playlist-14', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist); + + expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith( + 'playlist-14', + expect.objectContaining({ stalkerWatchdogTimeout: 45 }) + ); + }); + + it('surfaces the portal failure body when the retry also fails', async () => { + const playlist = { + _id: 'playlist-10', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + jest.spyOn(service, 'ensureToken').mockResolvedValue({ + token: 'token-1', + }); + // The transport marker for a blocked account, on both attempts. + dataService.sendIpcEvent.mockResolvedValue({ + stalkerAuthFailure: 'Access denied.', + }); + + await expect( + service.makeAuthenticatedRequest(playlist, { + type: 'vod', + action: 'get_categories', + }) + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'auth-failed', + failureBody: 'Access denied.', + }); + // One retry happened before giving up. + expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2); + }); + + it('recognizes the raw PWA plain-text failure body and retries', async () => { + const playlist = { + _id: 'playlist-11', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + jest.spyOn(service, 'ensureToken') + .mockResolvedValueOnce({ token: 'stale' }) + .mockResolvedValueOnce({ token: 'fresh' }); + dataService.sendIpcEvent + .mockResolvedValueOnce('Unauthorized request.') + .mockResolvedValueOnce({ js: { data: [] } }); + + await expect( + service.makeAuthenticatedRequest(playlist, { + type: 'itv', + action: 'get_ordered_list', + }) + ).resolves.toEqual({ js: { data: [] } }); + }); + + it('throws StalkerPortalError with the portal text when auth is refused', async () => { + // Blocked account: get_profile answers status 1 with msg/block_msg. + dataService.sendIpcEvent + .mockResolvedValueOnce({ + js: { token: 'token-1', random: 'random-1' }, + }) + .mockResolvedValueOnce({ + js: { + status: 1, + msg: 'device conflict - device_id mismatch', + block_msg: 'Your STB is damaged.
Call the provider.', + }, + }); + + await expect( + service.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + name: 'StalkerPortalError', + kind: 'blocked', + portalText: + 'device conflict - device_id mismatch — Your STB is damaged. Call the provider.', + }); + }); + + it('keeps StalkerPortalError recognizable via instanceof', () => { + expect(new StalkerPortalError('blocked')).toBeInstanceOf(Error); + }); + it('passes an explicit serial into the initial handshake request', async () => { dataService.sendIpcEvent .mockResolvedValueOnce({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 746e95a6c..f31a6c613 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -1,6 +1,6 @@ import { Injectable, Injector, inject } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; import { + extractStalkerAuthFailureBody, isFullStalkerPortalPlaylist, isFullStalkerPortalUrl, Playlist, @@ -9,10 +9,6 @@ import { } from '@iptvnator/shared/interfaces'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { - isStalkerAuthFailureMessage, - isStalkerAuthFailureResponse, -} from './stalker-portal-discovery.utils'; import { getStalkerPortalIdentityFromPlaylist, LEGACY_DEFAULT_STALKER_SERIAL, @@ -20,75 +16,39 @@ import { stalkerIdentityFingerprint, type StalkerPortalIdentity, } from './stalker-identity.utils'; +import { + StalkerAuthApi, + type StalkerAuthenticateOptions, + type StalkerAuthenticationResult, + type StalkerHandshakeResponse, + type StalkerPortalCredentials, + type StalkerProfileResponse, +} from './stalker-auth.api'; +import { isStalkerAuthorizationFailure } from './stalker-response-classification'; +import { StalkerPortalError } from './stalker-portal-error'; +import { + stalkerSessionFingerprint, + StalkerSessionStore, + type PersistedStalkerSession, +} from './stalker-session-store'; +import { StalkerTokenCache } from './stalker-token-cache'; +import { StalkerWatchdogController } from './stalker-watchdog.controller'; -export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity }; +export { + getStalkerPortalIdentityFromPlaylist, + normalizeStalkerPortalIdentity, + stalkerIdentityFingerprint, +}; export type { StalkerPortalIdentity }; - -/** - * SHA1 hash using native Web Crypto API - * Produces correct 40-character hex hash matching real Stalker clients - */ -async function sha1(str: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(str); - const hashBuffer = await crypto.subtle.digest('SHA-1', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); -} - -/** - * Generates SHA1 prehash from MAC address - * This must match what real Stalker clients send - */ -async function generatePrehash(macAddress: string): Promise { - // Use MAC address with colons, uppercase - this is what most clients use - const str = macAddress.toUpperCase(); - return (await sha1(str)).toUpperCase(); -} - -/** - * Generates a random string for metrics - */ -function generateRandom(): string { - const chars = 'abcdef0123456789'; - let result = ''; - for (let i = 0; i < 40; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} +export type { + StalkerAuthenticateOptions, + StalkerAuthenticationResult, + StalkerHandshakeResponse, + StalkerPortalCredentials, + StalkerProfileResponse, +}; export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL; -const STALKER_WATCHDOG_INTERVAL_MS = 25_000; - -export interface StalkerHandshakeResponse { - js: { - token: string; - not_valid?: number; - random?: string; - }; -} - -export interface StalkerProfileResponse { - js: { - id?: string; - name?: string; - mac?: string; - status?: number; - msg?: string; - block_msg?: string; - account_info?: { - login?: string; - expire_date?: number; - tariff_plan_name?: string; - status?: number; - }; - }; -} - -interface StalkerAuthConfirmationResponse { - js?: boolean; -} /** * Service to manage Stalker portal session tokens. @@ -101,40 +61,25 @@ interface StalkerAuthConfirmationResponse { export class StalkerSessionService { private dataService = inject(DataService); // Lazy: PlaylistsService drags the persistence stack and is only needed - // when a watchdog ping re-resolves its playlist from the stored row. + // when a session is resolved from (or written back to) the stored row. private readonly injector = inject(Injector); private readonly logger = createLogger('StalkerSession'); + private readonly authApi = new StalkerAuthApi(this.dataService, this.logger); + private readonly sessionStore = new StalkerSessionStore( + () => this.injector.get(PlaylistsService), + this.logger + ); + private readonly watchdog = new StalkerWatchdogController({ + sendRequest: (playlist, params) => + this.makeAuthenticatedRequest(playlist, params, false), + readPersistedPlaylist: (playlistId) => + this.sessionStore.readRow(playlistId), + logger: this.logger, + }); - // In-memory token cache for the current session, keyed by playlist ID - // and tagged with the identity fingerprint the session was negotiated - // for — an edited MAC/identity must never inherit the previous token. - private tokenCache = new Map< - string, - { token: string; identityFingerprint: string } - >(); - private watchdogPlaylistDecorator: - | ((playlist: Playlist) => Playlist) - | null = null; - - // Pending authentication promises to prevent race conditions. - // When multiple requests need a token simultaneously, they all wait for - // the same auth — but ONLY when they act as the same identity: a result - // negotiated for a pre-edit identity must not be adopted by requests - // carrying the edited one. - private pendingAuth = new Map< - string, - { - promise: Promise<{ token: string; serialNumber?: string }>; - identityFingerprint: string; - } - >(); - private watchdogIntervals = new Map< - string, - ReturnType - >(); - private watchdogPlaylists = new Map(); - private watchdogInFlight = new Set(); - private activeWatchdogPlaylistId: string | null = null; + // Session state for this run, identity-tagged so an edited playlist can + // inherit neither a cached token nor an in-flight authentication. + private readonly tokens = new StalkerTokenCache(); /** * Checks if a URL looks like a full stalker portal URL (requires @@ -149,10 +94,10 @@ export class StalkerSessionService { /** * Gets the cached token for a playlist, or null if not cached. * Identity validation happens in `ensureToken`; this raw accessor stays - * for playback fast paths that cannot supply an identity (PR 6 scope). + * for playback fast paths that cannot supply an identity. */ getCachedToken(playlistId: string): string | null { - return this.tokenCache.get(playlistId)?.token || null; + return this.tokens.get(playlistId); } /** @@ -164,60 +109,38 @@ export class StalkerSessionService { token: string, identitySource: PlaylistMeta ): void { - this.tokenCache.set(playlistId, { + this.tokens.set( + playlistId, token, - identityFingerprint: stalkerIdentityFingerprint(identitySource), - }); - } - - /** - * Lets the repair layer overlay its in-session override on the row a - * watchdog ping resolves: the persisted row can still carry a - * pre-repair configuration while persistence is pending (or failed), - * and pinging that configuration would stop or misdirect the keepalive. - */ - registerWatchdogPlaylistDecorator( - decorator: (playlist: Playlist) => Playlist - ): void { - this.watchdogPlaylistDecorator = decorator; + stalkerSessionFingerprint(identitySource) + ); } /** * Clears the cached token for a playlist (e.g., on auth failure) */ clearCachedToken(playlistId: string): void { - this.tokenCache.delete(playlistId); + this.tokens.clear(playlistId); + } + + /** + * Lets the repair layer overlay its in-session override on the row a + * watchdog ping resolves. + */ + registerWatchdogPlaylistDecorator( + decorator: (playlist: Playlist) => Playlist + ): void { + this.watchdog.registerPlaylistDecorator(decorator); } /** * Sets which playlist should receive periodic watchdog pings. * This keeps some Ministra/Stalker sessions alive for live playback. + * The cadence comes from the portal's profile (`watchdog_timeout` + + * `timeslot`), defaulting to the documented 120 s. */ setActiveWatchdogPlaylist(playlist?: Playlist | null): void { - const nextPlaylistId = playlist?._id ?? null; - - if ( - this.activeWatchdogPlaylistId && - this.activeWatchdogPlaylistId !== nextPlaylistId - ) { - this.stopWatchdog(this.activeWatchdogPlaylistId); - } - - this.activeWatchdogPlaylistId = nextPlaylistId; - - if ( - !playlist || - !isFullStalkerPortalPlaylist(playlist) || - !playlist.portalUrl || - !playlist.macAddress - ) { - if (nextPlaylistId) { - this.stopWatchdog(nextPlaylistId); - } - return; - } - - this.startWatchdog(playlist); + this.watchdog.setActivePlaylist(playlist); } /** @@ -225,357 +148,146 @@ export class StalkerSessionService { * was just repaired. Only reacts when the playlist IS the active * watchdog target: a simple→full repair starts the required keepalive, * full→simple stops it, and an endpoint change repoints the pings — - * without waiting for the next route activation (the activation-time - * snapshot in `watchdogPlaylists` would otherwise stay stale). + * without waiting for the next route activation. + * + * Delegation is the contract: `setActiveWatchdogPlaylist` owns the + * start/stop/repoint logic, this only feeds it the fresh row. */ refreshActiveWatchdogPlaylist(playlist: Playlist): void { - if (this.activeWatchdogPlaylistId !== playlist._id) { + if (!this.watchdog.isActivePlaylist(playlist._id)) { return; } this.setActiveWatchdogPlaylist(playlist); } - private startWatchdog(playlist: Playlist): void { - const playlistId = playlist._id; - this.watchdogPlaylists.set(playlistId, playlist); - - if (this.watchdogIntervals.has(playlistId)) { - return; - } - - void this.sendWatchdogPing(playlistId, '1'); - - const intervalId = setInterval(() => { - void this.sendWatchdogPing(playlistId, '0'); - }, STALKER_WATCHDOG_INTERVAL_MS); - - this.watchdogIntervals.set(playlistId, intervalId); - } - - private stopWatchdog(playlistId: string): void { - const intervalId = this.watchdogIntervals.get(playlistId); - if (intervalId) { - clearInterval(intervalId); - this.watchdogIntervals.delete(playlistId); - } - this.watchdogPlaylists.delete(playlistId); - this.watchdogInFlight.delete(playlistId); - } - /** - * The playlist a watchdog ping authenticates as. The persisted row is - * the source of truth: portal metadata (endpoint, mode, MAC, identity) - * edited or repaired mid-session must reach the keepalive within one - * ping cycle — the activation-time snapshot is only the fallback when - * the row cannot be read. + * Adopts a session another layer already negotiated — today the endpoint + * discovery run behind a lazy repair, whose classification handshake and + * `get_profile` produced both a token and the portal's cadence. + * + * Caching the token alone (as the repair used to) leaves the retry + * satisfied and no authentication path ever applies the profile outcome, + * so a freshly repaired playlist would keep pinging on the default + * cadence until the token failed or the app restarted. + * + * `identitySource` must describe the REPAIRED configuration: the session + * belongs to the endpoint it was negotiated against. */ - private async resolveWatchdogPlaylist( - playlistId: string - ): Promise { - try { - const row = await firstValueFrom( - this.injector - .get(PlaylistsService) - .getPlaylistById(playlistId) - ); - if (row) { - const playlist = row as Playlist; - // Keep the fallback snapshot fresh for the next cycle. - this.watchdogPlaylists.set(playlistId, playlist); - return this.decorateWatchdogPlaylist(playlist); - } - } catch { - // Store unavailable (e.g. isolated tests): keep the snapshot. - } - - const snapshot = this.watchdogPlaylists.get(playlistId); - return snapshot ? this.decorateWatchdogPlaylist(snapshot) : snapshot; - } - - /** - * Overlays the repair layer's in-session override (when registered) so - * a ping never authenticates against a configuration a completed repair - * has already proven broken — even before persistence lands. - */ - private decorateWatchdogPlaylist(playlist: Playlist): Playlist { - return this.watchdogPlaylistDecorator - ? this.watchdogPlaylistDecorator(playlist) - : playlist; - } - - private async sendWatchdogPing( + adoptDiscoveredSession( playlistId: string, - init: '0' | '1' - ): Promise { - if (this.watchdogInFlight.has(playlistId)) { - return; - } - - // Claimed BEFORE the row read: it awaits, and two overlapping pings - // passing the check together would double-fire the keepalive. - this.watchdogInFlight.add(playlistId); - try { - const playlist = await this.resolveWatchdogPlaylist(playlistId); - if ( - !playlist || - !playlist.portalUrl || - !playlist.macAddress || - !isFullStalkerPortalPlaylist(playlist) - ) { - this.stopWatchdog(playlistId); - return; - } - - await this.makeAuthenticatedRequest( - playlist, - { - type: 'watchdog', - action: 'get_events', - event_active_id: '0', - cur_play_type: '0', - init, - JsHttpRequest: '1-xml', - }, - false - ); - } catch (error) { - // Keep failures non-fatal; next interval can recover after token refresh. - this.logger.warn('Watchdog ping failed:', error); - } finally { - this.watchdogInFlight.delete(playlistId); + identitySource: Playlist, + session: { + token: string; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; } + ): void { + this.setCachedToken(playlistId, session.token, identitySource); + this.applySessionOutcome( + playlistId, + { + token: session.token, + reusedStoredToken: false, + watchdogTimeoutSeconds: session.watchdogTimeoutSeconds, + timeslotSeconds: session.timeslotSeconds, + }, + { + token: identitySource.stalkerToken, + identityFingerprint: identitySource.stalkerSessionIdentity, + watchdogTimeoutSeconds: identitySource.stalkerWatchdogTimeout, + timeslotSeconds: identitySource.stalkerTimeslot, + }, + stalkerSessionFingerprint(identitySource) + ); } /** - * Performs handshake to get a session token for a full stalker portal - * Returns both the token and the random value for use in subsequent requests + * Performs handshake to get a session token for a full stalker portal. + * An optional persisted token is re-presented: the handshake is + * idempotent, so a still-valid token comes back unchanged. */ - async performHandshake( + performHandshake( portalUrl: string, macAddress: string, - identity: StalkerPortalIdentity = {} - ): Promise<{ token: string; random: string }> { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - const prehash = await generatePrehash(macAddress); - - const params: Record = { - type: 'stb', - action: 'handshake', - token: '', - prehash, - JsHttpRequest: '1-xml', - }; - - try { - const response: StalkerHandshakeResponse = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - ...(normalizedIdentity.serialNumber - ? { serialNumber: normalizedIdentity.serialNumber } - : {}), - } - ); - - if (response?.js?.token) { - return { - token: response.js.token, - random: response.js.random || generateRandom(), - }; - } - - this.logger.error('No token in response'); - throw new Error('Handshake failed: No token received'); - } catch (error) { - this.logger.error('Handshake error:', error); - throw error; - } + identity: StalkerPortalIdentity = {}, + storedToken?: string + ): Promise<{ token: string; random: string; notValid: boolean }> { + return this.authApi.performHandshake( + portalUrl, + macAddress, + identity, + storedToken + ); } /** - * Gets account profile information to validate the portal and check subscription - * Based on working implementation from stalker-to-m3u repo + * Gets account profile information to validate the portal and check + * subscription. */ - async getProfile( + getProfile( portalUrl: string, macAddress: string, token: string, identity: StalkerPortalIdentity, - handshakeRandom: string + handshakeRandom: string, + options: { authSecondStep?: boolean; notValidToken?: boolean } = {} ): Promise { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - - // Build metrics JSON matching working app - const metrics: Record = { - mac: macAddress, - model: 'MAG250', - type: 'STB', - random: handshakeRandom, - ...(normalizedIdentity.serialNumber - ? { sn: normalizedIdentity.serialNumber } - : {}), - }; - - // Generate prehash for get_profile (same as handshake) - const prehash = await generatePrehash(macAddress); - - // Profile request matching working StalkerTV app - // auth_second_step=1, includes metrics, prehash, and device_id params - const params: Record = { - type: 'stb', - action: 'get_profile', - hd: '1', - not_valid_token: '0', - video_out: 'hdmi', - auth_second_step: '1', - num_banks: '2', - metrics: JSON.stringify(metrics), - ...(normalizedIdentity.serialNumber - ? { sn: normalizedIdentity.serialNumber } - : {}), - ...(normalizedIdentity.deviceId1 - ? { device_id: normalizedIdentity.deviceId1 } - : {}), - ...(normalizedIdentity.deviceId2 - ? { device_id2: normalizedIdentity.deviceId2 } - : {}), - ...(normalizedIdentity.signature1 - ? { signature: normalizedIdentity.signature1 } - : {}), - ...(normalizedIdentity.signature2 - ? { signature2: normalizedIdentity.signature2 } - : {}), - prehash: prehash, - stb_type: '', - JsHttpRequest: '1-xml', - }; - - try { - const response: StalkerProfileResponse = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - token, - ...(normalizedIdentity.serialNumber - ? { serialNumber: normalizedIdentity.serialNumber } - : {}), - } - ); - - return response; - } catch (error) { - this.logger.error('Get profile error:', error); - throw error; - } - } - - /** - * Performs do_auth to authenticate the session after handshake - */ - async doAuth( - portalUrl: string, - macAddress: string, - token: string - ): Promise { - const params: Record = { - type: 'stb', - action: 'do_auth', - login: '', - password: '', - JsHttpRequest: '1-xml', - }; - - try { - const response = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params, - token, - } - ); - - // do_auth returns { js: true } on success - if (response?.js === true) { - return true; - } - - return false; - } catch (error) { - this.logger.error('do_auth error:', error); - throw error; - } - } - - /** - * Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces) - * Returns the token and account info if successful - */ - async authenticate( - portalUrl: string, - macAddress: string, - identity: StalkerPortalIdentity = {} - ): Promise<{ - token: string; - accountInfo?: StalkerProfileResponse['js']['account_info']; - /** Raw envelope so callers can apply their own failure checks. */ - profileResponse?: StalkerProfileResponse; - }> { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - - // Step 1: Handshake to get token and random - const { token, random } = await this.performHandshake( + return this.authApi.getProfile( portalUrl, macAddress, - normalizedIdentity + token, + identity, + handshakeRandom, + options ); - - // Step 2: Get profile to activate token and get account info - // The random from handshake must be used in auth_second_step - try { - const profileResponse = await this.getProfile( - portalUrl, - macAddress, - token, - normalizedIdentity, - random - ); - - // Check for profile-level errors - if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) { - const errorMsg = - profileResponse.js.msg || - profileResponse.js.block_msg || - 'Unknown profile error'; - this.logger.error('Profile error:', errorMsg); - throw new Error(`Profile error: ${errorMsg}`); - } - - return { - token, - accountInfo: profileResponse?.js?.account_info, - profileResponse, - }; - } catch (error) { - // Profile fetch failed - this is a real error, propagate it - this.logger.error('Profile fetch failed:', error); - throw error; - } } /** - * Ensures a valid token exists for a playlist, performing full auth if needed - * IMPORTANT: Based on working traces, each session needs handshake + get_profile - * Returns the token to use for requests, and the serial number to store + * Performs do_auth — the login/password step behind get_profile status 2. + */ + doAuth( + portalUrl: string, + macAddress: string, + token: string, + credentials: StalkerPortalCredentials, + identity: StalkerPortalIdentity = {} + ): Promise { + return this.authApi.doAuth( + portalUrl, + macAddress, + token, + credentials, + identity + ); + } + + /** + * Performs the full authentication flow: handshake → get_profile, driving + * the status-2 `do_auth` login flow when the portal demands credentials. + * Throws `StalkerPortalError` with the portal's own `msg`/`block_msg` + * text when the portal refuses the session. + */ + authenticate( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + options: StalkerAuthenticateOptions = {} + ): Promise { + return this.authApi.authenticate( + portalUrl, + macAddress, + identity, + options + ); + } + + /** + * Ensures a valid token exists for a playlist, performing auth if needed. + * A previously persisted token is re-presented in the handshake first — + * while it is still the MAC's session token the portal returns it + * unchanged and the `get_profile` round trip is skipped entirely. + * Returns the token to use for requests, and the serial number to store. */ async ensureToken( playlist: Playlist @@ -586,33 +298,22 @@ export class StalkerSessionService { } const identity = getStalkerPortalIdentityFromPlaylist(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); - // Check in-memory cache first (valid for current session only). - const cached = this.tokenCache.get(playlist._id); - if (cached) { - if ( - cached.identityFingerprint === - stalkerIdentityFingerprint(playlist) - ) { - return { - token: cached.token, - serialNumber: identity.serialNumber, - }; - } - // The cached session was negotiated for a DIFFERENT identity - // (the playlist was edited): retire it and authenticate as the - // current one instead of pairing new identity with old session. - this.clearCachedToken(playlist._id); + // Only the session negotiated for THIS identity may be reused. + const cachedToken = this.tokens.takeFor(playlist._id, fingerprint); + if (cachedToken) { + return { token: cachedToken, serialNumber: identity.serialNumber }; } // Check if there's already a pending authentication for this playlist // This prevents race conditions when multiple resources request a token simultaneously - const pendingEntry = this.pendingAuth.get(playlist._id); + const pendingEntry = this.tokens.getPending(playlist._id); if (pendingEntry) { - if ( - pendingEntry.identityFingerprint === - stalkerIdentityFingerprint(playlist) - ) { + if (pendingEntry.identityFingerprint === fingerprint) { this.logger.debug('Waiting for pending authentication...'); return pendingEntry.promise; } @@ -629,8 +330,6 @@ export class StalkerSessionService { return this.ensureToken(playlist); } - // No cached token - need to do full authentication (handshake + get_profile) - // Don't trust stored tokens as they may be from a different session if (!playlist.portalUrl || !playlist.macAddress) { this.logger.error('Missing portal URL or MAC address'); throw new Error('Portal URL and MAC address are required'); @@ -642,23 +341,54 @@ export class StalkerSessionService { // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { try { - const { token } = await this.authenticate( + // The PERSISTED session is bound to the endpoint too: a + // playlist repointed at another host must not re-present the + // previous portal's token to it. + const stored = await this.sessionStore.read( + playlist, + fingerprint + ); + const result = await this.authenticate( portalUrl, macAddress, - identity + identity, + { + storedToken: stored.token, + credentials: { + username: playlist.username, + password: playlist.password, + }, + // Only skip the profile once the cadence is known. A + // playlist stored before the cadence was persisted + // has a reusable token and no cadence, and skipping + // would strand it on the 120 s default forever — the + // profile is the only thing that could teach it. One + // request, once; every later start skips. + skipProfileWhenReused: + stored.watchdogTimeoutSeconds !== undefined, + } ); - this.setCachedToken(playlist._id, token, playlist); - return { token, serialNumber: identity.serialNumber }; + this.setCachedToken(playlist._id, result.token, playlist); + this.applySessionOutcome( + playlist._id, + result, + stored, + fingerprint + ); + return { + token: result.token, + serialNumber: identity.serialNumber, + }; } finally { // Clean up pending promise regardless of success/failure - this.pendingAuth.delete(playlist._id); + this.tokens.clearPending(playlist._id); } })(); // Store the pending promise so other concurrent requests can wait on it - this.pendingAuth.set(playlist._id, { + this.tokens.setPending(playlist._id, { promise: authPromise, - identityFingerprint: stalkerIdentityFingerprint(playlist), + identityFingerprint: fingerprint, }); return authPromise; @@ -685,14 +415,18 @@ export class StalkerSessionService { const portalUrl = playlist.portalUrl; const macAddress = playlist.macAddress; const identity = getStalkerPortalIdentityFromPlaylist(playlist); + // One key for both caches: endpoint + identity + credentials. An + // identity-only in-run key would hand the cached bearer token to a + // freshly edited endpoint before the persisted check ever ran. + const fingerprint = stalkerSessionFingerprint(playlist); // Claim the per-playlist slot. Re-check after every await: one // settled promise releases every waiter at once, so a single // pre-check would let them all start competing handshakes. for ( - let inFlight = this.pendingAuth.get(playlist._id); + let inFlight = this.tokens.getPending(playlist._id); inFlight; - inFlight = this.pendingAuth.get(playlist._id) + inFlight = this.tokens.getPending(playlist._id) ) { this.logger.debug('Waiting for pending authentication...'); // A failed pending auth must not abort the refresh; this call @@ -718,11 +452,8 @@ export class StalkerSessionService { // Waiters attach their own handlers; this one only keeps a // rejected slot from surfacing as an unhandled rejection. void slot.catch(() => undefined); - const slotEntry = { - promise: slot, - identityFingerprint: stalkerIdentityFingerprint(playlist), - }; - this.pendingAuth.set(playlist._id, slotEntry); + const slotEntry = { promise: slot, identityFingerprint: fingerprint }; + this.tokens.setPending(playlist._id, slotEntry); // ensureToken() reads tokenCache before pendingAuth, so leaving the // old token there would hand a token this handshake is about to @@ -734,9 +465,29 @@ export class StalkerSessionService { const result = await this.authenticate( portalUrl, macAddress, - identity + identity, + { + credentials: { + username: playlist.username, + password: playlist.password, + }, + } ); this.setCachedToken(playlist._id, result.token, playlist); + // This path always ran a real get_profile, so the decoded cadence + // is authoritative; the previous values are only the write-back + // comparison baseline. + this.applySessionOutcome( + playlist._id, + result, + { + token: playlist.stalkerToken, + identityFingerprint: playlist.stalkerSessionIdentity, + watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout, + timeslotSeconds: playlist.stalkerTimeslot, + }, + fingerprint + ); settleSlot({ token: result.token, serialNumber: identity.serialNumber, @@ -748,70 +499,24 @@ export class StalkerSessionService { } finally { // Only retire our own entry: a caller that started a later // authentication owns the map slot from then on. - if (this.pendingAuth.get(playlist._id) === slotEntry) { - this.pendingAuth.delete(playlist._id); - } + this.tokens.clearPendingIf(playlist._id, slotEntry); } } - /** - * Clears the cached token only while it is still the one that just - * failed. A request dispatched with the previous token can see its - * authorization failure arrive after a profile refresh has already - * cached a fresh token — deleting blindly would kill that fresh token - * and trigger a cascade of competing handshakes on strict portals. - */ - private retireFailedToken( + /** Delegates the watchdog + write-back half of an authentication. */ + private applySessionOutcome( playlistId: string, - failedToken: string | null + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string ): void { - if (failedToken && this.getCachedToken(playlistId) === failedToken) { - this.clearCachedToken(playlistId); - } - } - - /** - * Checks if a response or error indicates an authorization failure - */ - private isAuthorizationError(responseOrError: unknown): boolean { - if (!responseOrError) return false; - - const response = responseOrError as Record; - - // The complete set of portal auth failures — the plain-text bodies - // (`Authorization failed.`, `Access denied.`, `Unauthorized - // request.`) and their JSON-envelope forms. Shared with endpoint - // discovery and the lazy repair so a phrase one layer classifies as - // an auth failure cannot be ignored by another: the session would - // otherwise keep an expired token and every later request fails. - if ( - isStalkerAuthFailureResponse(responseOrError) || - isStalkerAuthFailureMessage(response?.['message']) - ) { - return true; - } - - // HTTP auth codes surviving the IPC boundary only as message text - // (`HTTP Error 401: …`): the custom `status` property is stripped by - // ipcRenderer, so the numeric code must be read from the message. - if ( - typeof response?.['message'] === 'string' && - /HTTP Error 40[13]\b/.test(response['message']) - ) { - return true; - } - - // Everything above is structural. What used to follow was a - // `JSON.stringify(responseOrError)` sweep with an UNANCHORED - // `authorization failed` pattern — the same false positive the body - // detector was just anchored against, reachable through a different - // door: a short proxy/WAF page containing the phrase retired the - // token, retried, and threw `Authorization failed after retry`, - // whose own message then matched the repair trigger and re-probed a - // portal that never refused anything. The shared detector already - // covers every real shape, including the `js.error`/`js.msg` - // envelopes, so the sweep only added the false positive. - return response?.['status'] === 401 || response?.['status'] === 403; + this.sessionStore.applyAuthenticationOutcome( + playlistId, + result, + stored, + fingerprint, + this.watchdog + ); } /** @@ -839,12 +544,15 @@ export class StalkerSessionService { ); // Check for authorization failure in response - if (this.isAuthorizationError(response)) { + if (isStalkerAuthorizationFailure(response)) { // Retire the failed token even when not retrying (e.g. // watchdog pings): leaving it cached would hand a dead // session to the next caller. - this.retireFailedToken(playlist._id, token); - if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) { + this.tokens.retireFailed(playlist._id, token); + if ( + retryOnAuthFailure && + isFullStalkerPortalPlaylist(playlist) + ) { // Retry once with fresh authentication return this.makeAuthenticatedRequest( playlist, @@ -853,16 +561,22 @@ export class StalkerSessionService { ); } - throw new Error('Authorization failed after retry'); + const failureBody = + extractStalkerAuthFailureBody(response) ?? undefined; + throw new StalkerPortalError( + 'auth-failed', + failureBody, + failureBody + ); } return response; } catch (error) { // Check if error indicates auth failure - if (this.isAuthorizationError(error)) { + if (isStalkerAuthorizationFailure(error)) { // Same rule as above: a failed session is retired even on // the no-retry path. - this.retireFailedToken(playlist._id, token); + this.tokens.retireFailed(playlist._id, token); if ( retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts new file mode 100644 index 000000000..64015da2e --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts @@ -0,0 +1,91 @@ +export interface StalkerPendingAuth { + promise: Promise<{ token: string; serialNumber?: string }>; + identityFingerprint: string; +} + +/** + * In-memory session state for the current app run, keyed by playlist ID and + * tagged with the identity fingerprint the session was negotiated for. + * + * The tagging is the point: a playlist whose endpoint, identity or login was + * edited must never inherit the previous session — neither the cached token + * nor an authentication still in flight for the old one. The key comes from + * `stalkerSessionFingerprint`, so an edit applies without a restart. + */ +export class StalkerTokenCache { + private readonly tokens = new Map< + string, + { token: string; identityFingerprint: string } + >(); + private readonly pending = new Map(); + + /** + * The cached token regardless of identity. Playback fast paths that + * cannot supply an identity use this; `takeFor` is the checked accessor. + */ + get(playlistId: string): string | null { + return this.tokens.get(playlistId)?.token || null; + } + + set(playlistId: string, token: string, sessionFingerprint: string): void { + this.tokens.set(playlistId, { + token, + identityFingerprint: sessionFingerprint, + }); + } + + clear(playlistId: string): void { + this.tokens.delete(playlistId); + } + + /** + * The cached token when it was negotiated for this exact identity. A + * mismatch retires the entry and returns null, so the caller + * authenticates as the current identity instead of pairing a new one + * with an old session. + */ + takeFor(playlistId: string, fingerprint: string): string | null { + const cached = this.tokens.get(playlistId); + if (!cached) { + return null; + } + if (cached.identityFingerprint === fingerprint) { + return cached.token; + } + + this.clear(playlistId); + return null; + } + + /** + * Clears the cached token only while it is still the one that just + * failed. A request dispatched with the previous token can see its + * authorization failure arrive after a profile refresh has already + * cached a fresh token — deleting blindly would kill that fresh token + * and trigger a cascade of competing handshakes on strict portals. + */ + retireFailed(playlistId: string, failedToken: string | null): void { + if (failedToken && this.get(playlistId) === failedToken) { + this.clear(playlistId); + } + } + + getPending(playlistId: string): StalkerPendingAuth | undefined { + return this.pending.get(playlistId); + } + + setPending(playlistId: string, entry: StalkerPendingAuth): void { + this.pending.set(playlistId, entry); + } + + clearPending(playlistId: string): void { + this.pending.delete(playlistId); + } + + /** Retires the pending slot only when it is still the caller's own. */ + clearPendingIf(playlistId: string, entry: StalkerPendingAuth): void { + if (this.pending.get(playlistId) === entry) { + this.pending.delete(playlistId); + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts new file mode 100644 index 000000000..760dc488f --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts @@ -0,0 +1,168 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; +import type { createLogger } from '@iptvnator/portal/shared/util'; +import { StalkerWatchdogController } from './stalker-watchdog.controller'; + +describe('StalkerWatchdogController', () => { + const playlist = { + _id: 'playlist-1', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: true, + } as Playlist; + + let sendRequest: jest.Mock; + let readPersistedPlaylist: jest.Mock; + let controller: StalkerWatchdogController; + + const logger = { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + sendRequest = jest.fn().mockResolvedValue({ js: {} }); + // The row is the source of truth for each ping; specs that care + // about mid-session edits override this per test. + readPersistedPlaylist = jest.fn().mockResolvedValue(playlist); + controller = new StalkerWatchdogController({ + sendRequest, + readPersistedPlaylist, + logger, + }); + }); + + afterEach(() => { + controller.setActivePlaylist(null); + jest.useRealTimers(); + }); + + /** Lets the async sendPing settle between timer advances. */ + async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + } + + it('pings immediately with init=1, then every 120 s by default', async () => { + controller.setActivePlaylist(playlist); + await flush(); + + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sendRequest).toHaveBeenCalledWith( + playlist, + expect.objectContaining({ + action: 'get_events', + type: 'watchdog', + init: '1', + }) + ); + + // The legacy cadence was 25 s — nothing may fire that early. + jest.advanceTimersByTime(25_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(95_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(2); + expect(sendRequest.mock.calls[1][1]).toEqual( + expect.objectContaining({ init: '0' }) + ); + }); + + it('reschedules to the profile cadence with the timeslot offset', async () => { + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + controller.applyProfileTiming(playlist._id, { + watchdogTimeoutSeconds: 90, + timeslotSeconds: 10, + }); + + // Timeslot offset: nothing during the first 10 s... + jest.advanceTimersByTime(9_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + + // ...then the 90 s cadence starts. + jest.advanceTimersByTime(91_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(90_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(2); + }); + + it('keeps the stored cadence when the watchdog restarts', async () => { + controller.applyProfileTiming(playlist._id, { + watchdogTimeoutSeconds: 60, + timeslotSeconds: 0, + }); + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + jest.advanceTimersByTime(60_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('clamps a garbage watchdog_timeout instead of adopting it', async () => { + controller.applyProfileTiming(playlist._id, { + watchdogTimeoutSeconds: 1, + timeslotSeconds: 0, + }); + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + // Clamped to the 30 s floor — a 1 s cadence would hammer the portal. + jest.advanceTimersByTime(29_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('logs and continues when a ping fails — never retries or escalates', async () => { + sendRequest.mockRejectedValue(new Error('portal down')); + controller.setActivePlaylist(playlist); + await flush(); + + expect(logger.warn).toHaveBeenCalled(); + expect(sendRequest).toHaveBeenCalledTimes(1); + + // No immediate retry: the next attempt is the next scheduled tick. + jest.advanceTimersByTime(119_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(1_000); + await flush(); + expect(sendRequest).toHaveBeenCalledTimes(2); + }); + + it('stops pinging when the active playlist is cleared', async () => { + controller.setActivePlaylist(playlist); + await flush(); + sendRequest.mockClear(); + + controller.setActivePlaylist(null); + jest.advanceTimersByTime(600_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('never pings for a simple (non-full) portal', async () => { + controller.setActivePlaylist({ + ...playlist, + isFullStalkerPortal: false, + } as Playlist); + jest.advanceTimersByTime(600_000); + await flush(); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts new file mode 100644 index 000000000..3e94a8708 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts @@ -0,0 +1,294 @@ +import { + isFullStalkerPortalPlaylist, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import type { createLogger } from '@iptvnator/portal/shared/util'; + +/** + * The portal expects `get_events` every `watchdog_timeout` seconds — 120 by + * default — not every 25 s as the client historically pinged. + */ +export const STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS = 120; + +/** Guard rails for a garbage `watchdog_timeout` echoed by a broken panel. */ +const MIN_PERIOD_SECONDS = 30; +const MAX_PERIOD_SECONDS = 3600; + +export interface StalkerWatchdogTiming { + periodSeconds: number; + /** Per-user jitter: delays the first periodic ping so clients spread out. */ + timeslotSeconds: number; +} + +export interface StalkerWatchdogDeps { + /** Sends one authenticated `get_events` ping. */ + sendRequest: ( + playlist: Playlist, + params: Record + ) => Promise; + /** + * Reads the persisted row for a playlist. The row is the source of truth + * for the configuration a ping authenticates as; see `resolvePlaylist`. + */ + readPersistedPlaylist: (playlistId: string) => Promise; + logger: ReturnType; +} + +interface WatchdogTimers { + timeslotTimeout?: ReturnType; + interval?: ReturnType; +} + +/** + * Keeps the active full portal's session "online" by pinging `get_events`. + * + * The cadence comes from the profile (`watchdog_timeout` + `timeslot`); until + * a profile has been decoded the documented default of 120 s applies. A missed + * ping never invalidates authentication — it only affects the portal's + * "online" reporting — so failures are logged and never retried or escalated. + */ +export class StalkerWatchdogController { + private readonly timers = new Map(); + /** Activation-time snapshot; only a fallback when the row cannot be read. */ + private readonly playlists = new Map(); + private readonly inFlight = new Set(); + /** Survives stop/start: the cadence is a per-portal fact, not session state. */ + private readonly timings = new Map(); + private activePlaylistId: string | null = null; + private playlistDecorator: ((playlist: Playlist) => Playlist) | null = null; + + constructor(private readonly deps: StalkerWatchdogDeps) {} + + /** + * Sets which playlist should receive periodic watchdog pings. + * This keeps some Ministra/Stalker sessions alive for live playback. + */ + setActivePlaylist(playlist?: Playlist | null): void { + const nextPlaylistId = playlist?._id ?? null; + + if (this.activePlaylistId && this.activePlaylistId !== nextPlaylistId) { + this.stop(this.activePlaylistId); + } + + this.activePlaylistId = nextPlaylistId; + + if ( + !playlist || + !isFullStalkerPortalPlaylist(playlist) || + !playlist.portalUrl || + !playlist.macAddress + ) { + if (nextPlaylistId) { + this.stop(nextPlaylistId); + } + return; + } + + this.start(playlist); + } + + /** Whether this playlist currently owns the watchdog. */ + isActivePlaylist(playlistId: string): boolean { + return this.activePlaylistId === playlistId; + } + + /** + * Lets the repair layer overlay its in-session override on the row a ping + * resolves: the persisted row can still carry a pre-repair configuration + * while persistence is pending (or failed), and pinging that would stop + * or misdirect the keepalive. + */ + registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void { + this.playlistDecorator = decorator; + } + + /** + * Applies the cadence decoded from a `get_profile` response. When the + * playlist's watchdog is already running on a different cadence, its + * periodic schedule restarts (without re-sending the init ping). + */ + applyProfileTiming( + playlistId: string, + timing: { watchdogTimeoutSeconds?: number; timeslotSeconds?: number } + ): void { + const next = normalizeTiming(timing); + const current = this.timings.get(playlistId); + if ( + current && + current.periodSeconds === next.periodSeconds && + current.timeslotSeconds === next.timeslotSeconds + ) { + return; + } + + this.timings.set(playlistId, next); + + if (this.timers.has(playlistId)) { + this.clearTimers(playlistId); + this.timers.set(playlistId, {}); + this.schedule(playlistId); + } + } + + private start(playlist: Playlist): void { + const playlistId = playlist._id; + this.playlists.set(playlistId, playlist); + + if (this.timers.has(playlistId)) { + return; + } + + this.timers.set(playlistId, {}); + void this.sendPing(playlistId, '1'); + this.schedule(playlistId); + } + + private stop(playlistId: string): void { + this.clearTimers(playlistId); + this.timers.delete(playlistId); + this.playlists.delete(playlistId); + this.inFlight.delete(playlistId); + } + + private clearTimers(playlistId: string): void { + const timers = this.timers.get(playlistId); + if (!timers) { + return; + } + if (timers.timeslotTimeout) { + clearTimeout(timers.timeslotTimeout); + } + if (timers.interval) { + clearInterval(timers.interval); + } + } + + private timingFor(playlistId: string): StalkerWatchdogTiming { + return ( + this.timings.get(playlistId) ?? { + periodSeconds: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, + timeslotSeconds: 0, + } + ); + } + + private schedule(playlistId: string): void { + const timers = this.timers.get(playlistId); + if (!timers) { + return; + } + + const { periodSeconds, timeslotSeconds } = this.timingFor(playlistId); + const startInterval = () => { + const current = this.timers.get(playlistId); + if (!current) { + return; + } + current.timeslotTimeout = undefined; + current.interval = setInterval(() => { + void this.sendPing(playlistId, '0'); + }, periodSeconds * 1000); + }; + + if (timeslotSeconds > 0) { + timers.timeslotTimeout = setTimeout( + startInterval, + timeslotSeconds * 1000 + ); + } else { + startInterval(); + } + } + + /** + * The playlist a ping authenticates as. The persisted row is the source of + * truth: portal metadata (endpoint, mode, MAC, identity) edited or + * repaired mid-session must reach the keepalive within one cycle — the + * activation-time snapshot is only the fallback when the row cannot be + * read. + */ + private async resolvePlaylist( + playlistId: string + ): Promise { + try { + const row = await this.deps.readPersistedPlaylist(playlistId); + if (row) { + // Keep the fallback snapshot fresh for the next cycle. + this.playlists.set(playlistId, row); + return this.decorate(row); + } + } catch { + // Store unavailable (e.g. isolated tests): keep the snapshot. + } + + const snapshot = this.playlists.get(playlistId); + return snapshot ? this.decorate(snapshot) : snapshot; + } + + private decorate(playlist: Playlist): Playlist { + return this.playlistDecorator + ? this.playlistDecorator(playlist) + : playlist; + } + + private async sendPing(playlistId: string, init: '0' | '1'): Promise { + if (this.inFlight.has(playlistId)) { + return; + } + + // Claimed BEFORE the row read: it awaits, and two overlapping pings + // passing the check together would double-fire the keepalive. + this.inFlight.add(playlistId); + try { + const playlist = await this.resolvePlaylist(playlistId); + if ( + !playlist || + !playlist.portalUrl || + !playlist.macAddress || + !isFullStalkerPortalPlaylist(playlist) + ) { + this.stop(playlistId); + return; + } + + await this.deps.sendRequest(playlist, { + type: 'watchdog', + action: 'get_events', + event_active_id: '0', + cur_play_type: '0', + init, + JsHttpRequest: '1-xml', + }); + } catch (error) { + // Keep failures non-fatal; the next tick can recover after a + // token refresh. Never retry or escalate: a missed ping does not + // invalidate authentication. + this.deps.logger.warn('Watchdog ping failed:', error); + } finally { + this.inFlight.delete(playlistId); + } + } +} + +function normalizeTiming(timing: { + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; +}): StalkerWatchdogTiming { + const period = clamp( + Math.floor( + timing.watchdogTimeoutSeconds ?? + STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS + ), + MIN_PERIOD_SECONDS, + MAX_PERIOD_SECONDS + ); + const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1); + return { periodSeconds: period, timeslotSeconds: timeslot }; +} + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(value, min), max); +} diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 144d062a0..dbf621fa1 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -441,6 +441,115 @@ describe('PlaylistsService', () => { ).toBe('1'); }); + it('carries the Stalker session fields through the SQLite fallback read', async () => { + // Without these, every cold Electron session loses the persisted + // cadence AND the identity the token was negotiated for — so the + // mismatch check cannot run and the watchdog falls back to default. + const electron = { + dbGetAppPlaylist: jest.fn(async () => ({ + id: 'stalker-1', + stalkerToken: 'TOKEN', + stalkerSessionIdentity: 'fingerprint-1', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 7, + })), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppState: jest.fn(async () => '1'), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn(), + dbUpsertAppPlaylists: jest.fn(), + }; + testWindow.electron = electron; + + const service = createService(); + const playlist = await firstValueFrom( + service.getPlaylistById('stalker-1') + ); + + expect(playlist).toEqual( + expect.objectContaining({ + stalkerToken: 'TOKEN', + stalkerSessionIdentity: 'fingerprint-1', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 7, + }) + ); + }); + + it('clears a stale watchdog cadence when the portal stops advertising one', async () => { + // Reusing the token skips the profile that would correct the cadence, + // so leaving the old value on the row means the next restart applies + // a cadence the portal no longer asks for. + const existingPlaylist: Playlist = { + _id: 'stalker-1', + title: 'Stalker', + count: 0, + importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(), + autoRefresh: false, + stalkerToken: 'OLD', + stalkerWatchdogTimeout: 45, + stalkerTimeslot: 7, + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' }) + ); + + const written = dbService.update.mock.calls[0][1] as Playlist; + expect(written.stalkerToken).toBe('NEW'); + expect(written.stalkerWatchdogTimeout).toBeUndefined(); + expect(written.stalkerTimeslot).toBeUndefined(); + }); + + it('persists the watchdog cadence alongside the stalker token', async () => { + const existingPlaylist: Playlist = { + _id: 'stalker-1', + title: 'Stalker', + count: 0, + importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(), + autoRefresh: false, + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + testWindow.electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.updateStalkerSession('stalker-1', { + stalkerToken: 'TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 12, + }) + ); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + stalkerToken: 'TOKEN', + stalkerWatchdogTimeout: 90, + stalkerTimeslot: 12, + }) + ); + }); + it('persists hiddenGroupTitles in playlist meta updates', async () => { const existingPlaylist: Playlist = { _id: 'playlist-1', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 2b3031066..942f980c8 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -242,6 +242,13 @@ export class PlaylistsService { stalkerSignature2: playlist.stalkerSignature2, isFullStalkerPortal: playlist.isFullStalkerPortal, stalkerToken: playlist.stalkerToken, + // Without these the Electron cold read loses the persisted + // cadence AND the identity the token was negotiated for, so the + // mismatch check cannot run and the watchdog falls back to the + // default. + stalkerSessionIdentity: playlist.stalkerSessionIdentity, + stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout, + stalkerTimeslot: playlist.stalkerTimeslot, stalkerAccountInfo: playlist.stalkerAccountInfo, } as Playlist; } @@ -729,6 +736,45 @@ export class PlaylistsService { }); } + /** + * Persists a freshly negotiated Stalker session on the playlist so the + * next app start can re-present the token (the portal handshake is + * idempotent) and keep the portal's own watchdog cadence — reusing a + * token skips the `get_profile` that carries the cadence, so it has to + * survive with the token. No-ops when the playlist row does not exist + * yet: the import flow saves both with the playlist itself. + */ + updateStalkerSession( + playlistId: string, + session: { + stalkerToken: string; + stalkerSessionIdentity?: string; + stalkerWatchdogTimeout?: number; + stalkerTimeslot?: number; + } + ) { + return this.serializePlaylistWrite(playlistId, async () => { + const playlist = await firstValueFrom( + this.getPlaylistById(playlistId) + ); + if (!playlist) { + return null; + } + + // The cadence is written unconditionally, including as + // `undefined`: a portal that stops advertising one must not leave + // a stale value behind for the next restart to re-apply, since + // reusing the token skips the profile that would correct it. + return this.persistPlaylistMutation({ + ...playlist, + stalkerToken: session.stalkerToken, + stalkerSessionIdentity: session.stalkerSessionIdentity, + stalkerWatchdogTimeout: session.stalkerWatchdogTimeout, + stalkerTimeslot: session.stalkerTimeslot, + }); + }); + } + updateFavorites(id: string, favorites: string[]) { return this.serializePlaylistWrite(id, async () => { const playlist = await firstValueFrom(this.getPlaylistById(id)); diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 2721ce84e..f16e5a709 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -58,6 +58,23 @@ export interface Playlist { serverTimezone?: string; /** Session token for full stalker portal authentication - persisted for session */ stalkerToken?: string; + /** + * Identity fingerprint the persisted `stalkerToken` was negotiated for. + * Re-presenting a token minted for a DIFFERENT identity (the user edited + * the MAC, serial or device ids) would pair a new identity with an old + * session — the same class of bug the in-memory cache guards against, so + * reuse is refused unless this still matches the playlist. + */ + stalkerSessionIdentity?: string; + /** + * Watchdog cadence the portal advertised in `get_profile`, persisted + * alongside the token: reusing a stored token skips the profile request + * that carries these, so without persistence the keep-alive would fall + * back to the 120 s default forever. + */ + stalkerWatchdogTimeout?: number; + /** Per-user watchdog jitter (seconds) from `get_profile`. */ + stalkerTimeslot?: number; /** Serial number for stalker portal - generated once and stored for consistency */ stalkerSerialNumber?: string; /** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */ diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts index 4332d46e7..fd6940178 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts @@ -1,5 +1,7 @@ import { classifyStalkerAuthFailureBody, + createStalkerAuthFailureMarker, + extractStalkerAuthFailureBody, isStalkerAuthFailureBody, isStalkerAuthFailureMessage, isStalkerAuthFailureResponse, @@ -98,6 +100,58 @@ describe('isStalkerAuthFailureResponse', () => { }); }); +describe('transport marker', () => { + it('round-trips through create / detect / extract', () => { + const marker = createStalkerAuthFailureMarker('Access denied.'); + expect(isStalkerAuthFailureResponse(marker)).toBe(true); + expect(extractStalkerAuthFailureBody(marker)).toBe('Access denied.'); + }); + + it('is what keeps the repair trigger working once Electron classifies', () => { + // The main process replaces the plain-text body with this marker, so + // a detector that only knew the raw string would silently stop + // triggering portal repair on the desktop app. + expect( + isStalkerAuthFailureResponse({ + stalkerAuthFailure: 'Authorization failed.', + }) + ).toBe(true); + }); + + it('does not accept arbitrary objects as markers', () => { + expect(isStalkerAuthFailureResponse({ stalkerAuthFailure: 'nope' })).toBe( + false + ); + expect(extractStalkerAuthFailureBody({})).toBeNull(); + }); +}); + +describe('extractStalkerAuthFailureBody', () => { + it('extracts from the raw PWA payload string', () => { + expect(extractStalkerAuthFailureBody('Authorization failed. 12')).toBe( + 'Authorization failed.' + ); + }); + + it('extracts from a {js: body} envelope', () => { + expect( + extractStalkerAuthFailureBody({ js: 'Authorization failed. 75' }) + ).toBe('Authorization failed.'); + }); + + it('reports no canonical body for the structured panel wording', () => { + // Still an auth failure — just not one of the middleware's bodies. + const response = { js: { error: 'Invalid token' } }; + expect(extractStalkerAuthFailureBody(response)).toBeNull(); + expect(isStalkerAuthFailureResponse(response)).toBe(true); + }); + + it('returns null for ordinary responses', () => { + expect(extractStalkerAuthFailureBody({ js: { data: [] } })).toBeNull(); + expect(extractStalkerAuthFailureBody(undefined)).toBeNull(); + }); +}); + describe('isStalkerAuthFailureMessage', () => { it('accepts messages our own auth layer produces', () => { expect( diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts index eb2d44edc..39927e0db 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts @@ -128,17 +128,77 @@ export function isStalkerAuthFailureMessage(message: unknown): boolean { } /** - * Whether a portal response is an authorization failure in EITHER wire - * shape: the middleware's plain-text body, or the JSON envelope some panels - * answer instead. Classification and the lazy-repair trigger must use this, - * not the string-only primitive: a JSON-failing panel would otherwise be - * persisted as token-free and never repaired. + * Structured shape a transport returns in place of the raw plain-text body. + * + * Returned, never thrown: `ipcRenderer.invoke` strips custom properties from + * rejected values, so a classified body would not survive the trip to the + * renderer as an error. The Electron main process mints this because it is + * where the body arrives; the PWA proxy still delivers the raw string, and + * every consumer goes through the predicates below rather than caring which. + */ +export interface StalkerAuthFailureMarker { + stalkerAuthFailure: StalkerAuthFailureBody; +} + +export function createStalkerAuthFailureMarker( + body: StalkerAuthFailureBody +): StalkerAuthFailureMarker { + return { stalkerAuthFailure: body }; +} + +function readAuthFailureMarker( + value: unknown +): StalkerAuthFailureBody | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + return classifyStalkerAuthFailureBody( + (value as StalkerAuthFailureMarker).stalkerAuthFailure + ); +} + +/** + * Extracts the canonical failure body from any shape an auth failure reaches + * a consumer in: the transport marker (Electron), the raw plain-text payload + * (PWA proxy), or a `{js: ''}` envelope. Returns null for everything + * else, including the structured `{js: {error|msg}}` form — that one is a + * panel's own wording, not one of the middleware's three bodies, so it has + * no canonical body to report. Use `isStalkerAuthFailureResponse` when the + * question is merely "did authorization fail?". + */ +export function extractStalkerAuthFailureBody( + value: unknown +): StalkerAuthFailureBody | null { + return ( + readAuthFailureMarker(value) ?? + classifyStalkerAuthFailureBody(value) ?? + classifyStalkerAuthFailureBody( + typeof value === 'object' && value !== null && 'js' in value + ? (value as { js?: unknown }).js + : undefined + ) + ); +} + +/** + * Whether a portal response is an authorization failure in ANY wire shape: + * the middleware's plain-text body, the transport marker minted from it, or + * the JSON envelope some panels answer instead. Classification and the + * lazy-repair trigger must use this, not the string-only primitive: a + * JSON-failing panel would otherwise be persisted as token-free and never + * repaired, and an Electron-classified body would stop triggering repair at + * all. */ export function isStalkerAuthFailureResponse(response: unknown): boolean { if (isStalkerAuthFailureBody(response)) { return true; } + if (readAuthFailureMarker(response) !== null) { + return true; + } + if ( response === null || typeof response !== 'object' || diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index 39eefd3e7..f8fedcd72 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -157,9 +157,7 @@ } @else if (isStalkerCategoryFailed()) { } @else { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index fc2927f01..102c124d0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -17,7 +17,10 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatTooltip } from '@angular/material/tooltip'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { + StalkerStore, + asStalkerPortalError, +} from '@iptvnator/portal/stalker/data-access'; import { PortalCategorySortMode, persistPortalCategorySortMode, @@ -152,6 +155,28 @@ export class WorkspaceContextPanelComponent { this.stalkerStore.isCategoryResourceLoading; readonly isStalkerCategoryFailed = this.stalkerStore.isCategoryResourceFailed; + /** + * When category loading failed because the portal refused the session, + * the portal's own explanation (msg/block_msg or the documented + * plain-text failure body) replaces the generic hint; a login-required + * refusal gets its own actionable text. + */ + readonly stalkerCategoryErrorDescription = computed(() => { + const portalError = asStalkerPortalError( + this.isStalkerCategoryFailed() + ); + if (portalError?.portalText) { + return portalError.portalText; + } + if (portalError?.kind === 'login-required') { + return this.translate.instant( + 'PORTALS.ERROR_VIEW.STALKER_LOGIN_REQUIRED' + ); + } + return this.translate.instant( + 'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' + ); + }); // Category count badges are only available for Stalker Live TV, where the // full channel list is cached — VOD/series/radio still page lazily, so // their per-category totals are unknown. diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index bbdd99343..752a99ecd 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -38,7 +38,6 @@ export const maxLinesBaseline = [ 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts', 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts', 'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts', - 'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts', 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',