mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(security): complete Electron hardening and review follow-ups
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts. S2: write-file IPC restricted to save-dialog-authorized paths. S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests). S4: EPG titles rendered via interpolation, not [innerHTML]. S5: downloads reveal/play limited to recorded download paths. S6: Stalker cmd encoded (slash-preserving) to block query injection. EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed. * perf(player): lazy-load web video players via @defer Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js, artplayer and mpegts.js split into a deferred chunk loaded on first playback instead of eagerly on the player route. Embedded MPV (native) stays eager. Spec uses DeferBlockBehavior.Playthrough. * fix(player): remove leaked HTML video listeners on destroy volumechange used a mismatched removeEventListener reference, while loadedmetadata and timeupdate were never removed at all. Bind all three to stable handler fields used for both add and remove, and add a teardown regression test asserting each listener is detached on destroy. * refactor(dashboard): extract pure navigation helpers from DashboardDataService Move the 8 stateless link/navigation-state/type-kind helpers into a new dashboard-navigation.util.ts so the routing logic is independently testable and the 1260-line god-service shrinks. DashboardDataService keeps the public methods as thin delegators (facade) so the public API and the single consumer (workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService decomposition; verified by the existing service spec (33/33) and the app typecheck. * fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder) - url-safety: broaden IPv6 link-local detection to the full fe80::/10 range (fe80:: through febf::), not just the fe80:: prefix (+ regression tests). - playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save dialog opened without a following write cannot accumulate entries until restart. - web-player-view: add a @placeholder to each @defer (on immediate) player block to avoid the one-frame blank/layout-shift before the chunk resolves. * fix(security): close Electron network and download gaps * test(downloads): cover cancellation and restart cleanup * fix(downloads): address Greptile review gaps * test(security): reproduce remaining Greptile findings * fix(security): close remaining Greptile findings * test(downloads): reproduce early database queue stall * fix(downloads): release queue after setup failures * test(downloads): reproduce completion queue stall * fix(downloads): release queue after completion failures
This commit is contained in:
1 parent
7775553a6b
commit
2c032cd3c8
44 files changed
+3646
-1124
No files matched your search
@@ -85,10 +85,7 @@
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="program-title"
|
||||
[innerHTML]="program.title"
|
||||
></div>
|
||||
<div class="program-title">{{ program.title }}</div>
|
||||
@if (isProgramPlaying(program)) {
|
||||
<div class="program-progress">
|
||||
<div class="epg-progress-track">
|
||||
|
||||
@@ -67,6 +67,31 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => {
|
||||
const el = component.videoPlayer.nativeElement;
|
||||
const removeSpy = jest.spyOn(el, 'removeEventListener');
|
||||
const handlers = component as unknown as {
|
||||
handleVolumeChange: EventListener;
|
||||
handleLoadedMetadata: EventListener;
|
||||
handleTimeUpdate: EventListener;
|
||||
};
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'volumechange',
|
||||
handlers.handleVolumeChange
|
||||
);
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'loadedmetadata',
|
||||
handlers.handleLoadedMetadata
|
||||
);
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'timeupdate',
|
||||
handlers.handleTimeUpdate
|
||||
);
|
||||
});
|
||||
|
||||
it('should call play channel function after input changes', () => {
|
||||
jest.spyOn(component, 'playChannel');
|
||||
jest.spyOn(global.console, 'error').mockImplementation(() => {
|
||||
|
||||
@@ -79,26 +79,38 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.playbackIssue.emit(null);
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.videoPlayer.nativeElement.addEventListener('volumechange', () => {
|
||||
this.onVolumeChange();
|
||||
private readonly handleVolumeChange = (): void => {
|
||||
this.onVolumeChange();
|
||||
};
|
||||
|
||||
private readonly handleLoadedMetadata = (): void => {
|
||||
if (this.startTime > 0) {
|
||||
this.videoPlayer.nativeElement.currentTime = this.startTime;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly handleTimeUpdate = (): void => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.videoPlayer.nativeElement.currentTime,
|
||||
duration: this.videoPlayer.nativeElement.duration,
|
||||
});
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'volumechange',
|
||||
this.handleVolumeChange
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
if (this.startTime > 0) {
|
||||
this.videoPlayer.nativeElement.currentTime = this.startTime;
|
||||
}
|
||||
}
|
||||
this.handleLoadedMetadata
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener('timeupdate', () => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.videoPlayer.nativeElement.currentTime,
|
||||
duration: this.videoPlayer.nativeElement.duration,
|
||||
});
|
||||
});
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'timeupdate',
|
||||
this.handleTimeUpdate
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'error',
|
||||
@@ -332,7 +344,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'volumechange',
|
||||
this.onVolumeChange
|
||||
this.handleVolumeChange
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'loadedmetadata',
|
||||
this.handleLoadedMetadata
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'timeupdate',
|
||||
this.handleTimeUpdate
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'error',
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
@if (selectedPlayer() === 'videojs') {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[volume]="volume()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
@defer (on immediate) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[volume]="volume()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'html5') {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
@defer (on immediate) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'artplayer') {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
@defer (on immediate) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'embedded-mpv') {
|
||||
<app-embedded-mpv-player
|
||||
[playback]="resolvedPlayback()"
|
||||
@@ -35,13 +47,17 @@
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @else {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[volume]="volume()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
@defer (on immediate) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[volume]="volume()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (visiblePlaybackDiagnostic(); as issue) {
|
||||
|
||||
@@ -14,6 +14,15 @@ app-html-video-player {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Holds the player's space (black, no layout shift) for the one-frame gap
|
||||
// before a @defer (on immediate) player chunk resolves.
|
||||
.web-player-defer-placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.web-player-diagnostic {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockBehavior,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -95,6 +99,8 @@ describe('WebPlayerViewComponent', () => {
|
||||
runtimeCapabilities = { supportsManagedExternalPlayers: false };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
// @defer blocks render their main content synchronously in tests.
|
||||
deferBlockBehavior: DeferBlockBehavior.Playthrough,
|
||||
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{ provide: StorageMap, useValue: storageMap },
|
||||
@@ -208,7 +214,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', () => {
|
||||
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => {
|
||||
const streamUrl = 'https://example.com/movie/123.ts';
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl,
|
||||
@@ -220,6 +226,8 @@ describe('WebPlayerViewComponent', () => {
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const player = fixture.debugElement.query(
|
||||
@@ -238,7 +246,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves playback HTTP metadata for channel-based players', () => {
|
||||
it('preserves playback HTTP metadata for channel-based players', async () => {
|
||||
const streamUrl = 'https://example.com/live/channel.m3u8';
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
@@ -257,6 +265,8 @@ describe('WebPlayerViewComponent', () => {
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const player = fixture.debugElement.query(
|
||||
@@ -275,7 +285,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to playback headers when explicit HTTP metadata is absent', () => {
|
||||
it('falls back to playback headers when explicit HTTP metadata is absent', async () => {
|
||||
const streamUrl = 'https://example.com/live/channel.m3u8';
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
@@ -291,6 +301,8 @@ describe('WebPlayerViewComponent', () => {
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const player = fixture.debugElement.query(
|
||||
@@ -376,18 +388,25 @@ describe('WebPlayerViewComponent', () => {
|
||||
canNext: false,
|
||||
autoplayEnabled: true,
|
||||
};
|
||||
fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv);
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
fixture.componentRef.setInput('seriesNavigation', seriesNavigation);
|
||||
(
|
||||
component as unknown as {
|
||||
playbackEnded: { subscribe: (fn: () => void) => void };
|
||||
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||||
previousEpisodeRequested: {
|
||||
subscribe: (fn: () => void) => void;
|
||||
};
|
||||
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||||
}
|
||||
).playbackEnded.subscribe(() => events.push('ended'));
|
||||
(
|
||||
component as unknown as {
|
||||
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||||
previousEpisodeRequested: {
|
||||
subscribe: (fn: () => void) => void;
|
||||
};
|
||||
}
|
||||
).previousEpisodeRequested.subscribe(() => events.push('previous'));
|
||||
(
|
||||
@@ -494,7 +513,9 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('clears playback diagnostics when retrying inline playback', () => {
|
||||
it('clears playback diagnostics when retrying inline playback', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const player = fixture.debugElement.query(
|
||||
By.directive(StubVjsPlayerComponent)
|
||||
|
||||
Reference in new issue
Block a user