mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(stalker): verify repair override retirement
This commit is contained in:
1 parent
41559b8890
commit
c34bc8739d
4 files changed
+71
-14
No files matched your search
@@ -1264,7 +1264,7 @@ engine` (restart required) or
|
||||
- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one.
|
||||
- Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `<base>/portal.php` → `<base>/server/load.php` → `<base>/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `<base>/portal.php`, while canonical-shaped unreachable addresses still abort. If bounded discovery returns while abandoned authentication remains on the wire, the refusal is shown immediately but Add and every form field stay disabled until its settlement promise resolves.
|
||||
- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. It preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; ordinary failure releases it without changing the saved or runtime connection. If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that result carries its settlement promise and both reservations remain installed until it resolves, so catalog, watchdog, repair, or retry authentication cannot race a late `get_profile`. If navigation or another owner closes/destroys the dialog while discovery is in flight, the UI discards a later successful result through `discardResolvedConnection()` and releases both reservations without persisting it. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard before dispatch and after transport, so a same-endpoint mode change rejects stale snapshots and completed responses in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
|
||||
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. Before an unrecorded repair calls discovery, it verifies that the persisted row still owns the failing source, so a late pre-Edit request cannot authenticate against the old portal after Edit commits and invalidate the newly saved token. Its in-session override is bound to source endpoint, mode, device identity, and credentials; an Edit or backup restore with the same playlist ID but different connection metadata retires the override and token. Each repair installs a session-level authentication fence synchronously, drains the existing token slot before probing, and keeps request routing ahead of effective-connection selection until repair finishes; an abandoned transport keeps both the repair and session fences until it actually settles. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
|
||||
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. Before an unrecorded repair calls discovery, it verifies that the persisted row still owns the failing source, so a late pre-Edit request cannot authenticate against the old portal after Edit commits and invalidate the newly saved token. Its in-session override is bound to source endpoint, mode, device identity, and credentials; an Edit or backup restore with the same playlist ID but different connection metadata retires the override and token only after the persisted row confirms ownership, so a delayed stale request cannot remove valid runtime state. Each repair installs a session-level authentication fence synchronously, drains the existing token slot before probing, and keeps request routing ahead of effective-connection selection until repair finishes; an abandoned transport keeps both the repair and session fences until it actually settles. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
|
||||
- Explicit Edit advances the repair generation before installing its resolved session. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit.
|
||||
- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them.
|
||||
- Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream.
|
||||
|
||||
@@ -272,7 +272,9 @@ failing one. A repaired configuration is applied immediately via an
|
||||
in-session override inside `executeStalkerRequest()` (stale store snapshots
|
||||
keep working). The override is bound to that source's endpoint, mode, device
|
||||
identity, and credentials; an Edit or backup restore under the same playlist
|
||||
ID retires it when any connection field differs. The repair is persisted
|
||||
ID retires it when any connection field differs, but only after the persisted
|
||||
row confirms ownership — a delayed stale request cannot globally remove the
|
||||
current override or token. The repair is persisted
|
||||
through `PlaylistsService.transformPlaylistMeta`
|
||||
— the verification and the patch run in ONE slot of the per-playlist write
|
||||
queue, so a user edit that is queued but not yet committed wins over the
|
||||
|
||||
@@ -24,6 +24,12 @@ const MISCLASSIFIED = {
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('StalkerPortalRepairService', () => {
|
||||
let service: StalkerPortalRepairService;
|
||||
let discover: jest.Mock;
|
||||
@@ -518,7 +524,9 @@ describe('StalkerPortalRepairService', () => {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://other.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
persistedRow = edited as Playlist;
|
||||
expect(service.applyOverride(edited)).toBe(edited);
|
||||
await flushMicrotasks();
|
||||
|
||||
// …and the once-per-session latch re-arms so the EDITED
|
||||
// configuration may probe if it fails too.
|
||||
@@ -527,7 +535,6 @@ describe('StalkerPortalRepairService', () => {
|
||||
portalUrl: 'http://other.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
persistedRow = edited as Playlist;
|
||||
const repairedAgain = await service.repairPortal(edited);
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
expect(repairedAgain?.portalUrl).toBe(
|
||||
@@ -693,11 +700,13 @@ describe('StalkerPortalRepairService', () => {
|
||||
stalkerSerialNumber: 'a',
|
||||
stalkerDeviceId1: 'b',
|
||||
} as PlaylistMeta;
|
||||
persistedRow = shiftedIdentity as Playlist;
|
||||
|
||||
// A DIFFERENT identity must invalidate, not inherit.
|
||||
expect(service.applyOverride(shiftedIdentity)).toBe(
|
||||
shiftedIdentity
|
||||
);
|
||||
await flushMicrotasks();
|
||||
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
|
||||
});
|
||||
|
||||
@@ -718,10 +727,13 @@ describe('StalkerPortalRepairService', () => {
|
||||
macAddress: '00:1A:79:00:44:44',
|
||||
} as PlaylistMeta;
|
||||
// The edit drops the active override…
|
||||
persistedRow = editedIdentity as Playlist;
|
||||
expect(service.applyOverride(editedIdentity)).toBe(editedIdentity);
|
||||
await flushMicrotasks();
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
|
||||
|
||||
// …and the restored configuration reinstalls it on failure.
|
||||
persistedRow = MISCLASSIFIED as Playlist;
|
||||
refreshActiveWatchdogPlaylist.mockClear();
|
||||
const restored = await service.repairPortal(MISCLASSIFIED);
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
@@ -752,8 +764,9 @@ describe('StalkerPortalRepairService', () => {
|
||||
portalUrl: 'http://c.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
// The edit drops the active override…
|
||||
expect(service.applyOverride(otherConfig)).toBe(otherConfig);
|
||||
persistedRow = otherConfig as Playlist;
|
||||
expect(service.applyOverride(otherConfig)).toBe(otherConfig);
|
||||
await flushMicrotasks();
|
||||
refreshActiveWatchdogPlaylist.mockClear();
|
||||
|
||||
// …and a stale A request does not bring it back.
|
||||
@@ -781,12 +794,13 @@ describe('StalkerPortalRepairService', () => {
|
||||
macAddress: '00:1A:79:00:88:88',
|
||||
} as PlaylistMeta;
|
||||
|
||||
persistedRow = editedIdentity as Playlist;
|
||||
expect(service.applyOverride(editedIdentity)).toBe(editedIdentity);
|
||||
await flushMicrotasks();
|
||||
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
|
||||
// The latch is re-armed for the edited identity.
|
||||
discover.mockClear();
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
persistedRow = editedIdentity as Playlist;
|
||||
await service.repairPortal(editedIdentity);
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -814,6 +828,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
persistedRow = restored as Playlist;
|
||||
|
||||
expect(service.applyOverride(restored)).toBe(restored);
|
||||
await flushMicrotasks();
|
||||
expect(clearCachedToken).toHaveBeenCalledWith(original._id);
|
||||
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
@@ -821,6 +836,36 @@ describe('StalkerPortalRepairService', () => {
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps a valid override when only a delayed snapshot has stale credentials', async () => {
|
||||
const current = {
|
||||
...MISCLASSIFIED,
|
||||
username: 'current-user',
|
||||
password: 'current-password',
|
||||
} as PlaylistMeta;
|
||||
persistedRow = current as Playlist;
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: current.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
await service.repairPortal(current);
|
||||
persistedRow = writtenRow as Playlist;
|
||||
clearCachedToken.mockClear();
|
||||
|
||||
const delayed = {
|
||||
...current,
|
||||
username: 'stale-user',
|
||||
password: 'stale-password',
|
||||
} as PlaylistMeta;
|
||||
expect(service.applyOverride(delayed)).toBe(delayed);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(clearCachedToken).not.toHaveBeenCalled();
|
||||
expect(service.applyOverride(current)).toMatchObject({
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('re-probes a DISCARDED configuration once the row is restored to it', async () => {
|
||||
// A's probe was discarded by the persisted-row preflight because
|
||||
// the row had moved to B; after the user restores the row to A,
|
||||
|
||||
@@ -149,11 +149,10 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
stalkerCredentialsFingerprint(playlist) !==
|
||||
override.credentialsFingerprint
|
||||
) {
|
||||
// The MAC, Stalker identity, or login was replaced after repair.
|
||||
// The override AND its cached token belong to the previous
|
||||
// account and must not be applied to a restored row sharing only
|
||||
// the playlist ID, endpoint, and device identity.
|
||||
this.dropOverride(playlist._id);
|
||||
// Do not let one delayed request globally retire current state.
|
||||
// The snapshot itself stays untouched; persistence confirms in
|
||||
// the background whether it really owns this playlist ID.
|
||||
this.retireOverrideIfPersisted(playlist, override);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
@@ -177,13 +176,24 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
};
|
||||
}
|
||||
|
||||
// The portal URL or mode was edited to something else entirely —
|
||||
// same story: the edited configuration is used verbatim and the
|
||||
// repair session state is retired.
|
||||
this.dropOverride(playlist._id);
|
||||
// Another endpoint/mode may likewise be a delayed request rather
|
||||
// than the current row. Use it verbatim, but mutate shared repair
|
||||
// state only after persistence confirms it.
|
||||
this.retireOverrideIfPersisted(playlist, override);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
private retireOverrideIfPersisted(
|
||||
playlist: PlaylistMeta,
|
||||
override: StalkerPortalModeOverride
|
||||
): void {
|
||||
void this.rowCurrentlyMatches(playlist).then((matches) => {
|
||||
if (matches && this.overrides.get(playlist._id) === override) {
|
||||
this.dropOverride(playlist._id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates every repair that started before an explicit Edit, without
|
||||
* changing the working override or token yet. Persistence may still fail;
|
||||
|
||||
Reference in new issue
Block a user