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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 03:39:28 +02:00
1 parent c114cd04d3
commit f5c3543c28
6 files changed
+167 -9

No files matched your search

+7 -1
View File
@@ -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
@@ -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
);
});
});
@@ -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
);
}
@@ -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
@@ -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({});
});
});
@@ -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<ParentalLockStore | null> {
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<boolean> {