mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
[codex] Add scoped EPG security trust controls (#1054)
* Add scoped EPG security trust controls * fix: address scoped trust review feedback --------- Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
1 parent
9043116ebd
commit
e8aa7c3a34
56 files changed
+1683
-243
No files matched your search
@@ -3,10 +3,16 @@ import {
|
||||
EpgImportProgress,
|
||||
EpgRuntimeBridgeService,
|
||||
} from './epg-runtime-bridge.service';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgProgressService } from './epg-progress.service';
|
||||
|
||||
describe('EpgProgressService', () => {
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let settingsStore: {
|
||||
getSettings: jest.Mock;
|
||||
getTrustOptions: jest.Mock;
|
||||
updateSettings: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
epgBridge = {
|
||||
@@ -15,6 +21,17 @@ describe('EpgProgressService', () => {
|
||||
supportsDataManagement: false,
|
||||
supportsProgress: false,
|
||||
};
|
||||
settingsStore = {
|
||||
getSettings: jest.fn(() => ({
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
trustedInsecureTlsHosts: ['playlist.local'],
|
||||
})),
|
||||
getTrustOptions: jest.fn(() => ({
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
trustedInsecureTlsHosts: ['playlist.local'],
|
||||
})),
|
||||
updateSettings: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -30,6 +47,10 @@ describe('EpgProgressService', () => {
|
||||
provide: EpgRuntimeBridgeService,
|
||||
useValue: epgBridge,
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: settingsStore,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -57,7 +78,31 @@ describe('EpgProgressService', () => {
|
||||
service.retry('https://example.com/epg.xml');
|
||||
|
||||
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
|
||||
'https://example.com/epg.xml'
|
||||
'https://example.com/epg.xml',
|
||||
{
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
trustedInsecureTlsHosts: ['playlist.local'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('trusts a private-network source and retries it', async () => {
|
||||
epgBridge.supportsDataManagement = true;
|
||||
const service = configureService();
|
||||
|
||||
await service.trustPrivateNetworkSourceAndRetry(
|
||||
'http://192.168.1.30/guide.xml'
|
||||
);
|
||||
|
||||
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
|
||||
trustedPrivateNetworkEpgUrls: [
|
||||
'http://192.168.1.20/guide.xml',
|
||||
'http://192.168.1.30/guide.xml',
|
||||
],
|
||||
});
|
||||
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
|
||||
'http://192.168.1.30/guide.xml',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
normalizeHost,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
EpgImportProgress,
|
||||
EpgRuntimeBridgeService,
|
||||
@@ -7,6 +12,7 @@ import {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EpgProgressService {
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -22,9 +28,7 @@ export class EpgProgressService {
|
||||
readonly queuedImports = computed(() =>
|
||||
this.imports()
|
||||
.filter((item) => item.status === 'queued')
|
||||
.sort(
|
||||
(a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)
|
||||
)
|
||||
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0))
|
||||
);
|
||||
readonly queuedCount = computed(() => this.queuedImports().length);
|
||||
readonly isVisible = computed(() => this.imports().length > 0);
|
||||
@@ -48,7 +52,46 @@ export class EpgProgressService {
|
||||
if (!this.epgBridge.supportsDataManagement) {
|
||||
return;
|
||||
}
|
||||
void this.epgBridge.forceFetchEpg(url);
|
||||
void this.epgBridge.forceFetchEpg(
|
||||
url,
|
||||
this.settingsStore.getTrustOptions()
|
||||
);
|
||||
}
|
||||
|
||||
async trustPrivateNetworkSourceAndRetry(url: string): Promise<void> {
|
||||
const settings = this.settingsStore.getSettings();
|
||||
const trustedUrls = new Set(
|
||||
settings.trustedPrivateNetworkEpgUrls ?? []
|
||||
);
|
||||
trustedUrls.add(url.trim());
|
||||
|
||||
await this.settingsStore.updateSettings({
|
||||
trustedPrivateNetworkEpgUrls: Array.from(trustedUrls),
|
||||
});
|
||||
this.retry(url);
|
||||
}
|
||||
|
||||
async trustInsecureTlsHostAndRetry(
|
||||
url: string,
|
||||
host?: string
|
||||
): Promise<void> {
|
||||
const trustedHost = host ?? this.getHostname(url);
|
||||
if (!trustedHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = this.settingsStore.getSettings();
|
||||
const trustedHosts = new Set(
|
||||
(settings.trustedInsecureTlsHosts ?? []).map((item) =>
|
||||
normalizeHost(item)
|
||||
)
|
||||
);
|
||||
trustedHosts.add(normalizeHost(trustedHost));
|
||||
|
||||
await this.settingsStore.updateSettings({
|
||||
trustedInsecureTlsHosts: Array.from(trustedHosts),
|
||||
});
|
||||
this.retry(url);
|
||||
}
|
||||
|
||||
private initializeListener(): void {
|
||||
@@ -71,11 +114,31 @@ export class EpgProgressService {
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (progress.status === 'complete' || progress.status === 'error') {
|
||||
if (
|
||||
progress.status === 'complete' ||
|
||||
(progress.status === 'error' && !this.isActionableError(progress))
|
||||
) {
|
||||
setTimeout(() => this.removeImport(progress.url), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private isActionableError(progress: EpgImportProgress): boolean {
|
||||
return (
|
||||
progress.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked ||
|
||||
progress.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate
|
||||
);
|
||||
}
|
||||
|
||||
private getHostname(url: string): string | undefined {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private removeImport(url: string): void {
|
||||
this.importsMap.update((current) => {
|
||||
const updated = new Map(current);
|
||||
|
||||
@@ -85,9 +85,13 @@ describe('EpgRuntimeBridgeService', () => {
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(fetchEpg).toHaveBeenCalledWith(['https://example.com/epg.xml']);
|
||||
expect(fetchEpg).toHaveBeenCalledWith(
|
||||
['https://example.com/epg.xml'],
|
||||
undefined
|
||||
);
|
||||
expect(forceFetchEpg).toHaveBeenCalledWith(
|
||||
'https://example.com/epg.xml'
|
||||
'https://example.com/epg.xml',
|
||||
undefined
|
||||
);
|
||||
expect(clearEpgData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ElectronBridgeEpgFreshnessResult,
|
||||
ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES,
|
||||
ElectronBridgeResult,
|
||||
ElectronBridgeTrustOptions,
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -76,20 +77,28 @@ export class EpgRuntimeBridgeService {
|
||||
return this.runtime.supportsEpgProgramSearch;
|
||||
}
|
||||
|
||||
fetchEpg(urls: string[]): Promise<EpgFetchResult | null> {
|
||||
fetchEpg(
|
||||
urls: string[],
|
||||
options?: ElectronBridgeTrustOptions
|
||||
): Promise<EpgFetchResult | null> {
|
||||
if (!this.supportsImport) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return this.bridge?.fetchEpg?.(urls) ?? Promise.resolve(null);
|
||||
return this.bridge?.fetchEpg?.(urls, options) ?? Promise.resolve(null);
|
||||
}
|
||||
|
||||
forceFetchEpg(url: string): Promise<EpgFetchResult | null> {
|
||||
forceFetchEpg(
|
||||
url: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
): Promise<EpgFetchResult | null> {
|
||||
if (!this.supportsDataManagement) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return this.bridge?.forceFetchEpg?.(url) ?? Promise.resolve(null);
|
||||
return (
|
||||
this.bridge?.forceFetchEpg?.(url, options) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
clearEpgData(): Promise<EpgClearResult | null> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
import { EpgService } from './epg.service';
|
||||
|
||||
@@ -9,6 +10,7 @@ describe('EpgService', () => {
|
||||
let service: EpgService;
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let snackBar: { open: jest.Mock };
|
||||
let settingsStore: { getSettings: jest.Mock; getTrustOptions: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
epgBridge = {
|
||||
@@ -22,6 +24,16 @@ describe('EpgService', () => {
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
settingsStore = {
|
||||
getSettings: jest.fn(() => ({
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
trustedInsecureTlsHosts: ['playlist.local'],
|
||||
})),
|
||||
getTrustOptions: jest.fn(() => ({
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
trustedInsecureTlsHosts: ['playlist.local'],
|
||||
})),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -40,6 +52,10 @@ describe('EpgService', () => {
|
||||
instant: (key: string) => key,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: settingsStore,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -61,10 +77,13 @@ describe('EpgService', () => {
|
||||
'https://example.com/other.xml',
|
||||
]);
|
||||
|
||||
expect(epgBridge.fetchEpg).toHaveBeenCalledWith([
|
||||
'https://example.com/epg.xml',
|
||||
'https://example.com/other.xml',
|
||||
]);
|
||||
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
|
||||
['https://example.com/epg.xml', 'https://example.com/other.xml'],
|
||||
{
|
||||
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
|
||||
trustedInsecureTlsHosts: ['playlist.local'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show a fetch error when the bridge returns no result', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
import { normalizeEpgPrograms } from './epg-program-normalization.util';
|
||||
|
||||
@@ -25,6 +26,7 @@ export class EpgService {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private translate = inject(TranslateService);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
|
||||
private epgAvailable = new BehaviorSubject<boolean>(false);
|
||||
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
|
||||
@@ -46,7 +48,12 @@ export class EpgService {
|
||||
const validUrls = urls.filter((url) => url?.trim());
|
||||
if (validUrls.length === 0) return;
|
||||
|
||||
from(this.epgBridge.fetchEpg(validUrls))
|
||||
from(
|
||||
this.epgBridge.fetchEpg(
|
||||
validUrls,
|
||||
this.settingsStore.getTrustOptions()
|
||||
)
|
||||
)
|
||||
.pipe(
|
||||
tap((result) => {
|
||||
if (result === null) return;
|
||||
@@ -272,7 +279,9 @@ export class EpgService {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
return from(this.epgBridge.getChannelMetadata(normalizedChannelIds)).pipe(
|
||||
return from(
|
||||
this.epgBridge.getChannelMetadata(normalizedChannelIds)
|
||||
).pipe(
|
||||
map((metadataByChannelId) => {
|
||||
return new Map<string, EpgChannelMetadata | null>(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
|
||||
Reference in new issue
Block a user