From f5c3543c28f58cdab350e864395a80762a5fd333 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 03:39:28 +0200 Subject: [PATCH] fix(settings): drop pre-relock Stalker search pages and fail closed on a corrupt lock store - A Stalker search page issued before a relock was filtered with the pre-relock withheld set and could still be applied after it; the staleness check now includes the parental lock version. - A lock store payload that does not parse or is not an object is a failed read (everything withheld until it reads again), no longer an empty store. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 8 +- .../stalker-search-request.util.spec.ts | 35 +++++++++ .../stalker-search-request.util.ts | 29 +++++++ .../stalker-search.component.ts | 18 +++-- .../parental-lock-storage.spec.ts | 75 +++++++++++++++++++ .../parental-lock/parental-lock-storage.ts | 11 ++- 6 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.ts create mode 100644 libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 4a562c4f4..4535847c4 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -106,7 +106,8 @@ the strength of default settings. The lock store itself fails closed the same way: `ParentalLockStorageService.readLocks()` reports a failed read as `null` (distinct from an absent store, `{}`; Electron reads through `DatabaseService.readAppState`, which keeps a rejected IPC apart from a -missing key), and while the lock is active with the store unreadable +missing key, and a stored payload that does not parse or is not an object +is a failed read too — corruption never becomes an empty store), and while the lock is active with the store unreadable `ParentalLockService.withholdsEverything` is true — every `is*Locked` predicate answers true and the set-based filters (PWA Xtream, Stalker content and search, the M3U channel list) receive @@ -173,6 +174,11 @@ would be built on an empty in-memory store and wipe the persisted locks). passing as unlocked. Electron routes carry SQLite row ids, so the Xtream guard maps them through the unfiltered category read; Stalker routes already carry the genre id. +- **Stalker search staleness:** portal requests are not aborted, so a + search page issued before a relock can finish after it, filtered with the + pre-relock withheld set; `isStalkerSearchRequestCurrent` keys the + response on the parental lock version as well as term/type/page/portal, + so such a page is dropped instead of repopulating the grid. - **Stalker search route** (`/workspace/stalker/:id/search`, no category in the URL): `StalkerSearchComponent` filters each portal page through the same withheld-genre predicate, carries `parentalLockVersion` in its diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.spec.ts new file mode 100644 index 000000000..f96a8ed87 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.spec.ts @@ -0,0 +1,35 @@ +import { isStalkerSearchRequestCurrent } from './stalker-search-request.util'; + +describe('isStalkerSearchRequestCurrent', () => { + const key = { + search: 'family', + contentType: 'vod', + page: 2, + playlistId: 'stalker-1', + parentalLockVersion: 3, + }; + + it('accepts a response whose coordinates are unchanged', () => { + expect(isStalkerSearchRequestCurrent(key, { ...key })).toBe(true); + }); + + it('rejects a response issued under an older parental lock version', () => { + expect( + isStalkerSearchRequestCurrent(key, { + ...key, + parentalLockVersion: 4, + }) + ).toBe(false); + }); + + it.each([ + ['search', { search: 'famil' }], + ['contentType', { contentType: 'series' }], + ['page', { page: 3 }], + ['playlistId', { playlistId: 'stalker-2' }], + ])('rejects a response whose %s moved on', (_field, change) => { + expect(isStalkerSearchRequestCurrent(key, { ...key, ...change })).toBe( + false + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.ts new file mode 100644 index 000000000..2d0b1ca26 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-request.util.ts @@ -0,0 +1,29 @@ +/** The coordinates a Stalker search page was requested under. */ +export interface StalkerSearchRequestKey { + readonly search: string; + readonly contentType: string; + readonly page: number; + readonly playlistId: string | null; + /** `ParentalLockService.version` at request time. */ + readonly parentalLockVersion: number; +} + +/** + * Whether a search response may still be applied. Portal requests are not + * aborted, so a page issued under an older state can finish after a newer + * one; the parental lock version is part of the key because an older + * response was filtered with the OLDER withheld set — applying it after a + * relock would repopulate the grid with locked-category rows. + */ +export function isStalkerSearchRequestCurrent( + requested: StalkerSearchRequestKey, + current: StalkerSearchRequestKey +): boolean { + return ( + requested.search === current.search && + requested.contentType === current.contentType && + requested.page === current.page && + requested.playlistId === current.playlistId && + requested.parentalLockVersion === current.parentalLockVersion + ); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index a1bcccfb6..daec451be 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -69,6 +69,7 @@ import { } from '@iptvnator/portal/stalker/data-access'; import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { isStalkerSearchRequestCurrent } from './stalker-search-request.util'; interface StalkerFilter { key: StalkerSearchContentType; @@ -340,13 +341,18 @@ export class StalkerSearchComponent { ...(contentType === 'vod' ? { genre: '0' } : {}), }; - // A stale response (term/filter/page/portal moved on while this - // page was in flight) must not clobber the accumulated list. + // A stale response (term/filter/page/portal — or the parental + // lock — moved on while this page was in flight) must not clobber + // the accumulated list: the request is not aborted, and a + // pre-relock response was filtered with the pre-relock set. const isCurrent = (): boolean => - params.search === this.searchTerm() && - params.contentType === this.selectedFilterType() && - params.page === this.searchPage() && - params.playlistId === (this.currentPlaylist()?._id ?? null); + isStalkerSearchRequestCurrent(params, { + search: this.searchTerm(), + contentType: this.selectedFilterType(), + page: this.searchPage(), + playlistId: this.currentPlaylist()?._id ?? null, + parentalLockVersion: this.parentalLock.version(), + }); try { // executeStalkerRequest owns the portal-mode decision (shared diff --git a/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts new file mode 100644 index 000000000..7abcaa587 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { PARENTAL_LOCK_STORE_KEY } from '@iptvnator/shared/interfaces'; +import { DatabaseService } from '../database-electron.service'; +import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; +import { ParentalLockStorageService } from './parental-lock-storage'; + +describe('ParentalLockStorageService.readLocks', () => { + const runtime = { supportsAppStateStorage: false }; + const databaseService = { + readAppState: jest.fn< + Promise<{ value: string | null } | null>, + [string] + >(), + }; + let service: ParentalLockStorageService; + + beforeEach(() => { + localStorage.clear(); + runtime.supportsAppStateStorage = false; + databaseService.readAppState.mockReset(); + TestBed.configureTestingModule({ + providers: [ + ParentalLockStorageService, + { provide: RuntimeCapabilitiesService, useValue: runtime }, + { provide: DatabaseService, useValue: databaseService }, + ], + }); + service = TestBed.inject(ParentalLockStorageService); + }); + + it('reads an absent store as empty', async () => { + await expect(service.readLocks()).resolves.toEqual({}); + }); + + it('normalizes a stored store', async () => { + localStorage.setItem( + PARENTAL_LOCK_STORE_KEY, + JSON.stringify({ p: { m3u: ['Adult'], junk: 1 } }) + ); + await expect(service.readLocks()).resolves.toEqual({ + p: { xtream: [], stalker: [], m3u: ['Adult'] }, + }); + }); + + it.each([ + ['truncated JSON', '{"p":{"m3u":["Adu'], + ['an array payload', '[]'], + ['a scalar payload', '"locks"'], + ])('treats %s as a failed read, not an empty store', async (_, raw) => { + localStorage.setItem(PARENTAL_LOCK_STORE_KEY, raw); + await expect(service.readLocks()).resolves.toBeNull(); + }); + + it('treats a throwing localStorage as a failed read', async () => { + const getItem = jest + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => { + throw new Error('SecurityError'); + }); + try { + await expect(service.readLocks()).resolves.toBeNull(); + } finally { + getItem.mockRestore(); + } + }); + + it('keeps an Electron read failure apart from a missing key', async () => { + runtime.supportsAppStateStorage = true; + databaseService.readAppState.mockResolvedValueOnce(null); + await expect(service.readLocks()).resolves.toBeNull(); + + databaseService.readAppState.mockResolvedValueOnce({ value: null }); + await expect(service.readLocks()).resolves.toEqual({}); + }); +}); diff --git a/libs/services/src/lib/parental-lock/parental-lock-storage.ts b/libs/services/src/lib/parental-lock/parental-lock-storage.ts index 3e0dcea7d..3afea5653 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-storage.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-storage.ts @@ -38,6 +38,8 @@ export class ParentalLockStorageService { * The lock store, or `null` when it could not be READ — distinct from an * absent store, which is `{}`. A failed read must not look like "nothing * is locked": the service withholds everything until it can read again. + * A stored payload that does not parse, or is not an object, counts as + * a failed read too: corruption must not become an empty store. */ async readLocks(): Promise { const result = await this.read(PARENTAL_LOCK_STORE_KEY); @@ -47,11 +49,16 @@ export class ParentalLockStorageService { if (!result.value) { return {}; } + let parsed: unknown; try { - return normalizeParentalLockStore(JSON.parse(result.value)); + parsed = JSON.parse(result.value); } catch { - return {}; + return null; } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + return normalizeParentalLockStore(parsed); } async writeLocks(store: ParentalLockStore): Promise {