mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries
- The Electron mirror of the feature switch is awaited; a mirror that cannot be written undoes the settings write, so a reload never starts from a mirror that disagrees with the persisted switch. - A failed multi-type re-stamp rolls the store back AND re-stamps every touched type from it, since earlier types may already carry the new locks; a failed rollback keeps the playlist stale (fail-closed). - A persisted lock store whose nested entries are not what writeLocks produces is a failed read, not an empty store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b64defb576
commit
559ec5508c
10 files changed
+193
-23
No files matched your search
@@ -106,8 +106,10 @@ 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 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
|
||||
missing key, and a stored payload that does not parse or does not have the
|
||||
shape `writeLocks` produces — `isWellFormedParentalLockStore`, down to the
|
||||
nested entries — 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
|
||||
@@ -122,9 +124,11 @@ The window before the initial read settles is treated the same way
|
||||
(`ParentalLockLockStore.readable` is false until then): settings can report
|
||||
the feature as on before the locks are known. The feature switch itself is
|
||||
persisted through one guarded path (`persistEnabled`): `updateSettings`
|
||||
patches memory before it writes, so a failed write is undone in memory, the
|
||||
Electron mirror is left untouched and `setupPin`/`disable` report false —
|
||||
the toggle never shows a state the next launch will not have.
|
||||
patches memory before it writes, so a failed write is undone in memory and
|
||||
`setupPin`/`disable` report false; the Electron mirror write is awaited
|
||||
next, and a mirror that cannot be written undoes the settings write the
|
||||
same way — the toggle never shows a state the next launch will not have,
|
||||
on either side.
|
||||
|
||||
### In-memory catalogs
|
||||
|
||||
@@ -249,9 +253,11 @@ the toggle never shows a state the next launch will not have.
|
||||
re-locks one playlist/type inside ONE transaction, so a failed restamp
|
||||
keeps the previous index instead of leaving every category unlocked. The
|
||||
store commits BEFORE that re-stamp, so a failed re-stamp rolls the store
|
||||
back to what the index reflects (a category must not be recorded and
|
||||
shown as locked while Electron reads, which filter by the index alone,
|
||||
still serve it); if the rollback write fails too, the playlist is
|
||||
back to the previous locks AND re-stamps every touched type from them (a
|
||||
backup restore stamps three types, and the ones before the failing type
|
||||
already carry the new locks; a category must not be recorded and shown
|
||||
as locked while Electron reads, which filter by the index alone, still
|
||||
serve it); if the rollback write or its re-stamp 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 — awaited inside
|
||||
the store's `load()`, so `readable` (and with it every catalog read the
|
||||
|
||||
@@ -16,14 +16,23 @@ export function syncParentalLockStateToMainProcess(active: boolean): void {
|
||||
|
||||
/**
|
||||
* Mirrors the feature switch into electron-conf so a reloaded renderer and a
|
||||
* restarted worker start locked while the feature is on.
|
||||
* restarted worker start locked while the feature is on. Resolves false
|
||||
* when the main process could not persist it — the caller must then not
|
||||
* report the switch as changed, or a reload would start from the old
|
||||
* mirror. True in the PWA, which has no mirror.
|
||||
*/
|
||||
export function mirrorParentalLockEnabledSetting(enabled: boolean): void {
|
||||
export async function mirrorParentalLockEnabledSetting(
|
||||
enabled: boolean
|
||||
): Promise<boolean> {
|
||||
const bridge = window.electron;
|
||||
if (typeof bridge?.updateSettings !== 'function') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await bridge.updateSettings({ parentalLockEnabled: enabled });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to mirror the parental lock switch.', error);
|
||||
return false;
|
||||
}
|
||||
void bridge
|
||||
.updateSettings({ parentalLockEnabled: enabled })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
@@ -108,6 +108,36 @@ describe('ParentalLockLockStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('re-stamps every touched type from the restored store after a partial multi-type failure', async () => {
|
||||
await store.load();
|
||||
await store.ensureReadable();
|
||||
setCategoryLocks.mockClear();
|
||||
// live commits, movies fails: the index now carries the NEW live
|
||||
// locks while the store is rolled back to the old ones.
|
||||
setCategoryLocks
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
store.replacePlaylistLocks('pl-1', {
|
||||
xtream: [{ categoryType: 'movies', xtreamId: 3 }],
|
||||
stalker: [],
|
||||
m3u: [],
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
|
||||
// Rollback re-stamps all three types from the restored store.
|
||||
expect(setCategoryLocks.mock.calls.slice(3)).toEqual([
|
||||
['pl-1', 'live', [7]],
|
||||
['pl-1', 'movies', []],
|
||||
['pl-1', 'series', []],
|
||||
]);
|
||||
expect(store.readable()).toBe(true);
|
||||
});
|
||||
|
||||
it('re-stamps on the next access when even the rollback write failed', async () => {
|
||||
await store.load();
|
||||
await store.ensureReadable();
|
||||
|
||||
@@ -189,7 +189,7 @@ export class ParentalLockLockStore {
|
||||
if (await this.stampXtreamLocks(playlistId, [categoryType])) {
|
||||
return true;
|
||||
}
|
||||
await this.rollBack(playlistId, previous);
|
||||
await this.rollBack(playlistId, previous, [categoryType]);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ export class ParentalLockLockStore {
|
||||
if (await this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES)) {
|
||||
return true;
|
||||
}
|
||||
await this.rollBack(playlistId, previous);
|
||||
await this.rollBack(playlistId, previous, XTREAM_CATEGORY_TYPES);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -238,15 +238,23 @@ export class ParentalLockLockStore {
|
||||
* The store commits before the SQLite index is re-stamped; a failed
|
||||
* re-stamp would otherwise leave a category recorded (and shown) as
|
||||
* locked while Electron reads, which filter by the index alone, still
|
||||
* serve it. The store goes back to what the index reflects; if even
|
||||
* that write fails, the playlist is re-stamped on the next access.
|
||||
* serve it. The store goes back to the previous locks and the touched
|
||||
* types are re-stamped from it — a multi-type re-stamp may have
|
||||
* committed some types before the failing one. If either step fails,
|
||||
* the playlist is marked stale, which keeps the session fail-closed and
|
||||
* re-stamps it on the next access.
|
||||
*/
|
||||
private async rollBack(
|
||||
playlistId: string,
|
||||
previous: ParentalLockPlaylistLocks
|
||||
previous: ParentalLockPlaylistLocks,
|
||||
categoryTypes: readonly ParentalLockXtreamCategoryType[]
|
||||
): Promise<void> {
|
||||
if (!(await this.persistPlaylistLocks(playlistId, previous))) {
|
||||
console.error('Failed to roll back the parental lock store.');
|
||||
const restored = await this.persistPlaylistLocks(playlistId, previous);
|
||||
const restamped =
|
||||
restored &&
|
||||
(await this.stampXtreamLocks(playlistId, categoryTypes));
|
||||
if (!restored || !restamped) {
|
||||
console.error('Failed to roll back the parental lock index.');
|
||||
this.markIndexStale(playlistId);
|
||||
this.revisionState.update((value) => value + 1);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ describe('ParentalLockStorageService.readLocks', () => {
|
||||
['truncated JSON', '{"p":{"m3u":["Adu'],
|
||||
['an array payload', '[]'],
|
||||
['a scalar payload', '"locks"'],
|
||||
['a corrupt nested list', '{"p":{"xtream":"corrupt"}}'],
|
||||
['a corrupt nested entry', '{"p":{"m3u":[1]}}'],
|
||||
[
|
||||
'an entry the normalizer would drop',
|
||||
'{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}]}}',
|
||||
],
|
||||
])('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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
isWellFormedParentalLockStore,
|
||||
normalizeParentalLockStore,
|
||||
PARENTAL_LOCK_PIN_KEY,
|
||||
PARENTAL_LOCK_STORE_KEY,
|
||||
@@ -55,7 +56,7 @@ export class ParentalLockStorageService {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
if (!isWellFormedParentalLockStore(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeParentalLockStore(parsed);
|
||||
|
||||
@@ -226,6 +226,20 @@ describe('ParentalLockService', () => {
|
||||
expect(updateBridgeSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls the switch back when the Electron mirror cannot be written', async () => {
|
||||
prompt.requestPin.mockResolvedValue('1234');
|
||||
updateBridgeSettings.mockRejectedValueOnce(new Error('ipc'));
|
||||
const service = await createService();
|
||||
|
||||
await expect(service.setupPin()).resolves.toBe(false);
|
||||
|
||||
expect(service.enabled()).toBe(false);
|
||||
expect(service.unlocked()).toBe(false);
|
||||
expect(updateSettings).toHaveBeenLastCalledWith({
|
||||
parentalLockEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('starts locked with the feature on and unlocks through the prompt', async () => {
|
||||
storage.pinHash = await hashParentalLockPin('1234');
|
||||
parentalLockEnabled.set(true);
|
||||
|
||||
@@ -267,7 +267,14 @@ export class ParentalLockService {
|
||||
.catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
mirrorParentalLockEnabledSetting(enabled);
|
||||
// The mirror is what a reloaded renderer and a restarted worker
|
||||
// start from; a switch persisted on one side only is undone.
|
||||
if (!(await mirrorParentalLockEnabledSetting(enabled))) {
|
||||
await this.settingsStore
|
||||
.updateSettings({ parentalLockEnabled: !enabled })
|
||||
.catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isParentalLockPlaylistLocksEmpty,
|
||||
isWellFormedParentalLockStore,
|
||||
lockedStalkerCategoryIds,
|
||||
lockedXtreamCategoryIds,
|
||||
normalizeParentalLockPlaylistLocks,
|
||||
@@ -109,3 +110,40 @@ describe('parental-lock.util', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWellFormedParentalLockStore', () => {
|
||||
it('accepts what writeLocks produces, lists omitted or empty', () => {
|
||||
expect(isWellFormedParentalLockStore({})).toBe(true);
|
||||
expect(
|
||||
isWellFormedParentalLockStore({
|
||||
p: {
|
||||
xtream: [{ categoryType: 'live', xtreamId: 7 }],
|
||||
stalker: [{ categoryType: 'itv', categoryId: '9' }],
|
||||
m3u: ['Adult'],
|
||||
},
|
||||
q: { m3u: [] },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-object root', []],
|
||||
['a non-object playlist entry', { p: 'corrupt' }],
|
||||
['a non-list lock field', { p: { xtream: 'corrupt' } }],
|
||||
['a non-string group title', { p: { m3u: [1] } }],
|
||||
[
|
||||
'an unknown category type',
|
||||
{ p: { xtream: [{ categoryType: 'x', xtreamId: 1 }] } },
|
||||
],
|
||||
[
|
||||
'a non-numeric xtream id',
|
||||
{ p: { xtream: [{ categoryType: 'live', xtreamId: 'nope' }] } },
|
||||
],
|
||||
[
|
||||
'the Stalker "all" pseudo id',
|
||||
{ p: { stalker: [{ categoryType: 'itv', categoryId: '*' }] } },
|
||||
],
|
||||
])('rejects %s', (_, value) => {
|
||||
expect(isWellFormedParentalLockStore(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,57 @@ export function normalizeParentalLockStalkerCategories(
|
||||
return result;
|
||||
}
|
||||
|
||||
function isWellFormedList(
|
||||
value: unknown,
|
||||
isWellFormedEntry: (entry: unknown) => boolean
|
||||
): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) && value.every(isWellFormedEntry))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a persisted store has the shape `writeLocks` produces: every
|
||||
* playlist entry an object whose optional `xtream`/`stalker`/`m3u` lists
|
||||
* hold only entries the normalizers accept. The normalizers DROP what they
|
||||
* do not understand, which is right for user-supplied backups but turns a
|
||||
* corrupted persisted store into "nothing is locked"; a store that fails
|
||||
* this check is treated as unreadable instead.
|
||||
*/
|
||||
export function isWellFormedParentalLockStore(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(
|
||||
(locks) =>
|
||||
isRecord(locks) &&
|
||||
isWellFormedList(
|
||||
locks['xtream'],
|
||||
(entry) =>
|
||||
isRecord(entry) &&
|
||||
PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(
|
||||
entry['categoryType'] as ParentalLockXtreamCategoryType
|
||||
) &&
|
||||
normalizeNumericId(entry['xtreamId']) !== null
|
||||
) &&
|
||||
isWellFormedList(
|
||||
locks['stalker'],
|
||||
(entry) =>
|
||||
isRecord(entry) &&
|
||||
PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(
|
||||
entry['categoryType'] as ParentalLockStalkerCategoryType
|
||||
) &&
|
||||
((typeof entry['categoryId'] === 'string' &&
|
||||
entry['categoryId'].trim() !== '' &&
|
||||
entry['categoryId'].trim() !== '*') ||
|
||||
(typeof entry['categoryId'] === 'number' &&
|
||||
Number.isFinite(entry['categoryId'])))
|
||||
) &&
|
||||
isWellFormedList(locks['m3u'], (entry) => typeof entry === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeParentalLockGroupTitles(value: unknown): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const item of toArray(value)) {
|
||||
|
||||
Reference in new issue
Block a user