mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(settings): fail closed on an unreadable lock store and make lock writes reliable
- A lock store that cannot be read is no longer treated as empty: while the lock is active every category is withheld (renderer predicates and set-based filters alike) until the PIN is entered or the store reads again, and writes are refused meanwhile so an empty in-memory store can never wipe the persisted locks. The lock set now lives in its own ParentalLockLockStore service. - The M3U group dialog's lock write is awaited and a failed save is reported in a snackbar instead of being silently dropped. - The Electron categories.locked re-stamp clears and re-locks inside one transaction, so a failed restamp keeps the previous index. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
41bab4dcaa
commit
c114cd04d3
16 files changed
+482
-143
No files matched your search
@@ -212,14 +212,28 @@ describe('category.operations parental lock', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('re-stamps one playlist/type: clears everything, then locks the listed ids', async () => {
|
||||
const where = jest.fn().mockResolvedValue(undefined);
|
||||
function lockIndexDb() {
|
||||
const run = jest.fn();
|
||||
const where = jest.fn().mockReturnValue({ run });
|
||||
const set = jest.fn().mockReturnValue({ where });
|
||||
const update = jest.fn().mockReturnValue({ set });
|
||||
const db = { update } as unknown as AppDatabase;
|
||||
const transaction = jest.fn((callback: () => void) => callback());
|
||||
return {
|
||||
db: { update, transaction } as unknown as AppDatabase,
|
||||
run,
|
||||
set,
|
||||
transaction,
|
||||
where,
|
||||
};
|
||||
}
|
||||
|
||||
it('re-stamps one playlist/type in one transaction: clears everything, then locks the listed ids', async () => {
|
||||
const { db, run, set, transaction, where } = lockIndexDb();
|
||||
|
||||
await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(set).toHaveBeenNthCalledWith(1, { locked: false });
|
||||
expect(set).toHaveBeenNthCalledWith(2, { locked: true });
|
||||
const lockScope = new SQLiteSyncDialect().sqlToQuery(
|
||||
@@ -230,10 +244,7 @@ describe('category.operations parental lock', () => {
|
||||
});
|
||||
|
||||
it('only clears when no id is locked', async () => {
|
||||
const where = jest.fn().mockResolvedValue(undefined);
|
||||
const set = jest.fn().mockReturnValue({ where });
|
||||
const update = jest.fn().mockReturnValue({ set });
|
||||
const db = { update } as unknown as AppDatabase;
|
||||
const { db, set } = lockIndexDb();
|
||||
|
||||
await setCategoryLocks(db, 'playlist-1', 'series', []);
|
||||
|
||||
@@ -241,3 +252,23 @@ describe('category.operations parental lock', () => {
|
||||
expect(set).toHaveBeenCalledWith({ locked: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCategoryLocks atomicity', () => {
|
||||
it('runs both statements inside the transaction callback', async () => {
|
||||
const order: string[] = [];
|
||||
const run = jest.fn(() => order.push('run'));
|
||||
const where = jest.fn().mockReturnValue({ run });
|
||||
const set = jest.fn().mockReturnValue({ where });
|
||||
const update = jest.fn().mockReturnValue({ set });
|
||||
const transaction = jest.fn((callback: () => void) => {
|
||||
order.push('begin');
|
||||
callback();
|
||||
order.push('commit');
|
||||
});
|
||||
const db = { update, transaction } as unknown as AppDatabase;
|
||||
|
||||
await setCategoryLocks(db, 'playlist-1', 'live', [5]);
|
||||
|
||||
expect(order).toEqual(['begin', 'run', 'run', 'commit']);
|
||||
});
|
||||
});
|
||||
@@ -250,14 +250,22 @@ export async function setCategoryLocks(
|
||||
...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))),
|
||||
];
|
||||
|
||||
await db.update(schema.categories).set({ locked: false }).where(scope);
|
||||
// One transaction: a re-stamp that fails after the clear would otherwise
|
||||
// leave every category of this playlist/type unlocked while the lock
|
||||
// store still lists the intended locks. `.run()` (synchronous), not
|
||||
// `.execute()`: see playback-position.operations.ts.
|
||||
await db.transaction(() => {
|
||||
db.update(schema.categories).set({ locked: false }).where(scope).run();
|
||||
|
||||
if (lockedIds.length > 0) {
|
||||
await db
|
||||
.update(schema.categories)
|
||||
.set({ locked: true })
|
||||
.where(and(scope, inArray(schema.categories.xtreamId, lockedIds)));
|
||||
}
|
||||
if (lockedIds.length > 0) {
|
||||
db.update(schema.categories)
|
||||
.set({ locked: true })
|
||||
.where(
|
||||
and(scope, inArray(schema.categories.xtreamId, lockedIds))
|
||||
)
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -102,7 +102,18 @@ then unknown rather than off — a stored PIN stands in for it (`enabled`
|
||||
follows `hasPin`, so the session starts locked and one PIN entry unlocks
|
||||
it), and without a PIN the renderer does not report at all, leaving the
|
||||
main process on its mirrored default instead of announcing "unlocked" on
|
||||
the strength of default settings.
|
||||
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
|
||||
`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
|
||||
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).
|
||||
|
||||
### In-memory catalogs
|
||||
|
||||
@@ -211,7 +222,12 @@ the strength of default settings.
|
||||
its candidates through the capability-selected data source
|
||||
(`IXtreamDataSource.getAllCategories`, which the PWA source answers from
|
||||
its session cache or the API), so PWA users can set locks too; the
|
||||
hide/show checkboxes remain Electron-only.
|
||||
hide/show checkboxes remain Electron-only. The M3U dialog hands its lock
|
||||
list back to `ChannelListContainerComponent`, which awaits the write and
|
||||
reports a failed save in a snackbar (the dialog has closed by then). On
|
||||
Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and
|
||||
re-locks one playlist/type inside ONE transaction, so a failed restamp
|
||||
keeps the previous index instead of leaving every category unlocked.
|
||||
- Header lock/unlock button and the `parental-lock-now` /
|
||||
`parental-unlock` palette commands.
|
||||
|
||||
@@ -240,7 +256,9 @@ predicate is `ParentalLockService.is*Locked`.
|
||||
- Contracts: `libs/shared/interfaces/src/lib/parental-lock.util.ts`,
|
||||
`parental-lock-pin.util.ts`, `Settings.parentalLock*`,
|
||||
`PARENTAL_LOCK_SET_STATE`.
|
||||
- Renderer: `libs/services/src/lib/parental-lock/`,
|
||||
- Renderer: `libs/services/src/lib/parental-lock/` (`ParentalLockService`
|
||||
owns the session state and PIN flows; `ParentalLockLockStore` owns the
|
||||
persisted lock set, its unreadable state and the SQLite re-stamp),
|
||||
`apps/web/src/app/services/parental-lock-prompt.service.ts`,
|
||||
`parental-lock-enforcement.service.ts`,
|
||||
`apps/web/src/app/settings/settings-parental-lock*`,
|
||||
|
||||
+5
-1
@@ -15,6 +15,7 @@ import {
|
||||
resetHostConnectivityGuard,
|
||||
} from '@iptvnator/services';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
StalkerCategoryItem,
|
||||
StalkerContentItem,
|
||||
@@ -47,11 +48,14 @@ function withheldStalkerCategoryIds(
|
||||
parentalLock: ParentalLockService,
|
||||
playlist: PlaylistMeta | undefined,
|
||||
contentType: StalkerContentType
|
||||
): Set<string> {
|
||||
): ReadonlySet<string> {
|
||||
const playlistId = playlist?._id;
|
||||
if (!playlistId || !parentalLock.active()) {
|
||||
return new Set();
|
||||
}
|
||||
if (parentalLock.withholdsEverything?.()) {
|
||||
return ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
return new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
resetHostConnectivityGuard,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
StalkerPortalActions,
|
||||
@@ -278,14 +279,17 @@ export class StalkerSearchComponent {
|
||||
// The dedicated search route has no category guard, so it filters
|
||||
// the portal's rows itself: a locked genre's title must not reach
|
||||
// the grid, its detail or playback through search.
|
||||
const withheldCategoryIds = this.parentalLock.active()
|
||||
? new Set(
|
||||
this.parentalLock.lockedStalkerIds(
|
||||
playlist._id,
|
||||
contentType
|
||||
)
|
||||
)
|
||||
: new Set<string>();
|
||||
const withheldCategoryIds: ReadonlySet<string> =
|
||||
!this.parentalLock.active()
|
||||
? new Set<string>()
|
||||
: this.parentalLock.withholdsEverything?.()
|
||||
? ALL_CATEGORIES_WITHHELD
|
||||
: new Set(
|
||||
this.parentalLock.lockedStalkerIds(
|
||||
playlist._id,
|
||||
contentType
|
||||
)
|
||||
);
|
||||
const lockVersionChanged =
|
||||
this.searchResultsLockVersion !== null &&
|
||||
this.searchResultsLockVersion !== params.parentalLockVersion;
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
let parentalLock: {
|
||||
active: jest.Mock<boolean, []>;
|
||||
lockedXtreamIds: jest.Mock<number[], [string, string]>;
|
||||
withholdsEverything: jest.Mock<boolean, []>;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
@@ -41,6 +42,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
lockedXtreamIds: jest.fn(() => []),
|
||||
withholdsEverything: jest.fn(() => false),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -96,6 +98,27 @@ describe('PwaXtreamDataSource', () => {
|
||||
expect(unlocked).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('withholds everything while the lock store cannot be read', async () => {
|
||||
apiService.getStreams.mockResolvedValue([
|
||||
{ stream_id: 1, name: 'Family film', category_id: '5' },
|
||||
]);
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.withholdsEverything = jest.fn(() => true);
|
||||
|
||||
const content = await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
);
|
||||
const found = await dataSource.searchContent('playlist-1', 'family', [
|
||||
'movie',
|
||||
]);
|
||||
|
||||
expect(content).toEqual([]);
|
||||
expect(found).toEqual([]);
|
||||
expect(parentalLock.lockedXtreamIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
|
||||
apiService.getStreams.mockResolvedValue([
|
||||
{ stream_id: 1, name: 'News' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable, Injector } from '@angular/core';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
foldSearchText,
|
||||
ContentMetadataPatch,
|
||||
Playlist,
|
||||
@@ -361,10 +362,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
private withheldCategoryIds(
|
||||
playlistId: string,
|
||||
type: CategoryType | StreamType
|
||||
): Set<string> {
|
||||
): ReadonlySet<string> {
|
||||
if (!this.parentalLock.active()) {
|
||||
return new Set();
|
||||
}
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
const dbType =
|
||||
type === 'vod' || type === 'movie'
|
||||
? 'movies'
|
||||
|
||||
@@ -568,12 +568,20 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
async getAppState(key: string): Promise<string | null> {
|
||||
return (await this.readAppState(key))?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link getAppState}, but a failed read (no bridge, rejected IPC)
|
||||
* comes back as `null` instead of looking like an absent key.
|
||||
*/
|
||||
async readAppState(key: string): Promise<{ value: string | null } | null> {
|
||||
if (typeof window.electron?.dbGetAppState !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await window.electron.dbGetAppState(key);
|
||||
return { value: await window.electron.dbGetAppState(key) };
|
||||
} catch (error) {
|
||||
console.error('Error getting app state:', error);
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './parental-lock-idle-timer';
|
||||
export * from './parental-lock-prompt.token';
|
||||
export * from './parental-lock-lock-store.service';
|
||||
export * from './parental-lock-storage';
|
||||
export * from './parental-lock.service';
|
||||
@@ -0,0 +1,207 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
createEmptyParentalLockPlaylistLocks,
|
||||
isParentalLockPlaylistLocksEmpty,
|
||||
lockedStalkerCategoryIds,
|
||||
lockedXtreamCategoryIds,
|
||||
normalizeParentalLockPlaylistLocks,
|
||||
ParentalLockPlaylistLocks,
|
||||
ParentalLockStalkerCategoryType,
|
||||
ParentalLockStore,
|
||||
ParentalLockXtreamCategoryType,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DatabaseService } from '../database-electron.service';
|
||||
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
|
||||
import { ParentalLockStorageService } from './parental-lock-storage';
|
||||
import {
|
||||
withM3uLocks,
|
||||
withStalkerLocks,
|
||||
withXtreamLocks,
|
||||
} from './parental-lock-store.util';
|
||||
|
||||
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
|
||||
'live',
|
||||
'movies',
|
||||
'series',
|
||||
];
|
||||
|
||||
/**
|
||||
* The lock store: WHICH categories are locked, per playlist, persisted
|
||||
* through `ParentalLockStorageService`. Knows nothing about the PIN or the
|
||||
* session state — `ParentalLockService` composes it and adds `active`.
|
||||
*
|
||||
* A store that could not be READ is a distinct state (`unreadable`): it is
|
||||
* not "nothing is locked", and no write may be built on the empty in-memory
|
||||
* store while it lasts, or the persisted locks would be wiped.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ParentalLockLockStore {
|
||||
private readonly storage = inject(ParentalLockStorageService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
|
||||
private readonly locks = signal<ParentalLockStore>({});
|
||||
private readonly revisionState = signal(0);
|
||||
private loading: Promise<void> | null = null;
|
||||
|
||||
/** The persisted store could not be read; see `ensureReadable()`. */
|
||||
readonly unreadable = signal(false);
|
||||
/** Bumps whenever the lock set changes; consumers re-query. */
|
||||
readonly revision = this.revisionState.asReadonly();
|
||||
readonly isEmpty = computed(() => Object.keys(this.locks()).length === 0);
|
||||
|
||||
/** Reads the persisted store once. */
|
||||
load(): Promise<void> {
|
||||
if (!this.loading) {
|
||||
this.loading = this.storage.readLocks().then((locks) => {
|
||||
if (locks === null) {
|
||||
console.error('The parental lock store could not be read.');
|
||||
this.unreadable.set(true);
|
||||
return;
|
||||
}
|
||||
this.locks.set(locks);
|
||||
});
|
||||
}
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads a store that failed to read earlier. False while it still
|
||||
* cannot be read.
|
||||
*/
|
||||
async ensureReadable(): Promise<boolean> {
|
||||
await this.load();
|
||||
if (!this.unreadable()) {
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
locksFor(playlistId: string): ParentalLockPlaylistLocks {
|
||||
return (
|
||||
this.locks()[playlistId] ?? createEmptyParentalLockPlaylistLocks()
|
||||
);
|
||||
}
|
||||
|
||||
lockedXtreamIds(
|
||||
playlistId: string,
|
||||
categoryType: ParentalLockXtreamCategoryType
|
||||
): number[] {
|
||||
return lockedXtreamCategoryIds(this.locks()[playlistId], categoryType);
|
||||
}
|
||||
|
||||
lockedStalkerIds(
|
||||
playlistId: string,
|
||||
categoryType: ParentalLockStalkerCategoryType
|
||||
): string[] {
|
||||
return lockedStalkerCategoryIds(this.locks()[playlistId], categoryType);
|
||||
}
|
||||
|
||||
lockedGroupTitles(playlistId: string): string[] {
|
||||
return this.locks()[playlistId]?.m3u ?? [];
|
||||
}
|
||||
|
||||
async setXtreamLocks(
|
||||
playlistId: string,
|
||||
categoryType: ParentalLockXtreamCategoryType,
|
||||
xtreamIds: number[]
|
||||
): Promise<boolean> {
|
||||
const next = withXtreamLocks(
|
||||
this.locksFor(playlistId),
|
||||
categoryType,
|
||||
xtreamIds
|
||||
);
|
||||
if (!(await this.persistPlaylistLocks(playlistId, next))) {
|
||||
return false;
|
||||
}
|
||||
return this.stampXtreamLocks(playlistId, [categoryType]);
|
||||
}
|
||||
|
||||
async setStalkerLocks(
|
||||
playlistId: string,
|
||||
categoryType: ParentalLockStalkerCategoryType,
|
||||
categoryIds: string[]
|
||||
): Promise<boolean> {
|
||||
return this.persistPlaylistLocks(
|
||||
playlistId,
|
||||
withStalkerLocks(
|
||||
this.locksFor(playlistId),
|
||||
categoryType,
|
||||
categoryIds
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async setM3uLocks(
|
||||
playlistId: string,
|
||||
groupTitles: string[]
|
||||
): Promise<boolean> {
|
||||
return this.persistPlaylistLocks(
|
||||
playlistId,
|
||||
withM3uLocks(this.locksFor(playlistId), groupTitles)
|
||||
);
|
||||
}
|
||||
|
||||
/** Backup restore: replaces every lock of one playlist. */
|
||||
async replacePlaylistLocks(
|
||||
playlistId: string,
|
||||
locks: ParentalLockPlaylistLocks
|
||||
): Promise<boolean> {
|
||||
if (!(await this.persistPlaylistLocks(playlistId, locks))) {
|
||||
return false;
|
||||
}
|
||||
return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-stamps the Electron `categories.locked` index for a playlist from
|
||||
* the store, e.g. after a refresh recreated the rows.
|
||||
*/
|
||||
async stampXtreamLocks(
|
||||
playlistId: string,
|
||||
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES
|
||||
): Promise<boolean> {
|
||||
if (!this.runtime.supportsXtreamSqliteDataSource) {
|
||||
return true;
|
||||
}
|
||||
let success = true;
|
||||
for (const categoryType of categoryTypes) {
|
||||
success =
|
||||
(await this.databaseService.setCategoryLocks(
|
||||
playlistId,
|
||||
categoryType,
|
||||
this.lockedXtreamIds(playlistId, categoryType)
|
||||
)) && success;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private async persistPlaylistLocks(
|
||||
playlistId: string,
|
||||
locks: ParentalLockPlaylistLocks
|
||||
): Promise<boolean> {
|
||||
if (!(await this.ensureReadable())) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeParentalLockPlaylistLocks(locks);
|
||||
const next: ParentalLockStore = { ...this.locks() };
|
||||
if (isParentalLockPlaylistLocksEmpty(normalized)) {
|
||||
delete next[playlistId];
|
||||
} else {
|
||||
next[playlistId] = normalized;
|
||||
}
|
||||
if (!(await this.storage.writeLocks(next))) {
|
||||
return false;
|
||||
}
|
||||
this.locks.set(next);
|
||||
this.revisionState.update((value) => value + 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class ParentalLockStorageService {
|
||||
}
|
||||
|
||||
async readPinHash(): Promise<string | null> {
|
||||
const value = await this.read(PARENTAL_LOCK_PIN_KEY);
|
||||
const value = (await this.read(PARENTAL_LOCK_PIN_KEY))?.value;
|
||||
return value && value.trim() !== '' ? value : null;
|
||||
}
|
||||
|
||||
@@ -34,13 +34,21 @@ export class ParentalLockStorageService {
|
||||
return this.write(PARENTAL_LOCK_PIN_KEY, hash);
|
||||
}
|
||||
|
||||
async readLocks(): Promise<ParentalLockStore> {
|
||||
const raw = await this.read(PARENTAL_LOCK_STORE_KEY);
|
||||
if (!raw) {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async readLocks(): Promise<ParentalLockStore | null> {
|
||||
const result = await this.read(PARENTAL_LOCK_STORE_KEY);
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
if (!result.value) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return normalizeParentalLockStore(JSON.parse(raw));
|
||||
return normalizeParentalLockStore(JSON.parse(result.value));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
@@ -53,12 +61,13 @@ export class ParentalLockStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
private async read(key: string): Promise<string | null> {
|
||||
/** `null` when the read itself failed; an absent key is `{ value: null }`. */
|
||||
private async read(key: string): Promise<{ value: string | null } | null> {
|
||||
if (this.usesAppState) {
|
||||
return this.databaseService.getAppState(key);
|
||||
return this.databaseService.readAppState(key);
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
return { value: localStorage.getItem(key) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,36 @@ describe('ParentalLockService', () => {
|
||||
expect(setParentalLockState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('withholds every category while the lock store cannot be read', async () => {
|
||||
storage.pinHash = await hashParentalLockPin('1234');
|
||||
parentalLockEnabled.set(true);
|
||||
storage.readLocks.mockResolvedValue(null);
|
||||
|
||||
const service = await createService();
|
||||
|
||||
expect(service.active()).toBe(true);
|
||||
expect(service.withholdsEverything()).toBe(true);
|
||||
expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true);
|
||||
expect(service.isStalkerCategoryLocked('p', 'itv', '1')).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.
|
||||
await expect(service.setM3uLocks('p', ['Adult'])).resolves.toBe(false);
|
||||
expect(storage.writeLocks).not.toHaveBeenCalled();
|
||||
|
||||
// The store reads again: the real locks apply and writes resume.
|
||||
storage.readLocks.mockResolvedValue({
|
||||
p: { xtream: [], stalker: [], m3u: ['Adult'] },
|
||||
});
|
||||
await expect(service.setM3uLocks('p', ['Adult', 'XXX'])).resolves.toBe(
|
||||
true
|
||||
);
|
||||
TestBed.flushEffects();
|
||||
expect(service.withholdsEverything()).toBe(false);
|
||||
expect(service.isM3uGroupLocked('p', 'News')).toBe(false);
|
||||
expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true);
|
||||
});
|
||||
|
||||
it('starts locked with the feature on and unlocks through the prompt', async () => {
|
||||
storage.pinHash = await hashParentalLockPin('1234');
|
||||
parentalLockEnabled.set(true);
|
||||
|
||||
@@ -7,23 +7,16 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
createEmptyParentalLockPlaylistLocks,
|
||||
hashParentalLockPin,
|
||||
isParentalLockPlaylistLocksEmpty,
|
||||
lockedStalkerCategoryIds,
|
||||
lockedXtreamCategoryIds,
|
||||
normalizeParentalLockPlaylistLocks,
|
||||
normalizeParentalLockRelockMinutes,
|
||||
ParentalLockPlaylistLocks,
|
||||
ParentalLockStalkerCategoryType,
|
||||
ParentalLockStore,
|
||||
ParentalLockXtreamCategoryType,
|
||||
verifyParentalLockPin,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DatabaseService } from '../database-electron.service';
|
||||
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
|
||||
import { SettingsStore } from '../settings-store.service';
|
||||
import { ParentalLockIdleTimer } from './parental-lock-idle-timer';
|
||||
import { ParentalLockLockStore } from './parental-lock-lock-store.service';
|
||||
import {
|
||||
PARENTAL_LOCK_PROMPT,
|
||||
ParentalLockPromptRequest,
|
||||
@@ -33,17 +26,6 @@ import {
|
||||
syncParentalLockStateToMainProcess,
|
||||
} from './parental-lock-bridge';
|
||||
import { ParentalLockStorageService } from './parental-lock-storage';
|
||||
import {
|
||||
withM3uLocks,
|
||||
withStalkerLocks,
|
||||
withXtreamLocks,
|
||||
} from './parental-lock-store.util';
|
||||
|
||||
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
|
||||
'live',
|
||||
'movies',
|
||||
'series',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parental lock: hides locked categories everywhere until the PIN is
|
||||
@@ -64,13 +46,11 @@ const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
|
||||
export class ParentalLockService {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly storage = inject(ParentalLockStorageService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly locks = inject(ParentalLockLockStore);
|
||||
private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true });
|
||||
|
||||
private readonly unlockedState = signal(false);
|
||||
private readonly pinHash = signal<string | null>(null);
|
||||
private readonly lockStore = signal<ParentalLockStore>({});
|
||||
private readonly versionState = signal(0);
|
||||
// The main process starts LOCKED whenever the feature is on (mirrored
|
||||
// setting). Reporting our state before settings have loaded would send a
|
||||
@@ -109,8 +89,20 @@ export class ParentalLockService {
|
||||
readonly active = computed(() => this.enabled() && !this.unlockedState());
|
||||
/** A PIN exists; enabling is only possible once this is true. */
|
||||
readonly hasPin = computed(() => this.pinHash() !== null);
|
||||
/**
|
||||
* Locked, and the lock store could not be read: which categories are
|
||||
* locked is unknown, so EVERY category is withheld — the renderer-side
|
||||
* filters treat every id as locked — until the PIN is entered or the
|
||||
* store reads again. Failing toward "nothing is locked" would expose the
|
||||
* protected content precisely during a storage failure.
|
||||
*/
|
||||
readonly withholdsEverything = computed(
|
||||
() => this.active() && this.locks.unreadable()
|
||||
);
|
||||
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
|
||||
readonly version = this.versionState.asReadonly();
|
||||
readonly version = computed(
|
||||
() => this.versionState() + this.locks.revision()
|
||||
);
|
||||
readonly relockMinutes = computed(() =>
|
||||
normalizeParentalLockRelockMinutes(
|
||||
this.settingsStore.parentalLockRelockMinutes?.()
|
||||
@@ -153,13 +145,12 @@ export class ParentalLockService {
|
||||
initialize(): Promise<void> {
|
||||
if (!this.initialization) {
|
||||
this.initialization = (async () => {
|
||||
const [pinHash, locks] = await Promise.all([
|
||||
const [pinHash] = await Promise.all([
|
||||
this.storage.readPinHash(),
|
||||
this.storage.readLocks(),
|
||||
this.locks.load(),
|
||||
this.settingsStore.loadSettings(),
|
||||
]);
|
||||
this.pinHash.set(pinHash);
|
||||
this.lockStore.set(locks);
|
||||
this.versionState.update((value) => value + 1);
|
||||
this.settingsReady.set(true);
|
||||
})().catch((error) => {
|
||||
@@ -196,6 +187,9 @@ export class ParentalLockService {
|
||||
if (!this.active()) {
|
||||
return true;
|
||||
}
|
||||
// A store that failed to read at startup may read now; do not send
|
||||
// the user through the PIN for categories that are not locked.
|
||||
await this.locks.ensureReadable();
|
||||
if (this.pendingUnlock) {
|
||||
return this.pendingUnlock;
|
||||
}
|
||||
@@ -316,37 +310,28 @@ export class ParentalLockService {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Lock store -------------------------------------------------------
|
||||
// -- Lock store (ParentalLockLockStore; predicates add `active`) -------
|
||||
|
||||
locksFor(playlistId: string): ParentalLockPlaylistLocks {
|
||||
return (
|
||||
this.lockStore()[playlistId] ??
|
||||
createEmptyParentalLockPlaylistLocks()
|
||||
);
|
||||
return this.locks.locksFor(playlistId);
|
||||
}
|
||||
|
||||
lockedXtreamIds(
|
||||
playlistId: string,
|
||||
categoryType: ParentalLockXtreamCategoryType
|
||||
): number[] {
|
||||
return lockedXtreamCategoryIds(
|
||||
this.lockStore()[playlistId],
|
||||
categoryType
|
||||
);
|
||||
return this.locks.lockedXtreamIds(playlistId, categoryType);
|
||||
}
|
||||
|
||||
lockedStalkerIds(
|
||||
playlistId: string,
|
||||
categoryType: ParentalLockStalkerCategoryType
|
||||
): string[] {
|
||||
return lockedStalkerCategoryIds(
|
||||
this.lockStore()[playlistId],
|
||||
categoryType
|
||||
);
|
||||
return this.locks.lockedStalkerIds(playlistId, categoryType);
|
||||
}
|
||||
|
||||
lockedGroupTitles(playlistId: string): string[] {
|
||||
return this.lockStore()[playlistId]?.m3u ?? [];
|
||||
return this.locks.lockedGroupTitles(playlistId);
|
||||
}
|
||||
|
||||
/** Whether the category is locked AND currently withheld. */
|
||||
@@ -357,7 +342,10 @@ export class ParentalLockService {
|
||||
): boolean {
|
||||
return (
|
||||
this.active() &&
|
||||
this.lockedXtreamIds(playlistId, categoryType).includes(xtreamId)
|
||||
(this.locks.unreadable() ||
|
||||
this.lockedXtreamIds(playlistId, categoryType).includes(
|
||||
xtreamId
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -371,16 +359,18 @@ export class ParentalLockService {
|
||||
}
|
||||
return (
|
||||
this.active() &&
|
||||
this.lockedStalkerIds(playlistId, categoryType).includes(
|
||||
String(categoryId)
|
||||
)
|
||||
(this.locks.unreadable() ||
|
||||
this.lockedStalkerIds(playlistId, categoryType).includes(
|
||||
String(categoryId)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
|
||||
return (
|
||||
this.active() &&
|
||||
this.lockedGroupTitles(playlistId).includes(groupTitle)
|
||||
(this.locks.unreadable() ||
|
||||
this.lockedGroupTitles(playlistId).includes(groupTitle))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,15 +379,8 @@ export class ParentalLockService {
|
||||
categoryType: ParentalLockXtreamCategoryType,
|
||||
xtreamIds: number[]
|
||||
): Promise<boolean> {
|
||||
const next = withXtreamLocks(
|
||||
this.locksFor(playlistId),
|
||||
categoryType,
|
||||
xtreamIds
|
||||
);
|
||||
if (!(await this.persistPlaylistLocks(playlistId, next))) {
|
||||
return false;
|
||||
}
|
||||
return this.stampXtreamLocks(playlistId, [categoryType]);
|
||||
await this.initialize();
|
||||
return this.locks.setXtreamLocks(playlistId, categoryType, xtreamIds);
|
||||
}
|
||||
|
||||
async setStalkerLocks(
|
||||
@@ -405,13 +388,11 @@ export class ParentalLockService {
|
||||
categoryType: ParentalLockStalkerCategoryType,
|
||||
categoryIds: string[]
|
||||
): Promise<boolean> {
|
||||
return this.persistPlaylistLocks(
|
||||
await this.initialize();
|
||||
return this.locks.setStalkerLocks(
|
||||
playlistId,
|
||||
withStalkerLocks(
|
||||
this.locksFor(playlistId),
|
||||
categoryType,
|
||||
categoryIds
|
||||
)
|
||||
categoryType,
|
||||
categoryIds
|
||||
);
|
||||
}
|
||||
|
||||
@@ -419,10 +400,8 @@ export class ParentalLockService {
|
||||
playlistId: string,
|
||||
groupTitles: string[]
|
||||
): Promise<boolean> {
|
||||
return this.persistPlaylistLocks(
|
||||
playlistId,
|
||||
withM3uLocks(this.locksFor(playlistId), groupTitles)
|
||||
);
|
||||
await this.initialize();
|
||||
return this.locks.setM3uLocks(playlistId, groupTitles);
|
||||
}
|
||||
|
||||
/** Backup restore: replaces every lock of one playlist. */
|
||||
@@ -430,53 +409,19 @@ export class ParentalLockService {
|
||||
playlistId: string,
|
||||
locks: ParentalLockPlaylistLocks
|
||||
): Promise<boolean> {
|
||||
if (!(await this.persistPlaylistLocks(playlistId, locks))) {
|
||||
return false;
|
||||
}
|
||||
return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES);
|
||||
await this.initialize();
|
||||
return this.locks.replacePlaylistLocks(playlistId, locks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-stamps the Electron `categories.locked` index for a playlist from
|
||||
* the store, e.g. after a refresh recreated the rows.
|
||||
*/
|
||||
async stampXtreamLocks(
|
||||
stampXtreamLocks(
|
||||
playlistId: string,
|
||||
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES
|
||||
categoryTypes?: readonly ParentalLockXtreamCategoryType[]
|
||||
): Promise<boolean> {
|
||||
if (!this.runtime.supportsXtreamSqliteDataSource) {
|
||||
return true;
|
||||
}
|
||||
let success = true;
|
||||
for (const categoryType of categoryTypes) {
|
||||
success =
|
||||
(await this.databaseService.setCategoryLocks(
|
||||
playlistId,
|
||||
categoryType,
|
||||
this.lockedXtreamIds(playlistId, categoryType)
|
||||
)) && success;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private async persistPlaylistLocks(
|
||||
playlistId: string,
|
||||
locks: ParentalLockPlaylistLocks
|
||||
): Promise<boolean> {
|
||||
await this.initialize();
|
||||
const normalized = normalizeParentalLockPlaylistLocks(locks);
|
||||
const next: ParentalLockStore = { ...this.lockStore() };
|
||||
if (isParentalLockPlaylistLocksEmpty(normalized)) {
|
||||
delete next[playlistId];
|
||||
} else {
|
||||
next[playlistId] = normalized;
|
||||
}
|
||||
if (!(await this.storage.writeLocks(next))) {
|
||||
return false;
|
||||
}
|
||||
this.lockStore.set(next);
|
||||
this.versionState.update((value) => value + 1);
|
||||
return true;
|
||||
return this.locks.stampXtreamLocks(playlistId, categoryTypes);
|
||||
}
|
||||
|
||||
private async storePin(pin: string): Promise<boolean> {
|
||||
|
||||
@@ -48,6 +48,22 @@ export const PARENTAL_LOCK_XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCat
|
||||
export const PARENTAL_LOCK_STALKER_CATEGORY_TYPES: readonly ParentalLockStalkerCategoryType[] =
|
||||
['itv', 'vod', 'series', 'radio'];
|
||||
|
||||
/**
|
||||
* A "withheld categories" set that withholds EVERY category. Used while the
|
||||
* lock is active but the lock store could not be read: consumers keep their
|
||||
* `size`/`has` checks and simply find nothing they may show.
|
||||
*/
|
||||
export const ALL_CATEGORIES_WITHHELD: ReadonlySet<string> = Object.freeze(
|
||||
new (class extends Set<string> {
|
||||
override has(): boolean {
|
||||
return true;
|
||||
}
|
||||
override get size(): number {
|
||||
return 1;
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
export function createEmptyParentalLockPlaylistLocks(): ParentalLockPlaylistLocks {
|
||||
return { xtream: [], stalker: [], m3u: [] };
|
||||
}
|
||||
|
||||
+10
-1
@@ -6,6 +6,8 @@ import {
|
||||
NavigationEnd,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
|
||||
@@ -126,6 +128,11 @@ describe('ChannelListContainerComponent', () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ChannelListContainerComponent],
|
||||
providers: [
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
{
|
||||
provide: EpgService,
|
||||
useValue: epgService,
|
||||
@@ -297,7 +304,9 @@ describe('ChannelListContainerComponent', () => {
|
||||
});
|
||||
epgService.getCurrentProgramsForChannels
|
||||
.mockReturnValueOnce(first.asObservable())
|
||||
.mockReturnValueOnce(of(new Map([['guide-news', program('Fresh')]])));
|
||||
.mockReturnValueOnce(
|
||||
of(new Map([['guide-news', program('Fresh')]]))
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
const channels = [
|
||||
|
||||
+25
-3
@@ -19,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
foldSearchText,
|
||||
Channel,
|
||||
EpgProgram,
|
||||
@@ -119,6 +121,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly store = inject(Store);
|
||||
private readonly router = inject(Router);
|
||||
@@ -343,6 +347,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
if (!playlistId || !this.parentalLock.active()) {
|
||||
return new Set<string>();
|
||||
}
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
return new Set(this.parentalLock.lockedGroupTitles(playlistId));
|
||||
});
|
||||
/**
|
||||
@@ -585,12 +592,27 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
onLockedGroupTitlesChanged(lockedGroupTitles: string[]): void {
|
||||
async onLockedGroupTitlesChanged(
|
||||
lockedGroupTitles: string[]
|
||||
): Promise<void> {
|
||||
const playlistId = this.lockPlaylistId();
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
void this.parentalLock.setM3uLocks(playlistId, lockedGroupTitles);
|
||||
// The dialog has closed by now, so a failed write (storage full or
|
||||
// unavailable, app-state IPC failure) must be reported here, or the
|
||||
// user relocks believing the chosen groups are protected.
|
||||
const saved = await this.parentalLock.setM3uLocks(
|
||||
playlistId,
|
||||
lockedGroupTitles
|
||||
);
|
||||
if (!saved) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onHiddenGroupTitlesChanged(hiddenGroupTitles: string[]): void {
|
||||
|
||||
Reference in new issue
Block a user