mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
451b373b1d
commit
8ad8907ef9
11 files changed
+237
-19
No files matched your search
@@ -41,6 +41,7 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
{
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
throttle: request.throttle,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean>;
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
@@ -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<string | null>(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',
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+53
@@ -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);
|
||||
});
|
||||
});
|
||||
+34
-18
@@ -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<boolean>;
|
||||
/**
|
||||
* 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<string | null>(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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user