mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user