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:
Salem authored and GitHub committed 2026-06-12 15:24:29 +02:00
1 parent 7775553a6b
commit 2c032cd3c8
44 files changed
+3646 -1124

No files matched your search

@@ -47,17 +47,23 @@ import {
toTimestamp,
} from './dashboard-mappers';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
PORTAL_PLAYBACK_POSITIONS,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import {
getGlobalFavoriteLink as getGlobalFavoriteLinkUtil,
getGlobalFavoriteNavigationState as getGlobalFavoriteNavigationStateUtil,
getPlaylistLink as getPlaylistLinkUtil,
getRecentItemLink as getRecentItemLinkUtil,
getRecentItemNavigationState as getRecentItemNavigationStateUtil,
getRecentlyAddedLink as getRecentlyAddedLinkUtil,
getRecentlyAddedNavigationState as getRecentlyAddedNavigationStateUtil,
isTypeInKind as isTypeInKindUtil,
type DashboardContentKind,
} from './dashboard-navigation.util';
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
export type { DashboardContentKind };
// Compound key for looking up a playback position by recent item — a single
// playlist can contain the same xtream-id for a VOD and an episode (rare,
@@ -799,28 +805,11 @@ export class DashboardDataService {
type: PortalActivityType,
kind: DashboardContentKind
): boolean {
if (kind === 'all') {
return true;
}
if (kind === 'channels') {
return type === 'live';
}
if (kind === 'vod') {
return type === 'movie';
}
return type === 'series';
return isTypeInKindUtil(type, kind);
}
getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
return getPlaylistLinkUtil(playlist);
}
getPlaylistProvider(playlist: PlaylistMeta): string {
@@ -858,13 +847,13 @@ export class DashboardDataService {
}
getRecentItemLink(item: GlobalRecentItem): string[] {
return getRecentItemNavigation(item).link;
return getRecentItemLinkUtil(item);
}
getRecentItemNavigationState(
item: GlobalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
return getRecentItemNavigationStateUtil(item);
}
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
@@ -965,67 +954,23 @@ export class DashboardDataService {
}
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
return getGlobalFavoriteNavigation(item).link;
return getGlobalFavoriteLinkUtil(item);
}
getGlobalFavoriteNavigationState(
item: DashboardFavoriteItem
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
return getGlobalFavoriteNavigationStateUtil(item);
}
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).link;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).link;
return getRecentlyAddedLinkUtil(item);
}
getRecentlyAddedNavigationState(
item: DashboardRecentlyAddedItem
): WorkspaceNavigationTarget['state'] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).state;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).state;
return getRecentlyAddedNavigationStateUtil(item);
}
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
@@ -0,0 +1,128 @@
import {
PlaylistMeta,
PortalActivityType,
PortalAddedItem,
PortalFavoriteItem,
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
/**
* Pure navigation/link helpers for dashboard items.
*
* Extracted from `DashboardDataService` so the routing logic can be unit-tested
* in isolation and the service stays a thin facade. None of these functions
* touch component/service state — they map a dashboard item to a router link
* (and optional navigation state) using the shared portal navigation builders.
*/
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
export function isTypeInKind(
type: PortalActivityType,
kind: DashboardContentKind
): boolean {
if (kind === 'all') {
return true;
}
if (kind === 'channels') {
return type === 'live';
}
if (kind === 'vod') {
return type === 'movie';
}
return type === 'series';
}
export function getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
}
export function getRecentItemLink(item: PortalRecentItem): string[] {
return getRecentItemNavigation(item).link;
}
export function getRecentItemNavigationState(
item: PortalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
}
export function getGlobalFavoriteLink(item: PortalFavoriteItem): string[] {
return getGlobalFavoriteNavigation(item).link;
}
export function getGlobalFavoriteNavigationState(
item: PortalFavoriteItem
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
}
export function getRecentlyAddedLink(item: PortalAddedItem): string[] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).link;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).link;
}
export function getRecentlyAddedNavigationState(
item: PortalAddedItem
): WorkspaceNavigationTarget['state'] {
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).state;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).state;
}