diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index a5cd5c03a..ebfbb2a74 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -38,6 +38,7 @@ const handlers = new Map(); const mockStoreGet = jest.fn(); const mockStoreSet = jest.fn(); const mockUpdateSettings = jest.fn(); +const mockApplyParentalLockState = jest.fn(); const mockIpcHandle = jest.fn( (channel: string, handler: SettingsUpdateHandler): void => { handlers.set(channel, handler); @@ -61,12 +62,18 @@ jest.mock('../services/store.service', () => ({ PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD, VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, + PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED', store: { get: mockStoreGet, set: mockStoreSet, }, })); +jest.mock('./parental-lock.events', () => ({ + applyParentalLockState: (...args: unknown[]) => + mockApplyParentalLockState(...args), +})); + jest.mock('../server/http-server', () => ({ httpServer: { updateSettings: mockUpdateSettings, @@ -84,6 +91,7 @@ describe('SETTINGS_UPDATE', () => { mockStoreGet.mockReset(); mockStoreSet.mockReset(); mockUpdateSettings.mockReset(); + mockApplyParentalLockState.mockReset().mockResolvedValue(undefined); mockStoreGet.mockImplementation( (_key: string, fallbackValue: unknown): unknown => fallbackValue ); @@ -136,6 +144,35 @@ describe('SETTINGS_UPDATE', () => { ); }); + it('mirrors the parental lock switch and releases the worker only on switch-off', () => { + // Off → on: persist; the renderer announces its own live state. + settingsUpdateHandler({}, { parentalLockEnabled: true }); + expect(mockStoreSet).toHaveBeenCalledWith( + 'PARENTAL_LOCK_ENABLED', + true + ); + expect(mockApplyParentalLockState).not.toHaveBeenCalled(); + + // An ordinary save carrying the unchanged flag must not re-lock a + // worker the renderer believes is unlocked. + mockStoreGet.mockImplementation((key: string, fallback: unknown) => + key === 'PARENTAL_LOCK_ENABLED' ? true : fallback + ); + settingsUpdateHandler( + {}, + { parentalLockEnabled: true, showCaptions: true } + ); + expect(mockApplyParentalLockState).not.toHaveBeenCalled(); + + // On → off: release at once. + settingsUpdateHandler({}, { parentalLockEnabled: false }); + expect(mockStoreSet).toHaveBeenCalledWith( + 'PARENTAL_LOCK_ENABLED', + false + ); + expect(mockApplyParentalLockState).toHaveBeenCalledWith(false); + }); + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { settingsUpdateHandler( {}, diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index a070a6f5e..083cb4a93 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -45,13 +45,19 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { } // Mirrored so the database worker and a reloaded renderer start locked - // whenever the feature is on. Turning the feature off also releases the - // worker filter immediately; turning it on locks it — the renderer - // announces an explicit unlock through PARENTAL_LOCK_SET_STATE. + // whenever the feature is on. The LIVE enforcement state is the + // renderer's to announce through PARENTAL_LOCK_SET_STATE: every full + // settings save carries this flag unchanged, so applying it here would + // silently re-lock the worker under a renderer that still shows + // "unlocked". Only a switch-off releases the worker at once — nothing + // may stay withheld once the feature is gone. if (arg.parentalLockEnabled !== undefined) { const enabled = arg.parentalLockEnabled === true; + const wasEnabled = store.get(PARENTAL_LOCK_ENABLED, false) === true; store.set(PARENTAL_LOCK_ENABLED, enabled); - void applyParentalLockState(enabled).catch(() => undefined); + if (wasEnabled && !enabled) { + void applyParentalLockState(false).catch(() => undefined); + } } if (arg.mpvPlayerArguments !== undefined) { diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 0197cf88e..8e679126d 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -82,7 +82,13 @@ refresh deletes and re-inserts the rows. The renderer reports `active` to the main process over `PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`), -which forwards it to the worker. Like the playback keep-awake vote, the +which forwards it to the worker. `SETTINGS_UPDATE` only persists the +`parentalLockEnabled` mirror; it never derives the live state from a settings +save (every save carries the flag unchanged and would re-lock the worker +under a renderer that shows "unlocked"). The one exception is a switch-off, +which releases the worker at once. `requestUnlock()` awaits `initialize()` +before it can answer "not active", so a slow settings load cannot open a +gate. Like the playback keep-awake vote, the renderer's word does not outlive its page: on reload, main-frame navigation or a dead render process the flag falls back to the mirrored `PARENTAL_LOCK_ENABLED` setting, i.e. locked while the feature is on. The @@ -110,9 +116,11 @@ locked default. `parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series `:categoryId` routes prompt for the PIN when a locked category is reached by URL — bookmark, typed address, stale link — and redirect to the section - root on refusal. Electron routes carry SQLite row ids, so the Xtream guard - maps them through the unfiltered category read; Stalker routes already - carry the genre id. + root on refusal. Detail routes also resolve the ITEM's own category through + `getContentByXtreamId`, so a locked movie paired with an unlocked category + id in the URL is still refused. 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:** genres are stored unfiltered; `getCategoryResource` filters them, `getAllCategoriesForSelectedType` is the raw list for the lock dialog. `itvFullChannelList` and the content loader drop rows whose diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts index 372cb8c74..d6a6c3ea9 100644 --- a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts @@ -6,6 +6,7 @@ import { RouterStateSnapshot, UrlTree, } from '@angular/router'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { DatabaseService, ParentalLockService, @@ -13,12 +14,16 @@ import { } from '@iptvnator/services'; import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard'; -function createRoute(playlistId: string, categoryId: string) { +function createRoute( + playlistId: string, + categoryId: string, + itemParams: Record = {} +) { const parent = { paramMap: convertToParamMap({ id: playlistId }), } as ActivatedRouteSnapshot; const route = { - paramMap: convertToParamMap({ categoryId }), + paramMap: convertToParamMap({ categoryId, ...itemParams }), } as ActivatedRouteSnapshot; Object.defineProperty(route, 'pathFromRoot', { value: [parent, route], @@ -36,6 +41,7 @@ describe('parentalLockXtreamCategoryGuard', () => { let databaseService: { getAllXtreamCategories: jest.Mock }; let runtime: { supportsXtreamSqliteDataSource: boolean }; let router: { createUrlTree: jest.Mock }; + let dataSource: { getContentByXtreamId: jest.Mock }; beforeEach(() => { parentalLock = { @@ -53,8 +59,12 @@ describe('parentalLockXtreamCategoryGuard', () => { }; runtime = { supportsXtreamSqliteDataSource: true }; router = { createUrlTree: jest.fn(() => ({}) as UrlTree) }; + dataSource = { + getContentByXtreamId: jest.fn().mockResolvedValue(null), + }; TestBed.configureTestingModule({ providers: [ + { provide: XTREAM_DATA_SOURCE, useValue: dataSource }, { provide: ParentalLockService, useValue: parentalLock }, { provide: DatabaseService, useValue: databaseService }, { provide: RuntimeCapabilitiesService, useValue: runtime }, @@ -63,15 +73,50 @@ describe('parentalLockXtreamCategoryGuard', () => { }); }); - function run(section: 'live' | 'vod' | 'series', categoryId: string) { + function run( + section: 'live' | 'vod' | 'series', + categoryId: string, + itemParams: Record = {} + ) { return TestBed.runInInjectionContext(() => parentalLockXtreamCategoryGuard(section)( - createRoute('playlist-1', categoryId), + createRoute('playlist-1', categoryId, itemParams), {} as RouterStateSnapshot ) ); } + it('checks a detail item against its own category, not the one in the URL', async () => { + // Category row 12 (provider 900) is locked; row 13 (provider 901) is + // not. The URL names the unlocked one but the movie belongs to the + // locked one. + databaseService.getAllXtreamCategories.mockResolvedValue([ + { id: 12, xtream_id: 900, type: 'movies' }, + { id: 13, xtream_id: 901, type: 'movies' }, + ]); + parentalLock.isXtreamCategoryLocked.mockImplementation( + (_playlist: string, _type: string, xtreamId: number) => + xtreamId === 900 + ); + dataSource.getContentByXtreamId.mockResolvedValue({ category_id: 12 }); + + const result = await run('vod', '13', { vodId: '555' }); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 555, + 'playlist-1', + 'movie' + ); + expect(parentalLock.requestUnlock).toHaveBeenCalled(); + expect(result).not.toBe(true); + + // A movie in an unlocked category passes without a prompt. + parentalLock.requestUnlock.mockClear(); + dataSource.getContentByXtreamId.mockResolvedValue({ category_id: 13 }); + await expect(run('vod', '13', { vodId: '556' })).resolves.toBe(true); + expect(parentalLock.requestUnlock).not.toHaveBeenCalled(); + }); + it('passes through while the lock is inactive without touching the database', async () => { parentalLock.active.mockReturnValue(false); diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts index 045d46c79..07d5749ae 100644 --- a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts @@ -5,6 +5,7 @@ import { Router, UrlTree, } from '@angular/router'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { DatabaseService, ParentalLockService, @@ -39,8 +40,10 @@ function findPlaylistId(route: ActivatedRouteSnapshot): string | null { * not through the (already filtered) category rail. The lock is keyed by the * provider category id; in Electron the route carries the SQLite row id, so * the guard maps it through the unfiltered category read (which only the - * guard sees). A locked category prompts for the PIN; a refusal redirects - * to the section root instead of rendering the category or its detail. + * guard sees). Detail routes additionally check the ITEM's own category: a + * locked movie paired with an unlocked category id in the URL is still a + * locked movie. A locked target prompts for the PIN; a refusal redirects to + * the section root instead of rendering the category or its detail. */ export function parentalLockXtreamCategoryGuard( section: XtreamCategorySection @@ -50,6 +53,7 @@ export function parentalLockXtreamCategoryGuard( const router = inject(Router); const databaseService = inject(DatabaseService); const runtime = inject(RuntimeCapabilitiesService); + const dataSource = inject(XTREAM_DATA_SOURCE); await parentalLock.initialize(); if (!parentalLock.active()) { @@ -57,37 +61,55 @@ export function parentalLockXtreamCategoryGuard( } const playlistId = findPlaylistId(route); - const rawCategoryId = Number(route.paramMap.get('categoryId')); - if (!playlistId || !Number.isFinite(rawCategoryId)) { + if (!playlistId) { return true; } const categoryType = CATEGORY_TYPE_BY_SECTION[section]; - let xtreamId = rawCategoryId; - if (runtime.supportsXtreamSqliteDataSource) { - const rows = await databaseService.getAllXtreamCategories( + const rows = runtime.supportsXtreamSqliteDataSource + ? await databaseService.getAllXtreamCategories( + playlistId, + categoryType + ) + : null; + // Electron routes and content rows carry SQLite category row ids; + // the PWA carries provider ids everywhere. + const toProviderId = (categoryId: number): number | null => + rows === null + ? categoryId + : (rows.find((candidate) => candidate.id === categoryId) + ?.xtream_id ?? null); + const isLocked = (categoryId: number): boolean => { + const xtreamId = toProviderId(categoryId); + return ( + xtreamId !== null && + parentalLock.isXtreamCategoryLocked( + playlistId, + categoryType, + xtreamId + ) + ); + }; + + const routeCategoryId = Number(route.paramMap.get('categoryId')); + let locked = + Number.isFinite(routeCategoryId) && isLocked(routeCategoryId); + + const itemId = Number( + route.paramMap.get('vodId') ?? route.paramMap.get('serialId') + ); + if (!locked && section !== 'live' && Number.isFinite(itemId)) { + const item = await dataSource.getContentByXtreamId( + itemId, playlistId, - categoryType + section === 'vod' ? 'movie' : 'series' ); - const row = rows.find( - (candidate) => candidate.id === rawCategoryId - ); - if (!row) { - return true; - } - xtreamId = row.xtream_id; + const itemCategoryId = Number(item?.category_id); + locked = + Number.isFinite(itemCategoryId) && isLocked(itemCategoryId); } - if ( - !parentalLock.isXtreamCategoryLocked( - playlistId, - categoryType, - xtreamId - ) - ) { - return true; - } - if (await parentalLock.requestUnlock()) { + if (!locked || (await parentalLock.requestUnlock())) { return true; } return router.createUrlTree([ 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 1a1f9cd0a..5c6470b32 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 @@ -156,6 +156,33 @@ describe('ParentalLockService', () => { expect(setParentalLockState).toHaveBeenLastCalledWith(true); }); + it('does not open a gate before the settings have loaded', async () => { + storage.pinHash = await hashParentalLockPin('1234'); + prompt.requestPin.mockResolvedValue(null); + let releaseSettings!: () => void; + const settingsStore = TestBed.inject(SettingsStore) as unknown as { + loadSettings: jest.Mock; + }; + settingsStore.loadSettings.mockImplementation( + () => + new Promise((resolve) => { + releaseSettings = () => { + parentalLockEnabled.set(true); + resolve(); + }; + }) + ); + const service = TestBed.inject(ParentalLockService); + void service.initialize(); + + // Enabled is still the default false here: the answer must wait for + // the load instead of reading that as "feature off". + const gate = service.requestUnlock(); + releaseSettings(); + await expect(gate).resolves.toBe(false); + expect(prompt.requestPin).toHaveBeenCalledTimes(1); + }); + it('stays locked when the prompt is dismissed', async () => { storage.pinHash = await hashParentalLockPin('1234'); parentalLockEnabled.set(true); 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 1d1376408..9a3616fde 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -160,14 +160,17 @@ export class ParentalLockService { * is unlocked afterwards (already unlocked, feature off, or the PIN was * accepted). Concurrent callers share one prompt. */ - requestUnlock( + async requestUnlock( options: Pick< ParentalLockPromptRequest, 'titleKey' | 'descriptionKey' > = {} ): Promise { + // `enabled` is false until the settings have loaded; deciding "not + // active" before that would open a gate during a slow startup. + await this.initialize(); if (!this.active()) { - return Promise.resolve(true); + return true; } if (this.pendingUnlock) { return this.pendingUnlock; @@ -181,7 +184,6 @@ export class ParentalLockService { private async promptForUnlock( options: Pick ): Promise { - await this.initialize(); const hash = this.pinHash(); if (!this.prompt || !hash) { return false;