From b64defb576c69532d8ed69a8dd402eb7cdf68523 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 05:00:45 +0200 Subject: [PATCH] fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed - The lock store is readable only once the SQLite index has been re-derived from it, and a re-stamp that keeps failing keeps the session fail-closed, so catalog reads can never serve rows stamped unlocked by a stale index. - While everything is withheld, Stalker rows without a genre are withheld as well (the store filter and the renderer predicate). Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 10 ++- .../utils/stalker-parental-lock.utils.spec.ts | 24 +++++++ .../utils/stalker-parental-lock.utils.ts | 13 ++-- .../parental-lock-lock-store.service.spec.ts | 33 +++++++++- .../parental-lock-lock-store.service.ts | 63 ++++++++++++------- .../parental-lock.service.spec.ts | 3 + .../parental-lock/parental-lock.service.ts | 16 +++-- 7 files changed, 127 insertions(+), 35 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index f5419ce4f..beca58093 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -111,7 +111,10 @@ is a failed read too — corruption never becomes an empty store), and while the `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 -`ALL_CATEGORIES_WITHHELD`, so the whole catalog is withheld until the PIN +`ALL_CATEGORIES_WITHHELD` — under which a row WITHOUT a genre is withheld +too (`isStalkerItemWithheld`, `isStalkerCategoryLocked`), since "no genre" +must not be the one row a withheld catalog still shows — so the whole +catalog is withheld until the PIN is entered or the store reads again (`requestUnlock` and every lock write retry the read first, and a write is refused while it still fails, since it would be built on an empty in-memory store and wipe the persisted locks). @@ -250,7 +253,10 @@ the toggle never shows a state the next launch will not have. shown as locked while Electron reads, which filter by the index alone, still serve it); if the rollback write fails too, the playlist is re-stamped on the next store access, and every launch re-derives the - index from the store for each playlist that has locks. + index from the store for each playlist that has locks — awaited inside + the store's `load()`, so `readable` (and with it every catalog read the + renderer gates) stays false until the index agrees with the store; a + re-stamp that keeps failing keeps the session fail-closed. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts index e64ed50fb..36eceaa72 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts @@ -2,6 +2,7 @@ import { isStalkerItemWithheld, withoutWithheldStalkerItems, } from './stalker-parental-lock.utils'; +import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces'; describe('stalker-parental-lock.utils', () => { const withheld = new Set(['7', '9']); @@ -40,3 +41,26 @@ describe('stalker-parental-lock.utils', () => { expect(withoutWithheldStalkerItems(items, 'vod', withheld)).toEqual([]); }); }); + +describe('isStalkerItemWithheld in fail-closed mode', () => { + it('withholds rows without a genre only while everything is withheld', () => { + expect(isStalkerItemWithheld({}, 'itv', ALL_CATEGORIES_WITHHELD)).toBe( + true + ); + expect( + isStalkerItemWithheld( + { name: 'x' } as never, + 'vod', + ALL_CATEGORIES_WITHHELD + ) + ).toBe(true); + expect(isStalkerItemWithheld({}, 'itv', new Set(['9']))).toBe(false); + expect( + withoutWithheldStalkerItems( + [{ tv_genre_id: '1' }, {}], + 'itv', + ALL_CATEGORIES_WITHHELD + ) + ).toEqual([]); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts index e2f2f8d50..5e0069cf4 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts @@ -1,3 +1,4 @@ +import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces'; import type { StalkerContentType } from '../stalker-store.contracts'; /** Minimal item shape the parental lock needs: the category a row belongs to. */ @@ -24,11 +25,13 @@ export function isStalkerItemWithheld( contentType === 'itv' || contentType === 'radio' ? item.tv_genre_id : item.category_id; - return ( - categoryId !== undefined && - categoryId !== null && - withheldCategoryIds.has(String(categoryId)) - ); + if (categoryId === undefined || categoryId === null) { + // A row without a genre is visible under a normal lock set but not + // in fail-closed mode: while the locks are unknown, "no genre" must + // not become the one row the withheld catalog still shows. + return withheldCategoryIds === ALL_CATEGORIES_WITHHELD; + } + return withheldCategoryIds.has(String(categoryId)); } export function withoutWithheldStalkerItems( diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts index 4ec0752f6..e0274de1d 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts @@ -51,6 +51,35 @@ describe('ParentalLockLockStore', () => { expect(store.readable()).toBe(true); }); + it('is not readable until the startup re-stamp has completed', async () => { + let resolveStamp: (ok: boolean) => void = () => undefined; + setCategoryLocks.mockImplementation( + () => new Promise((resolve) => (resolveStamp = resolve)) + ); + const load = store.load(); + await Promise.resolve(); + await Promise.resolve(); + + expect(setCategoryLocks).toHaveBeenCalled(); + expect(store.readable()).toBe(false); + + setCategoryLocks.mockResolvedValue(true); + resolveStamp(true); + await load; + expect(store.readable()).toBe(true); + }); + + it('stays not readable while the startup re-stamp keeps failing', async () => { + setCategoryLocks.mockResolvedValue(false); + await store.load(); + expect(store.readable()).toBe(false); + await expect(store.ensureReadable()).resolves.toBe(false); + + setCategoryLocks.mockResolvedValue(true); + await expect(store.ensureReadable()).resolves.toBe(true); + expect(store.readable()).toBe(true); + }); + it('re-derives the SQLite index from the store on load', async () => { await store.load(); // ensureReadable awaits the reconcile that load() started. @@ -92,9 +121,11 @@ describe('ParentalLockLockStore', () => { store.setXtreamLocks('pl-1', 'live', [7, 9]) ).resolves.toBe(false); expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7, 9]); + expect(store.readable()).toBe(false); - await store.ensureReadable(); + await expect(store.ensureReadable()).resolves.toBe(true); expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7, 9]); + expect(store.readable()).toBe(true); }); }); diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts index 91e5f9a00..71b595689 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -50,17 +50,23 @@ export class ParentalLockLockStore { * the next store access. */ private readonly staleIndexPlaylists = new Set(); + private readonly staleIndexCount = signal(0); /** The persisted store could not be read; see `ensureReadable()`. */ readonly unreadable = signal(false); /** - * The store has been read and is trustworthy. False while the initial - * read is still in flight — the settings can report the feature as on - * before the locks are known, and an empty in-memory store must not - * read as "nothing is locked" in that window. + * The store has been read, is trustworthy, and (on Electron) the SQLite + * index agrees with it. False while the initial read is still in flight + * — the settings can report the feature as on before the locks are + * known, and an empty in-memory store must not read as "nothing is + * locked" in that window — and while a re-stamp is outstanding, since + * Electron reads filter by the index alone. */ readonly readable = computed( - () => this.loadedState() && !this.unreadable() + () => + this.loadedState() && + !this.unreadable() && + this.staleIndexCount() === 0 ); /** Bumps whenever the lock set changes; consumers re-query. */ readonly revision = this.revisionState.asReadonly(); @@ -75,19 +81,24 @@ export class ParentalLockLockStore { */ load(): Promise { if (!this.loading) { - this.loading = this.storage.readLocks().then((locks) => { + this.loading = (async () => { + const locks = await this.storage.readLocks(); if (locks === null) { console.error('The parental lock store could not be read.'); this.unreadable.set(true); } else { this.locks.set(locks); for (const playlistId of Object.keys(locks)) { - this.staleIndexPlaylists.add(playlistId); + this.markIndexStale(playlistId); } + // Awaited: catalog reads issued before the index agrees + // with the store would serve rows stamped unlocked, and + // nothing would reload them afterwards. + await this.reconcileXtreamIndex(); } this.loadedState.set(true); - void this.reconcileXtreamIndex(); - }); + this.revisionState.update((value) => value + 1); + })(); } return this.loading; } @@ -98,24 +109,29 @@ export class ParentalLockLockStore { */ async ensureReadable(): Promise { await this.load(); - if (!this.unreadable()) { - await this.reconcileXtreamIndex(); - return true; + if (this.unreadable()) { + const locks = await this.storage.readLocks(); + if (locks === null) { + return false; + } + this.locks.set(locks); + this.unreadable.set(false); + this.revisionState.update((value) => value + 1); } - const locks = await this.storage.readLocks(); - if (locks === null) { - return false; - } - this.locks.set(locks); - this.unreadable.set(false); - this.revisionState.update((value) => value + 1); - return true; + await this.reconcileXtreamIndex(); + return this.readable(); + } + + private markIndexStale(playlistId: string): void { + this.staleIndexPlaylists.add(playlistId); + this.staleIndexCount.set(this.staleIndexPlaylists.size); } /** Re-stamps every playlist whose index may lag behind the store. */ private async reconcileXtreamIndex(): Promise { if (!this.runtime.supportsXtreamSqliteDataSource) { this.staleIndexPlaylists.clear(); + this.staleIndexCount.set(0); return; } for (const playlistId of [...this.staleIndexPlaylists]) { @@ -130,6 +146,10 @@ export class ParentalLockLockStore { ); } } + if (this.staleIndexCount() !== this.staleIndexPlaylists.size) { + this.staleIndexCount.set(this.staleIndexPlaylists.size); + this.revisionState.update((value) => value + 1); + } } locksFor(playlistId: string): ParentalLockPlaylistLocks { @@ -227,7 +247,8 @@ export class ParentalLockLockStore { ): Promise { if (!(await this.persistPlaylistLocks(playlistId, previous))) { console.error('Failed to roll back the parental lock store.'); - this.staleIndexPlaylists.add(playlistId); + this.markIndexStale(playlistId); + this.revisionState.update((value) => value + 1); } } diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index 1b1263a7a..88dc226d6 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -164,6 +164,9 @@ describe('ParentalLockService', () => { expect(service.withholdsEverything()).toBe(true); expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true); expect(service.isStalkerCategoryLocked('p', 'itv', '1')).toBe(true); + expect(service.isStalkerCategoryLocked('p', 'itv', undefined)).toBe( + true + ); expect(service.isM3uGroupLocked('p', 'News')).toBe(true); // Nothing is known about the persisted locks: a write built on the // empty in-memory store would wipe them. diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index dd0f13441..633fc1955 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -374,15 +374,19 @@ export class ParentalLockService { categoryType: ParentalLockStalkerCategoryType, categoryId: string | number | null | undefined ): boolean { + if (!this.active()) { + return false; + } + // Fail-closed mode withholds rows without a genre too: "unknown + // genre" is not "no locked genre" while the locks are unknown. + if (!this.locks.readable()) { + return true; + } if (categoryId === null || categoryId === undefined) { return false; } - return ( - this.active() && - (!this.locks.readable() || - this.lockedStalkerIds(playlistId, categoryType).includes( - String(categoryId) - )) + return this.lockedStalkerIds(playlistId, categoryType).includes( + String(categoryId) ); }