mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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:
1 parent
c114cd04d3
commit
f5c3543c28
6 files changed
+167
-9
No files matched your search
@@ -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
|
||||
|
||||
+35
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user