fix(stalker): guard every resolved edit write

This commit is contained in:
4gray committed 2026-08-09 18:40:29 +02:00
1 parent fabffcc902
commit 9d2cacff1e
7 files changed
+172 -46

No files matched your search

+1 -1
View File
@@ -1261,7 +1261,7 @@ engine` (restart required) or
**Stalker Portal Mode and Endpoint Discovery**:
- A late post-navigation Edit is a compare-and-merge: its queued transform must still see the source connection authority captured when Edit began. Delete/restore or replacement under the same playlist ID aborts the late merge, while concurrent title/EPG metadata remains mergeable.
- Every resolved Edit commit is guarded inside the per-playlist write queue by the source connection authority captured when Edit began. Delete/restore or replacement under the same playlist ID aborts both ordinary and post-navigation writes; the latter still merge concurrent title/EPG metadata when authority matches.
- 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. A metadata-only Save omits connection/mode fields from its queued update, so the stored connection stays byte-identical even if the dialog hydrated before a concurrent discovery committed; it skips discovery. A persisted `portalUrl` keeps the row on the Stalker save path even if legacy Xtream fields remain. 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`. Once Save starts, navigation or dialog destruction does not discard a later successful result: `get_profile` may already have pinned the submitted serial/device identity remotely and cannot be recalled. That late commit uses `transformPlaylistMeta()` inside the per-playlist write queue to merge only connection/session fields into the current row, so newer title/EPG/metadata edits win; its returned row feeds the state-only update together with discovery's transient session patch, so NgRx replaces or clears its session fields while success UI is suppressed. 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.
+6 -5
View File
@@ -214,11 +214,12 @@ late commit uses `transformPlaylistMeta()` inside the per-playlist write queue
to merge only the resolved connection/session fields into the current row, so
a newer title, EPG, or other metadata edit wins. The returned merged row feeds
the state-only update, while dialog close and success UI are suppressed after
destruction. The transform also requires the current row to retain the source
connection authority captured when Edit began; delete/restore or replacement
under the same ID therefore aborts instead of receiving a late portal/session
merge. A row identified by its persisted `portalUrl` stays on the Stalker
save path even if legacy Xtream fields remain, so an unrelated Xtream write
destruction. Both the ordinary resolved metadata update and this late transform
require the queued current row to retain the source connection authority
captured when Edit began; delete/restore or replacement under the same ID
therefore aborts instead of receiving a portal/session write. A row identified
by its persisted `portalUrl` stays on the Stalker save path even if legacy
Xtream fields remain, so an unrelated Xtream write
cannot strand the Edit reservation. Before discovery starts, Edit reserves the
playlist ID; an
overlapping Edit cannot replace that owner. The reservation blocks every new
@@ -22,13 +22,15 @@ describe('Stalker edited-session coordination', () => {
) => void = () => undefined;
let service: StalkerSessionService;
let getPlaylistById: jest.Mock;
let updatePlaylistMeta: jest.Mock;
let updatePlaylistMetaIfCurrent: jest.Mock;
let transformPlaylistMeta: jest.Mock;
let updateStalkerSession: jest.Mock;
beforeEach(() => {
getPlaylistById = jest.fn(() => of(oldPlaylist));
updatePlaylistMeta = jest.fn(() => of(oldPlaylist));
updatePlaylistMetaIfCurrent = jest.fn((_playlist, isCurrent) =>
of(isCurrent(oldPlaylist) ? oldPlaylist : null)
);
transformPlaylistMeta = jest.fn((_id, transform) =>
of(transform(oldPlaylist))
);
@@ -45,7 +47,7 @@ describe('Stalker edited-session coordination', () => {
provide: PlaylistsService,
useValue: {
getPlaylistById,
updatePlaylistMeta,
updatePlaylistMetaIfCurrent,
transformPlaylistMeta,
updateStalkerSession,
},
@@ -73,20 +75,25 @@ describe('Stalker edited-session coordination', () => {
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
};
const replacement = service.replaceSessionAfterEdit(editedPlaylist);
const fencePromise = service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
resolveOldAuthentication({ token: 'OLD_TOKEN' });
await expect(oldAuthentication).rejects.toThrow(/stale/i);
await replacement;
const fence = await fencePromise;
await service.replaceSessionAfterEdit(editedPlaylist, fence);
expect(service.getCachedToken(oldPlaylist._id)).toBe('NEW_TOKEN');
expect(updatePlaylistMeta).toHaveBeenLastCalledWith(
expect(updatePlaylistMetaIfCurrent).toHaveBeenLastCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com/server/load.php',
stalkerSessionPatch: expect.objectContaining({
stalkerToken: 'NEW_TOKEN',
}),
})
}),
expect.any(Function)
);
expect(updateStalkerSession).not.toHaveBeenCalled();
});
@@ -211,18 +218,23 @@ describe('Stalker edited-session coordination', () => {
stalkerWatchdogTimeout: undefined,
stalkerTimeslot: undefined,
};
const replacement = service.replaceSessionAfterEdit(editedPlaylist);
const fencePromise = service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
resolveOldAuthentication({ token: 'OLD_TOKEN' });
await expect(oldAuthentication).rejects.toThrow(/stale/i);
await replacement;
const fence = await fencePromise;
await service.replaceSessionAfterEdit(editedPlaylist, fence);
expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
expect(updatePlaylistMeta).toHaveBeenLastCalledWith(
expect(updatePlaylistMetaIfCurrent).toHaveBeenLastCalledWith(
expect.objectContaining({
_id: oldPlaylist._id,
stalkerSessionPatch: null,
})
}),
expect.any(Function)
);
});
@@ -233,13 +245,17 @@ describe('Stalker edited-session coordination', () => {
stalkerToken: undefined,
stalkerSessionIdentity: undefined,
} as Playlist;
updatePlaylistMeta.mockReturnValueOnce(of(simplePlaylist));
updatePlaylistMetaIfCurrent.mockReturnValueOnce(of(simplePlaylist));
getPlaylistById.mockReturnValueOnce(of(simplePlaylist));
authenticate.mockReset().mockResolvedValue({
token: 'STALE_FULL_TOKEN',
});
await service.replaceSessionAfterEdit(simplePlaylist);
const fence = await service.beginEditDiscovery(
simplePlaylist,
oldPlaylist
);
await service.replaceSessionAfterEdit(simplePlaylist, fence);
await expect(service.ensureToken(oldPlaylist)).rejects.toThrow(
/stale/i
@@ -256,7 +272,9 @@ describe('Stalker edited-session coordination', () => {
...oldPlaylist,
stalkerToken: 'NEW_FULL_TOKEN',
} as Playlist;
updatePlaylistMeta.mockReturnValueOnce(of(resolvedFullPlaylist));
updatePlaylistMetaIfCurrent.mockReturnValueOnce(
of(resolvedFullPlaylist)
);
getPlaylistById.mockReturnValueOnce(of(resolvedFullPlaylist));
const fence = await service.beginEditDiscovery(simplePlaylist);
@@ -287,8 +305,12 @@ describe('Stalker edited-session coordination', () => {
portalUrl: 'https://new.example.com/server/load.php',
stalkerToken: 'NEW_TOKEN',
} as Playlist;
updatePlaylistMeta.mockReturnValueOnce(of(editedPlaylist));
await service.replaceSessionAfterEdit(editedPlaylist);
updatePlaylistMetaIfCurrent.mockReturnValueOnce(of(editedPlaylist));
const fence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
await service.replaceSessionAfterEdit(editedPlaylist, fence);
resolveOldResponse({ js: { data: ['STALE_CATEGORY'] } });
await expect(oldRequest).rejects.toThrow(/stale/i);
@@ -305,9 +327,13 @@ describe('Stalker edited-session coordination', () => {
stalkerTimeslot: 5,
};
await service.replaceSessionAfterEdit(editedPlaylist);
const fence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
await service.replaceSessionAfterEdit(editedPlaylist, fence);
expect(updatePlaylistMeta).toHaveBeenCalledWith(
expect(updatePlaylistMetaIfCurrent).toHaveBeenCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com/server/load.php',
username: 'subscriber',
@@ -318,7 +344,8 @@ describe('Stalker edited-session coordination', () => {
stalkerTimeslot: 5,
stalkerAccountInfo: undefined,
},
})
}),
expect.any(Function)
);
expect(updateStalkerSession).not.toHaveBeenCalled();
});
@@ -352,7 +379,7 @@ describe('Stalker edited-session coordination', () => {
{ preserveCurrentMetadata: true }
);
expect(updatePlaylistMeta).not.toHaveBeenCalled();
expect(updatePlaylistMetaIfCurrent).not.toHaveBeenCalled();
expect(transformPlaylistMeta).toHaveBeenCalledWith(
oldPlaylist._id,
expect.any(Function)
@@ -396,12 +423,40 @@ describe('Stalker edited-session coordination', () => {
).rejects.toThrow(/could not be persisted/i);
expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
expect(updatePlaylistMeta).not.toHaveBeenCalled();
expect(updatePlaylistMetaIfCurrent).not.toHaveBeenCalled();
});
it('rejects an ordinary resolved write after another connection replaces the playlist ID', async () => {
const replacementPlaylist = {
...oldPlaylist,
portalUrl: 'https://restored.example.com/portal.php',
macAddress: '00:1A:79:11:22:33',
} as Playlist;
updatePlaylistMetaIfCurrent.mockImplementationOnce(
(_playlist, isCurrent) =>
of(isCurrent(replacementPlaylist) ? replacementPlaylist : null)
);
const editedPlaylist = {
...oldPlaylist,
portalUrl: 'https://new.example.com/server/load.php',
stalkerToken: 'NEW_TOKEN',
} as Playlist;
const fence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
await expect(
service.replaceSessionAfterEdit(editedPlaylist, fence)
).rejects.toThrow(/could not be persisted/i);
expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
expect(transformPlaylistMeta).not.toHaveBeenCalled();
});
it('does not adopt a resolved full session when its atomic write fails', async () => {
service.adoptDiscoveredSimplePortal(oldPlaylist);
updatePlaylistMeta.mockReturnValueOnce(
updatePlaylistMetaIfCurrent.mockReturnValueOnce(
throwError(() => new Error('write failed'))
);
const editedPlaylist = {
@@ -410,9 +465,13 @@ describe('Stalker edited-session coordination', () => {
stalkerToken: 'NEW_TOKEN',
stalkerSessionIdentity: 'new-fingerprint',
};
const fence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
await expect(
service.replaceSessionAfterEdit(editedPlaylist)
service.replaceSessionAfterEdit(editedPlaylist, fence)
).rejects.toThrow('write failed');
expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
@@ -432,7 +491,11 @@ describe('Stalker edited-session coordination', () => {
portalUrl: 'https://new.example.com/server/load.php',
stalkerToken: 'NEW_TOKEN',
};
await service.replaceSessionAfterEdit(editedPlaylist);
const fence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
await service.replaceSessionAfterEdit(editedPlaylist, fence);
// Simulate delete + backup restore of the original row and ID.
service.setCachedToken(oldPlaylist._id, 'RESTORED_TOKEN', oldPlaylist);
@@ -449,7 +512,11 @@ describe('Stalker edited-session coordination', () => {
portalUrl: 'https://new.example.com/server/load.php',
stalkerToken: 'NEW_TOKEN',
};
await service.replaceSessionAfterEdit(editedPlaylist);
const committedFence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
await service.replaceSessionAfterEdit(editedPlaylist, committedFence);
const persistedRow = new Subject<Playlist>();
getPlaylistById.mockReturnValueOnce(persistedRow);
@@ -131,7 +131,7 @@ export class StalkerEditedSessionCoordinator {
replace(
playlist: Playlist,
fence?: StalkerEditFence,
fence: StalkerEditFence,
options: { preserveCurrentMetadata?: boolean } = {}
): Promise<Playlist> {
const playlistId = playlist._id;
@@ -140,20 +140,15 @@ export class StalkerEditedSessionCoordinator {
playlist,
sessionFingerprint
);
const owner = fence?.owner ?? Symbol('stalker-edit');
const owner = fence.owner;
const pending = this.pendingEdits.get(playlistId);
if (
fence &&
(fence.playlistId !== playlistId ||
this.pendingEdits.get(playlistId)?.owner !== fence.owner)
) {
if (fence.playlistId !== playlistId || pending?.owner !== fence.owner) {
return Promise.reject(
new Error('Stale Stalker playlist configuration')
);
}
const sourceConfigurationFingerprint =
pending?.sourceConfigurationFingerprint ??
stalkerConfigurationFingerprint(playlist);
pending.sourceConfigurationFingerprint;
// Keep the same owner while discovery replaces its input-shaped
// fingerprint with the resolved endpoint/mode fingerprint.
this.pendingEdits.set(playlistId, {
@@ -240,10 +235,15 @@ export class StalkerEditedSessionCoordinator {
)
: null
)
: playlists.updatePlaylistMeta({
...playlist,
stalkerSessionPatch: sessionPatch,
} as PlaylistMetaUpdate)
: playlists.updatePlaylistMetaIfCurrent(
{
...playlist,
stalkerSessionPatch: sessionPatch,
} as PlaylistMetaUpdate,
(current) =>
stalkerConfigurationFingerprint(current) ===
sourceConfigurationFingerprint
)
);
if (!persistedPlaylist) {
throw new Error('Resolved Stalker playlist could not be persisted');
@@ -289,7 +289,7 @@ export class StalkerSessionService {
*/
replaceSessionAfterEdit(
playlist: Playlist,
fence?: StalkerEditFence,
fence: StalkerEditFence,
options: { preserveCurrentMetadata?: boolean } = {}
): Promise<Playlist> {
return this.editedSessions.replace(playlist, fence, options);
@@ -586,6 +586,39 @@ describe('PlaylistsService', () => {
);
});
it('aborts a guarded metadata update when the queued row no longer matches', async () => {
const replacementPlaylist = {
_id: 'stalker-replaced',
title: 'Restored Portal',
portalUrl: 'https://restored.example.com/portal.php',
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(replacementPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await expect(
firstValueFrom(
service.updatePlaylistMetaIfCurrent(
{
_id: replacementPlaylist._id,
portalUrl: 'https://late.example.com/server/load.php',
} as PlaylistMeta,
(current) =>
current.portalUrl ===
'https://original.example.com/portal.php'
)
)
).resolves.toBeNull();
expect(dbService.update).not.toHaveBeenCalled();
});
describe('Stalker session patches in playlist meta updates', () => {
function createStalkerPlaylist(): Playlist {
return {
@@ -1737,7 +1770,7 @@ describe('PlaylistsService', () => {
});
it('transformPlaylistMeta aborts without writing when the transform returns null', async () => {
const { store, electron } = createStatefulElectronStore(
const { electron } = createStatefulElectronStore(
createBasePlaylist('portal-meta-abort')
);
testWindow.electron = electron;
@@ -628,10 +628,35 @@ export class PlaylistsService {
}
updatePlaylistMeta(updatedPlaylist: PlaylistMetaUpdate) {
return this.updatePlaylistMetaInQueue(updatedPlaylist);
}
/** Applies a meta update only while the queued current row still matches. */
updatePlaylistMetaIfCurrent(
updatedPlaylist: PlaylistMetaUpdate,
isCurrent: (playlist: Playlist) => boolean
) {
return this.updatePlaylistMetaInQueue(updatedPlaylist, isCurrent);
}
private updatePlaylistMetaInQueue(
updatedPlaylist: PlaylistMetaUpdate
): Observable<Playlist>;
private updatePlaylistMetaInQueue(
updatedPlaylist: PlaylistMetaUpdate,
isCurrent: (playlist: Playlist) => boolean
): Observable<Playlist | null>;
private updatePlaylistMetaInQueue(
updatedPlaylist: PlaylistMetaUpdate,
isCurrent?: (playlist: Playlist) => boolean
): Observable<Playlist | null> {
return this.serializePlaylistWrite(updatedPlaylist._id, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(updatedPlaylist._id)
);
if (isCurrent && !isCurrent(playlist)) {
return null;
}
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ?? playlist.detectedEpgUrls,