From 8ad8907ef9ed9304ef13626ebff9d66e4f3f886a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 12:56:37 +0200 Subject: [PATCH] fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts A backup merge now asks for the PIN again right before it replaces a playlist's locks when the app relocked during the import, instead of relying on the answer given at the start. The wrong-PIN count and the 30-second pause move from the dialog into the lock service, so dismissing and reopening the prompt no longer resets them. Co-Authored-By: Claude Opus 5.5 --- .../services/parental-lock-prompt.service.ts | 1 + docs/architecture/parental-lock.md | 11 +++- .../parental-lock-prompt.token.ts | 6 +++ .../parental-lock/parental-lock.service.ts | 5 ++ .../src/lib/playlist-backup.service.ts | 14 +++++ ...list-backup.service.xtream-restore.spec.ts | 39 ++++++++++++++ libs/shared/interfaces/src/index.ts | 1 + .../parental-lock-pin-throttle.util.spec.ts | 34 ++++++++++++ .../lib/parental-lock-pin-throttle.util.ts | 40 ++++++++++++++ ...parental-lock-pin-dialog.component.spec.ts | 53 +++++++++++++++++++ .../parental-lock-pin-dialog.component.ts | 52 +++++++++++------- 11 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.ts create mode 100644 libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts diff --git a/apps/web/src/app/services/parental-lock-prompt.service.ts b/apps/web/src/app/services/parental-lock-prompt.service.ts index 4f0ce95e1..4c4bec098 100644 --- a/apps/web/src/app/services/parental-lock-prompt.service.ts +++ b/apps/web/src/app/services/parental-lock-prompt.service.ts @@ -41,6 +41,7 @@ export class AppParentalLockPromptService implements ParentalLockPrompt { { mode: request.mode, verify: request.verify, + throttle: request.throttle, titleKey: request.titleKey, descriptionKey: request.descriptionKey, } diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 1b31fb7da..bf90bd3a0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -89,6 +89,10 @@ refresh deletes and re-inserts the rows. reached through the `PARENTAL_LOCK_PROMPT` token, provided by the app (`AppParentalLockPromptService` → `ParentalLockPinDialogComponent` in `libs/ui/components`), so the data-access lib stays free of UI. + Five wrong PINs pause the prompt for 30 seconds. The count and the + pause live in the service (`createParentalLockPinThrottle`, handed to + every unlock prompt), so dismissing the dialog and opening it again + does not reset them. The renderer reports `active` to the main process over `PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`), @@ -431,7 +435,12 @@ never runs against the empty fail-closed snapshot. A backup carrying lock lists replaces the matching playlists' locks, possibly with an emptier set, so the import asks for the PIN first (after -the file was chosen) and aborts when it is refused. Locks are restored LAST +the file was chosen) and aborts when it is refused. That answer can go +stale during a long import (idle relock, "Lock now"), so each merge asks +again right before it replaces locks if the app has relocked; a refusal +fails that entry and keeps its previous locks. A newly created playlist +starts without locks, so its restore can only add some and is not asked +again. Locks are restored LAST for each entry, after the Xtream data restore, so a failed merge leaves the playlist's previous locks in place. M3U group titles travel verbatim (`normalizeParentalLockGroupTitles`, exact diff --git a/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts b/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts index e1840bfb8..2a3bdf3e8 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts @@ -1,4 +1,5 @@ import { InjectionToken } from '@angular/core'; +import type { ParentalLockPinThrottle } from '@iptvnator/shared/interfaces'; export type ParentalLockPromptMode = 'unlock' | 'set'; @@ -10,6 +11,11 @@ export interface ParentalLockPromptRequest { * asking on a mismatch and applies its own attempt cooldown. */ verify?: (pin: string) => Promise; + /** + * Unlock only: the wrong-PIN count and cooldown, owned by the lock + * service so they survive the prompt being dismissed and reopened. + */ + throttle?: ParentalLockPinThrottle; /** Optional translation key overriding the mode's default title. */ titleKey?: string; /** Optional translation key overriding the mode's default description. */ 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 d3084fa87..e631b96a4 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -7,6 +7,7 @@ import { untracked, } from '@angular/core'; import { + createParentalLockPinThrottle, hashParentalLockPin, normalizeParentalLockRelockMinutes, ParentalLockPlaylistLocks, @@ -55,6 +56,8 @@ export class ParentalLockService { private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true }); private readonly unlockedState = signal(false); + /** Shared by every unlock prompt: a reopened dialog keeps the cooldown. */ + private readonly pinThrottle = createParentalLockPinThrottle(); private readonly pinHash = signal(null); /** The PIN hash could not be read; retried before PIN-protected steps. */ private readonly pinUnreadable = signal(false); @@ -219,6 +222,7 @@ export class ParentalLockService { const pin = await this.prompt.requestPin({ mode: 'unlock', verify: (candidate) => verifyParentalLockPin(candidate, hash), + throttle: this.pinThrottle, ...options, }); if (pin === null) { @@ -320,6 +324,7 @@ export class ParentalLockService { const pin = await this.prompt.requestPin({ mode: 'unlock', verify: (candidate) => verifyParentalLockPin(candidate, hash), + throttle: this.pinThrottle, titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE', descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION', }); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 92d2fafc5..3cf084785 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -1186,6 +1186,20 @@ export class PlaylistBackupService { }; } + // A merge REPLACES the playlist's locks, possibly with fewer. The PIN + // asked at the start may be stale by now (idle relock or "Lock now" + // during a long import): ask again if the session relocked. A new + // playlist starts without locks, so its restore can only add some. + if ( + next && + isMerge && + !staleOnNewId && + !(await this.parentalLock.requestUnlock()) + ) { + throw new PlaylistBackupError( + `Restoring the parental locks for "${playlistId}" needs the parental PIN: the app locked again during the restore.` + ); + } if ( next && !(await this.parentalLock.replacePlaylistLocks(playlistId, next)) diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index 9d21398a9..3917e418b 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -195,6 +195,45 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { ).not.toHaveBeenCalled(); }); + it('asks again before a merge replaces locks once the app relocked mid-import', async () => { + const collaborators = createRestoreCollaborators(); + // Accepted at the start, refused after an idle relock or "Lock now" + // landed while the import was running. + const requestUnlock = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValue(false); + const replacePlaylistLocks = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + parentalLock: { + initialize: jest.fn().mockResolvedValue(undefined), + locksReadable: jest.fn(() => true), + ensureLocksReadable: jest.fn().mockResolvedValue(true), + requestUnlock, + locksFor: jest.fn(() => ({ + xtream: [{ categoryType: 'live', xtreamId: 1 }], + stalker: [], + m3u: [], + })), + replacePlaylistLocks, + }, + }); + const manifest = createXtreamManifest([]); + ( + manifest.playlists[0].userState as { lockedCategories?: unknown } + ).lockedCategories = []; + + const summary = await service.importBackup(JSON.stringify(manifest)); + + expect(requestUnlock).toHaveBeenCalledTimes(2); + expect(replacePlaylistLocks).not.toHaveBeenCalled(); + expect(summary).toEqual( + expect.objectContaining({ merged: 0, failed: 1 }) + ); + expect(summary.errors[0]).toMatch(/locked again/); + }); + it('rejects a damaged parental lock list instead of erasing the persisted locks', async () => { const collaborators = createRestoreCollaborators(); const service = createPlaylistBackupService(collaborators); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index dfb3efeff..29853f3e1 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -29,6 +29,7 @@ export * from './lib/language.enum'; export * from './lib/m3u-favorite-channel.interface'; export * from './lib/parental-lock.util'; export * from './lib/parental-lock-pin.util'; +export * from './lib/parental-lock-pin-throttle.util'; export * from './lib/parsed-playlist.interface'; export * from './lib/performance-phase.interface'; export * from './lib/playback-position.interface'; diff --git a/libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.spec.ts b/libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.spec.ts new file mode 100644 index 000000000..ff738e997 --- /dev/null +++ b/libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.spec.ts @@ -0,0 +1,34 @@ +import { + createParentalLockPinThrottle, + PARENTAL_LOCK_PIN_COOLDOWN_MS, + PARENTAL_LOCK_PIN_MAX_ATTEMPTS, +} from './parental-lock-pin-throttle.util'; + +describe('createParentalLockPinThrottle', () => { + it('starts a cooldown after the maximum failures and ends it on time', () => { + let clock = 1_000; + const throttle = createParentalLockPinThrottle(() => clock); + for (let i = 1; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) { + expect(throttle.recordFailure()).toBe(false); + } + expect(throttle.cooldownUntil()).toBe(0); + + expect(throttle.recordFailure()).toBe(true); + expect(throttle.cooldownUntil()).toBe( + 1_000 + PARENTAL_LOCK_PIN_COOLDOWN_MS + ); + + clock += PARENTAL_LOCK_PIN_COOLDOWN_MS; + expect(throttle.cooldownUntil()).toBe(0); + }); + + it('clears the count and the cooldown after the right PIN', () => { + const throttle = createParentalLockPinThrottle(() => 0); + for (let i = 1; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) { + throttle.recordFailure(); + } + throttle.recordSuccess(); + expect(throttle.recordFailure()).toBe(false); + expect(throttle.cooldownUntil()).toBe(0); + }); +}); diff --git a/libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.ts b/libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.ts new file mode 100644 index 000000000..476421587 --- /dev/null +++ b/libs/shared/interfaces/src/lib/parental-lock-pin-throttle.util.ts @@ -0,0 +1,40 @@ +/** Failed PIN attempts before the prompt pauses for the cooldown. */ +export const PARENTAL_LOCK_PIN_MAX_ATTEMPTS = 5; +export const PARENTAL_LOCK_PIN_COOLDOWN_MS = 30_000; + +/** + * Wrong-PIN bookkeeping that outlives a single PIN dialog. Kept by the lock + * service and handed to every unlock prompt, so dismissing the dialog during + * a cooldown and opening it again neither resets the count nor the pause. + */ +export interface ParentalLockPinThrottle { + /** Epoch milliseconds until which no PIN may be tried; 0 when none. */ + cooldownUntil(): number; + /** Records a wrong PIN; true when this failure starts a cooldown. */ + recordFailure(): boolean; + /** Clears the count after the right PIN. */ + recordSuccess(): void; +} + +export function createParentalLockPinThrottle( + now: () => number = Date.now +): ParentalLockPinThrottle { + let failedAttempts = 0; + let cooldownUntil = 0; + return { + cooldownUntil: () => (cooldownUntil > now() ? cooldownUntil : 0), + recordFailure: () => { + failedAttempts += 1; + if (failedAttempts < PARENTAL_LOCK_PIN_MAX_ATTEMPTS) { + return false; + } + failedAttempts = 0; + cooldownUntil = now() + PARENTAL_LOCK_PIN_COOLDOWN_MS; + return true; + }, + recordSuccess: () => { + failedAttempts = 0; + cooldownUntil = 0; + }, + }; +} diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts new file mode 100644 index 000000000..437a2c271 --- /dev/null +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts @@ -0,0 +1,53 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { + createParentalLockPinThrottle, + PARENTAL_LOCK_PIN_MAX_ATTEMPTS, +} from '@iptvnator/shared/interfaces'; +import { ParentalLockPinDialogComponent } from './parental-lock-pin-dialog.component'; + +describe('ParentalLockPinDialogComponent cooldown', () => { + const throttle = createParentalLockPinThrottle(); + const verify = jest.fn(async () => false); + + function openDialog(): ParentalLockPinDialogComponent { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + ParentalLockPinDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { mode: 'unlock', verify, throttle }, + }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + ], + }); + const fixture = TestBed.createComponent(ParentalLockPinDialogComponent); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('keeps the cooldown when the prompt is dismissed and opened again', async () => { + const first = openDialog(); + for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) { + first.onPinInput('0000'); + await first.submit(); + } + expect(first.inCooldown()).toBe(true); + expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS); + + const reopened = openDialog(); + expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); + reopened.onPinInput('0000'); + await reopened.submit(); + + expect(reopened.inCooldown()).toBe(true); + expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS); + }); +}); diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index e3f7257c6..e9eafbfd2 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -20,7 +20,9 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; import { + createParentalLockPinThrottle, isValidParentalLockPin, + ParentalLockPinThrottle, PARENTAL_LOCK_PIN_MAX_LENGTH, PARENTAL_LOCK_PIN_MIN_LENGTH, } from '@iptvnator/shared/interfaces'; @@ -31,14 +33,16 @@ export interface ParentalLockPinDialogData { mode: ParentalLockPinDialogMode; /** Unlock only: whether the typed PIN is the right one. */ verify?: (pin: string) => Promise; + /** + * Unlock only: wrong-PIN count and cooldown owned by the caller, so a + * dismissed and reopened prompt keeps them. A local one is used when + * absent. + */ + throttle?: ParentalLockPinThrottle; titleKey?: string; descriptionKey?: string; } -/** Failed attempts before the prompt pauses for {@link COOLDOWN_MS}. */ -const MAX_ATTEMPTS_BEFORE_COOLDOWN = 5; -const COOLDOWN_MS = 30_000; - /** * PIN prompt for the parental lock. Pure UI: the caller supplies `verify` * for the unlock mode and receives the accepted PIN (or `undefined` when @@ -75,7 +79,8 @@ export class ParentalLockPinDialogComponent { readonly error = signal(null); readonly shake = signal(false); readonly cooldownUntil = signal(0); - private failedAttempts = 0; + private readonly throttle = + this.data.throttle ?? createParentalLockPinThrottle(); private cooldownTimer: number | null = null; readonly isSetMode = computed(() => this.data.mode === 'set'); @@ -102,6 +107,15 @@ export class ParentalLockPinDialogComponent { (!this.isSetMode() || this.confirmation() === this.pin()) ); + constructor() { + // Reopened during a cooldown: the pause carries on where it was. + const until = this.throttle.cooldownUntil(); + if (until > 0) { + this.startCooldown(until); + this.error.set('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); + } + } + static open( dialog: MatDialog, data: ParentalLockPinDialogData @@ -144,14 +158,13 @@ export class ParentalLockPinDialogComponent { try { const accepted = (await this.data.verify?.(pin)) === true; if (accepted) { + this.throttle.recordSuccess(); this.dialogRef.close(pin); return; } - this.failedAttempts += 1; this.pin.set(''); - if (this.failedAttempts >= MAX_ATTEMPTS_BEFORE_COOLDOWN) { - this.failedAttempts = 0; - this.startCooldown(); + if (this.throttle.recordFailure()) { + this.startCooldown(this.throttle.cooldownUntil()); this.fail('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); } else { this.fail('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN'); @@ -172,17 +185,20 @@ export class ParentalLockPinDialogComponent { window.setTimeout(() => this.shake.set(false), 400); } - private startCooldown(): void { - this.cooldownUntil.set(Date.now() + COOLDOWN_MS); + private startCooldown(until: number): void { + this.cooldownUntil.set(until); if (this.cooldownTimer !== null) { window.clearTimeout(this.cooldownTimer); } - this.cooldownTimer = window.setTimeout(() => { - this.cooldownTimer = null; - // `inCooldown` compares against the clock only when a signal it - // reads changes; resetting the deadline is that change. - this.cooldownUntil.set(0); - this.error.set(null); - }, COOLDOWN_MS); + this.cooldownTimer = window.setTimeout( + () => { + this.cooldownTimer = null; + // `inCooldown` compares against the clock only when a signal it + // reads changes; resetting the deadline is that change. + this.cooldownUntil.set(0); + this.error.set(null); + }, + Math.max(0, until - Date.now()) + ); } }