From 8323314853925f7d2a805dcb6d649f2182714838 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 22:46:45 +0200 Subject: [PATCH] fix(sources): case-insensitive Xtream path markers; no removal during a refresh - Strip and redact the account segments after `/LIVE/`, `/Movie/` or an encoded marker such as `/%6Cive/`, as after `/live/`. The display-name check also treats an Xtream path password as a URL secret. - A source cannot be removed while a switcher refresh clears and refills it: the removal service ignores the request, the switcher disables Remove, and the Sources page shows the refresh as busy. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/iptvnator-ui-guidelines.md | 5 ++- .../playlist-switcher-panel.component.html | 1 + .../playlist-switcher-panel.component.spec.ts | 41 ++++++++++++++++++- .../playlist-switcher-panel.component.ts | 5 +++ .../recent-playlists.component.html | 2 +- .../recent-playlists.component.spec.ts | 32 +++++++++++++++ .../recent-playlists.component.ts | 10 +++++ .../source-removal.service.spec.ts | 19 +++++++++ .../source-removal/source-removal.service.ts | 9 +++- .../lib/source-row/source-row.component.html | 1 + .../lib/source-row/source-row.component.ts | 2 + .../lib/source-display/source-display.spec.ts | 7 ++++ .../src/lib/source-display/source-display.ts | 17 +++++++- .../strip-url-credentials.spec.ts | 10 +++++ .../source-display/strip-url-credentials.ts | 3 +- .../src/lib/redact-sensitive-data.spec.ts | 10 +++++ .../logging/src/lib/redact-sensitive-data.ts | 12 +++++- 17 files changed, 179 insertions(+), 7 deletions(-) diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index d7bfcf878..df1dfbe82 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1390,7 +1390,10 @@ in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`, it the focus, the toast's Undo takes the focus. A delete from the switcher closes the popover first, since its focus trap would keep the keyboard from Undo: Undo takes the focus and returns it to the trigger - (`SourceRemovalOptions.returnFocusTo`). Bulk cleanup keeps its + (`SourceRemovalOptions.returnFocusTo`). A source cannot be removed while + a refresh clears and refills it: Remove is disabled in both lists and the + service ignores it (`PlaylistRefreshActionService.isSourceBusy`), and the + Sources page shows a switcher refresh as busy. Bulk cleanup keeps its confirmation dialog. - **Side list.** Type and, on desktop, Status (Any / Responding / Not responding) combine and persist (`SourcesViewStateService`); each group diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html index 785337a66..33a443417 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html @@ -157,6 +157,7 @@ [current]="option.playlist._id === currentId()" [highlighted]="index === highlight()" [canRefresh]="canRefresh(option.playlist)" + [removable]="!isRefreshing(option.playlist)" (activated)="select(option.playlist)" (refreshRequested)="refresh(option.playlist)" (playlistInfoRequested)="openInfo(option.playlist)" diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts index 5b3a09732..b058d70df 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts @@ -51,6 +51,7 @@ describe('PlaylistSwitcherPanelComponent', () => { let selectPlaylist: jest.Mock; let remove: jest.Mock; let navigate: jest.Mock; + let refreshing: ReturnType>; const input = () => (fixture.nativeElement as HTMLElement).querySelector( @@ -82,6 +83,7 @@ describe('PlaylistSwitcherPanelComponent', () => { selectPlaylist = jest.fn(); remove = jest.fn(); navigate = jest.fn(); + refreshing = signal(null); TestBed.configureTestingModule({ imports: [ PlaylistSwitcherPanelComponent, @@ -133,7 +135,11 @@ describe('PlaylistSwitcherPanelComponent', () => { }, { provide: PlaylistRefreshActionService, - useValue: { canRefresh: () => true, refresh: jest.fn() }, + useValue: { + canRefresh: () => true, + refresh: jest.fn(), + isSourceBusy: (id: string) => refreshing() === id, + }, }, { provide: MatDialog, useValue: { open: jest.fn() } }, { provide: Router, useValue: { navigate } }, @@ -281,6 +287,39 @@ describe('PlaylistSwitcherPanelComponent', () => { trigger.remove(); }); + it('offers no Remove while a refresh clears and refills the source', async () => { + refreshing.set('bravo'); + fixture.detectChanges(); + const menuRemove = async (id: string) => { + (fixture.nativeElement as HTMLElement) + .querySelector( + `#playlist-switcher-all-${id} .source-row__more` + ) + ?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const items = Array.from( + document.querySelectorAll( + '.cdk-overlay-container [mat-menu-item]' + ) + ); + const item = items[items.length - 1]; + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape' }) + ); + return item; + }; + expect((await menuRemove('bravo')).disabled).toBe(true); + expect((await menuRemove('alpha')).disabled).toBe(false); + // Still a source you can switch to. + (fixture.nativeElement as HTMLElement) + .querySelector( + '#playlist-switcher-all-bravo .source-row' + ) + ?.click(); + expect(selectPlaylist).toHaveBeenCalled(); + }); + it('leaves out a source whose delete is pending or running', () => { pending.set(new Set(['bravo'])); fixture.detectChanges(); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts index 7737d0e09..222e4a643 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts @@ -283,6 +283,11 @@ export class PlaylistSwitcherPanelComponent { return this.refreshAction.canRefresh(playlist); } + /** A source cannot be removed while a refresh clears and refills it. */ + isRefreshing(playlist: PlaylistMeta): boolean { + return this.refreshAction.isSourceBusy(playlist._id); + } + refresh(playlist: PlaylistMeta): void { this.close(); this.refreshAction.refresh(playlist); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html index 976e86ad4..ad21653b5 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html @@ -36,7 +36,7 @@ [current]="activePlaylistId() === item._id" [draggable]="isCustomSort()" [canRefresh]="refresh.canRefresh(item)" - [refreshing]="refresh.isRefreshing(item._id)" + [refreshing]="isRefreshing(item)" [deleting]="removal.progress.isDeleting(item._id)" [busyMessage]="busyMessage(item)" [busyProgress]="busyProgress(item)" diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 509079786..d8ab3530f 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -23,6 +23,7 @@ import { SortService, } from '@iptvnator/services'; import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; import { SourceRemovalService } from '../source-removal/source-removal.service'; import { RecentPlaylistsComponent } from './recent-playlists.component'; import { SourceListRefreshService } from './source-list-refresh.service'; @@ -62,6 +63,7 @@ describe('RecentPlaylistsComponent', () => { progress: object; }; let viewState: SourcesViewStateService; + let switcherRefreshing: ReturnType>; beforeEach(() => { localStorage.clear(); @@ -74,6 +76,7 @@ describe('RecentPlaylistsComponent', () => { ['xtream', { reachability: 'bad' }], ]); pending = signal>(new Set()); + switcherRefreshing = signal(null); removal = { pendingIds: pending, remove: jest.fn(), @@ -128,6 +131,13 @@ describe('RecentPlaylistsComponent', () => { supportsXtreamSqliteDataSource: true, }, }, + { + provide: PlaylistRefreshActionService, + useValue: { + isSourceBusy: (id: string) => + switcherRefreshing() === id, + }, + }, { provide: MatDialog, useValue: { open: jest.fn() } }, { provide: Router, useValue: { navigate: jest.fn() } }, ], @@ -253,6 +263,28 @@ describe('RecentPlaylistsComponent', () => { expect(ids()).toEqual(['xtream', 'file', 'm3u']); }); + it('keeps a source busy while the switcher refreshes it', () => { + switcherRefreshing.set('xtream'); + fixture.detectChanges(); + const rows = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + 'app-source-row' + ) + ); + expect( + rows.map((row) => + row + .querySelector('.source-row') + ?.classList.contains('source-row--busy') + ) + ).toEqual([false, true, false]); + // Its menu, and Remove with it, waits for the refresh to settle. + expect( + rows[1].querySelector('.source-row__more') + ?.disabled + ).toBe(true); + }); + it('hides sources whose delete is pending', () => { pending.set(new Set(['m3u'])); expect(ids()).toEqual(['xtream', 'file']); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index aba62e66e..f249babf1 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -43,6 +43,7 @@ import { } from '@iptvnator/services'; import { PlaylistMeta, sourceStatusBucket } from '@iptvnator/shared/interfaces'; import type { PlaylistType } from '../add-playlist-menu/playlist-type'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; import { SourceRemovalService } from '../source-removal/source-removal.service'; import { SourceRowComponent } from '../source-row/source-row.component'; import { EmptyStateComponent } from './empty-state/empty-state.component'; @@ -80,6 +81,7 @@ export class RecentPlaylistsComponent { private readonly viewState = inject(SourcesViewStateService); readonly removal = inject(SourceRemovalService); readonly refresh = inject(SourceListRefreshService); + private readonly refreshAction = inject(PlaylistRefreshActionService); readonly searchQueryInput = input(''); readonly playlistClicked = output(); @@ -218,6 +220,14 @@ export class RecentPlaylistsComponent { this.removal.remove(playlist, next); } + /** Refreshing from this page, or from the playlist switcher. */ + isRefreshing(item: PlaylistMeta): boolean { + return ( + this.refresh.isRefreshing(item._id) || + this.refreshAction.isSourceBusy(item._id) + ); + } + busyMessage(item: PlaylistMeta): string { return ( this.removal.progress.message(item._id) || diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts index f066a757f..de78aa84c 100644 --- a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts @@ -17,6 +17,7 @@ import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY, type RendererPerformancePhaseEvent, } from '@iptvnator/shared/logging'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; import { SOURCE_REMOVAL_UNDO_MS, SourceRemovalService, @@ -51,6 +52,7 @@ describe('SourceRemovalService', () => { }; let router: { url: string; navigate: jest.Mock; navigateByUrl: jest.Mock }; let service: SourceRemovalService; + let refreshing: Set; const toast = (message: string, data: { commitNow?: () => void } = {}) => { const dismissed = new Subject(); @@ -83,6 +85,7 @@ describe('SourceRemovalService', () => { deletePlaylist = jest.fn().mockResolvedValue(true); cancelOperation = jest.fn().mockResolvedValue(true); dispatch = jest.fn(); + refreshing = new Set(); resolvedPlaylistId = signal(null); routePlaylistId = signal(null); context = { @@ -131,6 +134,12 @@ describe('SourceRemovalService', () => { provide: RuntimeCapabilitiesService, useValue: { supportsXtreamSqliteDataSource: true }, }, + { + provide: PlaylistRefreshActionService, + useValue: { + isSourceBusy: (id: string) => refreshing.has(id), + }, + }, ], }); service = TestBed.inject(SourceRemovalService); @@ -155,6 +164,16 @@ describe('SourceRemovalService', () => { expect(service.isPending('a')).toBe(false); }); + it('does not remove a source while a refresh clears and refills it', async () => { + refreshing.add('a'); + service.remove(playlist('a')); + expireUndo(); + await flush(); + expect(service.isPending('a')).toBe(false); + expect(toasts).toHaveLength(0); + expect(deletePlaylist).not.toHaveBeenCalled(); + }); + it('restores the source on Undo without deleting anything', async () => { service.remove(playlist('a')); toasts[0].action.next(); diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts index b24ff937e..2c3410878 100644 --- a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts @@ -13,6 +13,7 @@ import { RuntimeCapabilitiesService, } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; import { SourceDeleteProgressService } from './source-delete-progress.service'; import { SourceRemovalToastComponent } from './source-removal-toast.component'; import { @@ -66,6 +67,7 @@ interface PendingRemoval { export class SourceRemovalService { private readonly deleteAction = inject(PlaylistDeleteActionService); private readonly playlistContext = inject(PlaylistContextFacade); + private readonly refreshAction = inject(PlaylistRefreshActionService); private readonly router = inject(Router); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly snackBar = inject(MatSnackBar); @@ -92,7 +94,12 @@ export class SourceRemovalService { next: PlaylistMeta | null = null, options: SourceRemovalOptions = {} ): void { - if (this.isPending(playlist._id)) { + // A refresh started from the switcher is destructive too (it clears + // the catalog, then re-imports it): the two must not overlap. + if ( + this.isPending(playlist._id) || + this.refreshAction.isSourceBusy(playlist._id) + ) { return; } // A second delete commits the first at once. diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html index ac86d9cd3..949fd88a9 100644 --- a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html @@ -246,6 +246,7 @@ mat-menu-item type="button" class="app-menu-item--danger" + [disabled]="busy() || !removable()" (click)="removeRequested.emit()" > delete diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts index a6b11f453..331a43346 100644 --- a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts @@ -91,6 +91,8 @@ export class SourceRowComponent { readonly busyMessage = input(''); readonly busyProgress = input(null); readonly canCancelBusy = input(false); + /** False while something else, such as a refresh, holds the source. */ + readonly removable = input(true); readonly activated = output(); readonly refreshRequested = output(); diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts index 61a0d678f..f1c4858a1 100644 --- a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts @@ -97,6 +97,13 @@ describe('sourceDisplayName', () => { expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine'); }); + it('drops a name holding the password of an Xtream stream path', () => { + const url = 'http://host.tv/LIVE/joe/hunter2/1.ts'; + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'host.tv' + ); + }); + it('returns null for a local file without a name', () => { expect(sourceDisplayName({ title: '' })).toBeNull(); expect(sourceDisplayName({ title: '', filename: 'list.m3u' })).toBe( diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.ts index 692521d1b..636060f24 100644 --- a/libs/playlist/shared/util/src/lib/source-display/source-display.ts +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.ts @@ -1,4 +1,7 @@ -import { isSensitiveKey } from '@iptvnator/shared/logging'; +import { + isSensitiveKey, + XTREAM_CREDENTIAL_PATH_SEGMENTS, +} from '@iptvnator/shared/logging'; import { PlaylistMeta, playlistDisplayLabel, @@ -90,6 +93,18 @@ function collectUrlSecrets( if (depth > 0) { secrets.push(safeDecode(url.username)); } + // An Xtream stream path carries the account: `/live///`. + const segments = url.pathname.split('/').map(safeDecode); + for (let index = 0; index + 2 < segments.length; index += 1) { + if ( + XTREAM_CREDENTIAL_PATH_SEGMENTS.has(segments[index].toLowerCase()) + ) { + secrets.push(segments[index + 2]); + if (depth > 0) { + secrets.push(segments[index + 1]); + } + } + } const visit = (params: URLSearchParams): void => params.forEach((value, key) => { const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); diff --git a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts index d40fec631..f955008cf 100644 --- a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts +++ b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts @@ -73,6 +73,16 @@ describe('stripUrlCredentials', () => { expect(stripUrlCredentials(inPath)).not.toMatch(/hunter2|joe/); }); + it('knows Xtream account segments in any case, also nested', () => { + expect( + stripUrlCredentials('http://host.tv/LIVE/joe/hunter2/1.ts') + ).toBe('http://host.tv/LIVE/1.ts'); + const nested = `https://proxy.test/list?url=${encodeURIComponent( + 'https://origin.test/LIVE/joe/hunter2/1.ts' + )}`; + expect(stripUrlCredentials(nested)).not.toMatch(/hunter2|joe/); + }); + it('drops a kept part that still names a secret', () => { expect( stripUrlCredentials( diff --git a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts index eece7dbe5..dded0fb5f 100644 --- a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts +++ b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts @@ -106,8 +106,9 @@ function stripUrl(value: string, depth: number): string | null { if (clean !== null) { kept.push(clean === decoded ? segment : encodeURIComponent(clean)); } + // `/LIVE/…` and `/%6Cive/…` mark account segments as `/live/…` does. if ( - XTREAM_CREDENTIAL_PATH_SEGMENTS.has(segment) && + XTREAM_CREDENTIAL_PATH_SEGMENTS.has(decoded.toLowerCase()) && index + 2 < segments.length ) { index += 2; diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts index 2ca48e7e5..7e0ac0ca9 100644 --- a/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts +++ b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts @@ -300,6 +300,16 @@ describe('redactSensitiveData', () => { expect(output).not.toContain(password); }); + it('redacts Xtream path credentials whatever the marker case', () => { + const output = serialized( + redactSensitiveData( + 'https://example.com/LIVE/case-user-secret/case-pass-secret/1.ts' + ) + ); + expect(output).not.toContain('case-user-secret'); + expect(output).not.toContain('case-pass-secret'); + }); + it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => { const input: Record = { status: 'ok', diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.ts b/libs/shared/logging/src/lib/redact-sensitive-data.ts index ebec2bee5..fae9f0269 100644 --- a/libs/shared/logging/src/lib/redact-sensitive-data.ts +++ b/libs/shared/logging/src/lib/redact-sensitive-data.ts @@ -60,6 +60,16 @@ export const XTREAM_CREDENTIAL_PATH_SEGMENTS: ReadonlySet = new Set([ 'series', 'timeshift', ]); +/** A path segment that precedes Xtream account segments, in any case. */ +function isXtreamCredentialMarker(segment: string): boolean { + let decoded = segment; + try { + decoded = decodeURIComponent(segment); + } catch { + // A malformed escape is compared as written. + } + return XTREAM_CREDENTIAL_PATH_SEGMENTS.has(decoded.toLowerCase()); +} export interface RedactionOptions { maxDepth?: number; @@ -145,7 +155,7 @@ function redactUrl( const pathSegments = redacted.pathname.split('/'); for (let index = 0; index < pathSegments.length - 2; index += 1) { - if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(pathSegments[index])) { + if (isXtreamCredentialMarker(pathSegments[index])) { pathSegments[index + 1] = REDACTED_VALUE; pathSegments[index + 2] = REDACTED_VALUE; }