mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
1 parent
5500cc8dbb
commit
8323314853
17 files changed
+179
-7
No files matched your search
@@ -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
|
||||
|
||||
+1
@@ -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)"
|
||||
|
||||
+40
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user