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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 22:46:45 +02:00
1 parent 5500cc8dbb
commit 8323314853
17 files changed
+179 -7

No files matched your search

+4 -1
View File
@@ -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
@@ -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)"
@@ -51,6 +51,7 @@ describe('PlaylistSwitcherPanelComponent', () => {
let selectPlaylist: jest.Mock;
let remove: jest.Mock;
let navigate: jest.Mock;
let refreshing: ReturnType<typeof signal<string | null>>;
const input = () =>
(fixture.nativeElement as HTMLElement).querySelector(
@@ -82,6 +83,7 @@ describe('PlaylistSwitcherPanelComponent', () => {
selectPlaylist = jest.fn();
remove = jest.fn();
navigate = jest.fn();
refreshing = signal<string | null>(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<HTMLElement>(
`#playlist-switcher-all-${id} .source-row__more`
)
?.click();
fixture.detectChanges();
await fixture.whenStable();
const items = Array.from(
document.querySelectorAll<HTMLButtonElement>(
'.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<HTMLElement>(
'#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();
@@ -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);
@@ -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)"
@@ -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<typeof signal<string | null>>;
beforeEach(() => {
localStorage.clear();
@@ -74,6 +76,7 @@ describe('RecentPlaylistsComponent', () => {
['xtream', { reachability: 'bad' }],
]);
pending = signal<ReadonlySet<string>>(new Set());
switcherRefreshing = signal<string | null>(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<HTMLButtonElement>('.source-row__more')
?.disabled
).toBe(true);
});
it('hides sources whose delete is pending', () => {
pending.set(new Set(['m3u']));
expect(ids()).toEqual(['xtream', 'file']);
@@ -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<string>('');
readonly playlistClicked = output<string>();
@@ -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) ||
@@ -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<string>;
const toast = (message: string, data: { commitNow?: () => void } = {}) => {
const dismissed = new Subject<void>();
@@ -83,6 +85,7 @@ describe('SourceRemovalService', () => {
deletePlaylist = jest.fn().mockResolvedValue(true);
cancelOperation = jest.fn().mockResolvedValue(true);
dispatch = jest.fn();
refreshing = new Set();
resolvedPlaylistId = signal<string | null>(null);
routePlaylistId = signal<string | null>(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();
@@ -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.
@@ -246,6 +246,7 @@
mat-menu-item
type="button"
class="app-menu-item--danger"
[disabled]="busy() || !removable()"
(click)="removeRequested.emit()"
>
<mat-icon>delete</mat-icon>
@@ -91,6 +91,8 @@ export class SourceRowComponent {
readonly busyMessage = input('');
readonly busyProgress = input<number | null>(null);
readonly canCancelBusy = input(false);
/** False while something else, such as a refresh, holds the source. */
readonly removable = input(true);
readonly activated = output<void>();
readonly refreshRequested = output<void>();
@@ -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(
@@ -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/<user>/<password>/`.
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, '');
@@ -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(
@@ -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;
@@ -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<string, unknown> = {
status: 'ok',
@@ -60,6 +60,16 @@ export const XTREAM_CREDENTIAL_PATH_SEGMENTS: ReadonlySet<string> = 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;
}