mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16: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
@@ -0,0 +1,48 @@
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import { EpgDatabaseClearOperation } from './epg-database';
|
||||
|
||||
function createDatabaseMock(exec: jest.Mock) {
|
||||
const database = {
|
||||
close: jest.fn(),
|
||||
exec,
|
||||
};
|
||||
const Database = jest.fn(() => database) as unknown as typeof BetterSqlite3;
|
||||
|
||||
return { Database, database };
|
||||
}
|
||||
|
||||
describe('EpgDatabaseClearOperation', () => {
|
||||
it('clears programs and channels in one transaction', () => {
|
||||
const exec = jest.fn();
|
||||
const { Database } = createDatabaseMock(exec);
|
||||
|
||||
new EpgDatabaseClearOperation(Database).run();
|
||||
|
||||
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
|
||||
'BEGIN',
|
||||
'DELETE FROM epg_programs',
|
||||
'DELETE FROM epg_channels',
|
||||
'COMMIT',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls back when either delete fails', () => {
|
||||
const failure = new Error('database is busy');
|
||||
const exec = jest.fn((statement: string) => {
|
||||
if (statement === 'DELETE FROM epg_channels') {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
const { Database } = createDatabaseMock(exec);
|
||||
|
||||
expect(() => new EpgDatabaseClearOperation(Database).run()).toThrow(
|
||||
failure
|
||||
);
|
||||
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
|
||||
'BEGIN',
|
||||
'DELETE FROM epg_programs',
|
||||
'DELETE FROM epg_channels',
|
||||
'ROLLBACK',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
|
||||
import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser';
|
||||
|
||||
/**
|
||||
* Database helper for worker-owned EPG operations.
|
||||
* Creates its own connection to avoid blocking the main thread.
|
||||
*/
|
||||
export class EpgDatabase {
|
||||
private readonly db: BetterSqlite3.Database;
|
||||
private readonly knownChannelIds = new Set<string>();
|
||||
private readonly insertChannelStmt: BetterSqlite3.Statement;
|
||||
private readonly insertProgramStmt: BetterSqlite3.Statement;
|
||||
private readonly deleteChannelsStmt: BetterSqlite3.Statement;
|
||||
|
||||
constructor(Database: typeof BetterSqlite3) {
|
||||
this.db = new Database(getIptvnatorDatabasePath());
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
|
||||
this.insertChannelStmt = this.db.prepare(`
|
||||
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
icon_url = excluded.icon_url,
|
||||
url = excluded.url,
|
||||
source_url = excluded.source_url,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
`);
|
||||
|
||||
this.insertProgramStmt = this.db.prepare(`
|
||||
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.deleteChannelsStmt = this.db.prepare(`
|
||||
DELETE FROM epg_channels WHERE source_url = ?
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a batch of channels. When `clearFirst` is true, the existing rows
|
||||
* for `sourceUrl` are deleted inside the same transaction as the insert so
|
||||
* old data is preserved if the fetch/parse never produces any channels.
|
||||
*/
|
||||
insertChannels(
|
||||
channels: ParsedChannel[],
|
||||
sourceUrl: string,
|
||||
clearFirst = false
|
||||
): void {
|
||||
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
|
||||
if (clearFirst) {
|
||||
this.deleteChannelsStmt.run(sourceUrl);
|
||||
this.knownChannelIds.clear();
|
||||
}
|
||||
|
||||
for (const channel of channels) {
|
||||
const displayName =
|
||||
channel.displayName?.[0]?.value || channel.id;
|
||||
const iconUrl = channel.icon?.[0]?.src || null;
|
||||
const url = channel.url?.[0] || null;
|
||||
|
||||
this.insertChannelStmt.run(
|
||||
channel.id,
|
||||
displayName,
|
||||
iconUrl,
|
||||
url,
|
||||
sourceUrl
|
||||
);
|
||||
this.knownChannelIds.add(channel.id);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert programs for channels already seen during the current parse.
|
||||
*/
|
||||
insertPrograms(programs: ParsedProgram[]): number {
|
||||
let insertedCount = 0;
|
||||
|
||||
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
|
||||
for (const program of programs) {
|
||||
if (!this.knownChannelIds.has(program.channel)) continue;
|
||||
|
||||
const title = program.title?.[0]?.value || 'Unknown';
|
||||
const description = program.desc?.[0]?.value || null;
|
||||
const category = program.category?.[0]?.value || null;
|
||||
const iconUrl = program.icon?.[0]?.src || null;
|
||||
const rating = program.rating?.[0]?.value || null;
|
||||
const episodeNum = program.episodeNum?.[0]?.value || null;
|
||||
|
||||
try {
|
||||
this.insertProgramStmt.run(
|
||||
program.channel,
|
||||
program.start,
|
||||
program.stop,
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
iconUrl,
|
||||
rating,
|
||||
episodeNum
|
||||
);
|
||||
insertedCount++;
|
||||
} catch {
|
||||
// Skip individual failures such as FK constraint errors.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(programs);
|
||||
return insertedCount;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class EpgDatabaseClearOperation {
|
||||
private readonly db: BetterSqlite3.Database;
|
||||
|
||||
constructor(Database: typeof BetterSqlite3) {
|
||||
this.db = new Database(getIptvnatorDatabasePath());
|
||||
}
|
||||
|
||||
run(): void {
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
this.db.exec('DELETE FROM epg_programs');
|
||||
this.db.exec('DELETE FROM epg_channels');
|
||||
this.db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
|
||||
import { Readable } from 'stream';
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import { createGunzip } from 'zlib';
|
||||
import {
|
||||
ParsedChannel,
|
||||
ParsedProgram,
|
||||
StreamingEpgParser,
|
||||
} from './epg-streaming-parser';
|
||||
import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database';
|
||||
import { StreamingEpgParser } from './epg-streaming-parser';
|
||||
import { shouldGunzipEpgResponse } from './epg-response-utils';
|
||||
import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety';
|
||||
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
||||
import {
|
||||
getNativeModuleSearchPaths,
|
||||
getWorkerDataNativeModuleSearchPaths,
|
||||
@@ -17,14 +14,11 @@ import {
|
||||
registerNativeModuleSearchPaths,
|
||||
} from './worker-runtime-paths';
|
||||
|
||||
let Database: typeof BetterSqlite3;
|
||||
|
||||
const nativeModuleSearchPaths = [
|
||||
...getWorkerDataNativeModuleSearchPaths(workerData),
|
||||
...getNativeModuleSearchPaths({
|
||||
resourcesPath: (
|
||||
process as NodeJS.Process & { resourcesPath?: string }
|
||||
).resourcesPath,
|
||||
resourcesPath: (process as NodeJS.Process & { resourcesPath?: string })
|
||||
.resourcesPath,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -36,12 +30,11 @@ function loadBetterSqlite3(): typeof BetterSqlite3 {
|
||||
loggerLabel: '[EPG Worker]',
|
||||
searchPaths: nativeModuleSearchPaths,
|
||||
fallbackRequire: () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('better-sqlite3') as typeof BetterSqlite3,
|
||||
});
|
||||
}
|
||||
|
||||
Database = loadBetterSqlite3();
|
||||
const Database = loadBetterSqlite3();
|
||||
|
||||
/**
|
||||
* Streaming EPG Parser Worker
|
||||
@@ -76,143 +69,6 @@ const loggerLabel = '[EPG Worker]';
|
||||
const CHANNEL_BATCH_SIZE = 100;
|
||||
const PROGRAM_BATCH_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* Database helper class for EPG operations
|
||||
* Creates its own connection to avoid blocking main thread
|
||||
*/
|
||||
class EpgDatabase {
|
||||
private db: BetterSqlite3.Database;
|
||||
private knownChannelIds: Set<string> = new Set();
|
||||
|
||||
// Prepared statements for better performance
|
||||
private insertChannelStmt: BetterSqlite3.Statement;
|
||||
private insertProgramStmt: BetterSqlite3.Statement;
|
||||
private deleteChannelsStmt: BetterSqlite3.Statement;
|
||||
|
||||
constructor() {
|
||||
const dbPath = getIptvnatorDatabasePath();
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.db.pragma('journal_mode = WAL'); // Better concurrent write performance
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
|
||||
// Prepare statements
|
||||
// Use INSERT OR REPLACE to update existing channels and refresh updated_at
|
||||
// Use strftime with ISO format for consistent date comparison
|
||||
this.insertChannelStmt = this.db.prepare(`
|
||||
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
icon_url = excluded.icon_url,
|
||||
url = excluded.url,
|
||||
source_url = excluded.source_url,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
`);
|
||||
|
||||
this.insertProgramStmt = this.db.prepare(`
|
||||
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.deleteChannelsStmt = this.db.prepare(`
|
||||
DELETE FROM epg_channels WHERE source_url = ?
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear existing EPG data for a source URL
|
||||
*/
|
||||
clearSourceData(sourceUrl: string): void {
|
||||
this.deleteChannelsStmt.run(sourceUrl);
|
||||
this.knownChannelIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a batch of channels. When `clearFirst` is true, the existing rows
|
||||
* for `sourceUrl` are deleted inside the same transaction as the insert so
|
||||
* old data is preserved if the fetch/parse never produces any channels.
|
||||
*/
|
||||
insertChannels(
|
||||
channels: ParsedChannel[],
|
||||
sourceUrl: string,
|
||||
clearFirst = false
|
||||
): void {
|
||||
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
|
||||
if (clearFirst) {
|
||||
this.deleteChannelsStmt.run(sourceUrl);
|
||||
this.knownChannelIds.clear();
|
||||
}
|
||||
for (const channel of channels) {
|
||||
const displayName =
|
||||
channel.displayName?.[0]?.value || channel.id;
|
||||
const iconUrl = channel.icon?.[0]?.src || null;
|
||||
const url = channel.url?.[0] || null;
|
||||
|
||||
this.insertChannelStmt.run(
|
||||
channel.id,
|
||||
displayName,
|
||||
iconUrl,
|
||||
url,
|
||||
sourceUrl
|
||||
);
|
||||
this.knownChannelIds.add(channel.id);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a batch of programs
|
||||
* Only inserts programs for known channels to avoid FK constraint failures
|
||||
*/
|
||||
insertPrograms(programs: ParsedProgram[]): number {
|
||||
let insertedCount = 0;
|
||||
|
||||
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
|
||||
for (const prog of programs) {
|
||||
// Skip if channel not known
|
||||
if (!this.knownChannelIds.has(prog.channel)) continue;
|
||||
|
||||
const title = prog.title?.[0]?.value || 'Unknown';
|
||||
const description = prog.desc?.[0]?.value || null;
|
||||
const category = prog.category?.[0]?.value || null;
|
||||
const iconUrl = prog.icon?.[0]?.src || null;
|
||||
const rating = prog.rating?.[0]?.value || null;
|
||||
const episodeNum = prog.episodeNum?.[0]?.value || null;
|
||||
|
||||
try {
|
||||
this.insertProgramStmt.run(
|
||||
prog.channel,
|
||||
prog.start,
|
||||
prog.stop,
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
iconUrl,
|
||||
rating,
|
||||
episodeNum
|
||||
);
|
||||
insertedCount++;
|
||||
} catch (err) {
|
||||
// Skip individual failures (e.g., FK constraint)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(programs);
|
||||
return insertedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses EPG data from URL using streaming
|
||||
* Inserts directly into SQLite to avoid blocking main thread
|
||||
@@ -221,7 +77,7 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
|
||||
console.log(loggerLabel, `Fetching EPG from ${url}`);
|
||||
|
||||
// Create database connection in worker
|
||||
const epgDb = new EpgDatabase();
|
||||
const epgDb = new EpgDatabase(Database);
|
||||
|
||||
// Old rows for this source are retained until the first successful insert
|
||||
// batch arrives — see `hasClearedSource` below. That way, a fetch or parse
|
||||
@@ -230,13 +86,30 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
|
||||
let hasClearedSource = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(url.trim());
|
||||
const isGzipped = shouldGunzipEpgResponse(url, response);
|
||||
// EPG URLs can originate from an untrusted M3U `url-tvg` attribute.
|
||||
// Validate every redirect and require an explicit operator opt-in for
|
||||
// private/LAN sources.
|
||||
const response = await requestWithValidatedRedirects<Readable>(
|
||||
url.trim(),
|
||||
{
|
||||
decompress: false,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
},
|
||||
{
|
||||
allowPrivateNetworks: isPrivateNetworkUrlAccessAllowed(),
|
||||
}
|
||||
);
|
||||
const responseUrl = response.config.url;
|
||||
const isGzipped = shouldGunzipEpgResponse(url, {
|
||||
headers: response.headers,
|
||||
url: responseUrl,
|
||||
});
|
||||
|
||||
if (response.url && response.url !== url) {
|
||||
if (responseUrl && responseUrl !== url) {
|
||||
console.log(
|
||||
loggerLabel,
|
||||
`Resolved EPG redirect: ${url} -> ${response.url}`
|
||||
`Resolved EPG redirect: ${url} -> ${responseUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,11 +118,11 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
|
||||
`EPG response detected as gzipped: ${isGzipped}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
if (!response.data) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
@@ -276,15 +149,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
|
||||
PROGRAM_BATCH_SIZE
|
||||
);
|
||||
|
||||
// Convert web stream to Node.js stream
|
||||
const nodeStream = Readable.fromWeb(response.body as any);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let dataStream: Readable = nodeStream;
|
||||
let dataStream: Readable = response.data;
|
||||
|
||||
if (isGzipped) {
|
||||
const gunzip = createGunzip();
|
||||
dataStream = nodeStream.pipe(gunzip);
|
||||
dataStream = response.data.pipe(gunzip);
|
||||
|
||||
gunzip.on('error', (err) => {
|
||||
console.error(loggerLabel, 'Gunzip error:', err);
|
||||
@@ -364,16 +234,12 @@ async function fetchAndParseEpgStreaming(url: string): Promise<void> {
|
||||
* Runs in worker thread to avoid blocking main thread
|
||||
*/
|
||||
function clearAllEpgData(): void {
|
||||
const dbPath = getIptvnatorDatabasePath();
|
||||
const db = new Database(dbPath);
|
||||
const clearOperation = new EpgDatabaseClearOperation(Database);
|
||||
|
||||
try {
|
||||
console.log(loggerLabel, 'Clearing all EPG data...');
|
||||
|
||||
// Delete programs first (foreign key constraint)
|
||||
db.exec('DELETE FROM epg_programs');
|
||||
// Then delete channels
|
||||
db.exec('DELETE FROM epg_channels');
|
||||
clearOperation.run();
|
||||
|
||||
console.log(loggerLabel, 'All EPG data cleared');
|
||||
|
||||
@@ -387,7 +253,7 @@ function clearAllEpgData(): void {
|
||||
};
|
||||
parentPort?.postMessage(errorResponse);
|
||||
} finally {
|
||||
db.close();
|
||||
clearOperation.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,11 +37,25 @@ describe('shouldGunzipEpgResponse', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('supports plain Axios response header objects', () => {
|
||||
expect(
|
||||
shouldGunzipEpgResponse('https://example.com/guide', {
|
||||
headers: {
|
||||
'Content-Type': 'application/gzip',
|
||||
},
|
||||
url: 'https://example.com/guide',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when content-disposition advertises a .gz filename', () => {
|
||||
expect(
|
||||
shouldGunzipEpgResponse('https://example.com/guide', {
|
||||
headers: new Headers([
|
||||
['content-disposition', 'attachment; filename="guide.xml.gz"'],
|
||||
[
|
||||
'content-disposition',
|
||||
'attachment; filename="guide.xml.gz"',
|
||||
],
|
||||
]),
|
||||
url: 'https://example.com/guide',
|
||||
})
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
type HeaderReader =
|
||||
| Record<string, unknown>
|
||||
| {
|
||||
get(name: string): unknown;
|
||||
};
|
||||
|
||||
function getHeaderValue(headers: HeaderReader, name: string): string | null {
|
||||
const value =
|
||||
'get' in headers && typeof headers.get === 'function'
|
||||
? headers.get(name)
|
||||
: Object.entries(headers).find(
|
||||
([headerName]) =>
|
||||
headerName.toLowerCase() === name.toLowerCase()
|
||||
)?.[1];
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
}
|
||||
|
||||
function hasGzipPath(url: string | null | undefined): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
@@ -16,13 +33,13 @@ function hasGzipPath(url: string | null | undefined): boolean {
|
||||
*/
|
||||
export function shouldGunzipEpgResponse(
|
||||
originalUrl: string,
|
||||
response: { headers: Headers; url?: string }
|
||||
response: { headers: HeaderReader; url?: string }
|
||||
): boolean {
|
||||
if (hasGzipPath(originalUrl) || hasGzipPath(response.url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
const contentType = getHeaderValue(response.headers, 'content-type');
|
||||
if (
|
||||
contentType &&
|
||||
/(application\/gzip|application\/x-gzip)/i.test(contentType)
|
||||
@@ -30,7 +47,10 @@ export function shouldGunzipEpgResponse(
|
||||
return true;
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
const contentDisposition = getHeaderValue(
|
||||
response.headers,
|
||||
'content-disposition'
|
||||
);
|
||||
if (contentDisposition?.toLowerCase().includes('.gz')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import { parentPort } from 'worker_threads';
|
||||
import { parse } from 'iptv-playlist-parser';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createPlaylistObject, getFilenameFromUrl } from '@iptvnator/shared/m3u-utils';
|
||||
import {
|
||||
createPlaylistObject,
|
||||
getFilenameFromUrl,
|
||||
} from '@iptvnator/shared/m3u-utils';
|
||||
import { createPlaylistAgentFactory } from '../util/secure-https';
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistRefreshEvent,
|
||||
@@ -12,8 +15,7 @@ import type {
|
||||
PlaylistRefreshWorkerIncomingMessage,
|
||||
PlaylistRefreshWorkerMessage,
|
||||
} from './playlist-refresh.worker.types';
|
||||
|
||||
const https = require('https');
|
||||
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
||||
|
||||
type ActiveRefreshState = {
|
||||
cancelled: boolean;
|
||||
@@ -23,7 +25,9 @@ type ActiveRefreshState = {
|
||||
const activeRefreshes = new Map<string, ActiveRefreshState>();
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error('Playlist refresh worker must be started with a parent port');
|
||||
throw new Error(
|
||||
'Playlist refresh worker must be started with a parent port'
|
||||
);
|
||||
}
|
||||
|
||||
function postMessage(message: PlaylistRefreshWorkerMessage<Playlist>): void {
|
||||
@@ -78,14 +82,16 @@ async function fetchPlaylistFromUrl(
|
||||
emitEvent(payload, { status: 'started', phase: 'fetching' });
|
||||
checkpoint(payload);
|
||||
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const result = await axios.get(payload.url!, {
|
||||
httpsAgent: agent,
|
||||
signal: controller.signal,
|
||||
timeout: 30000,
|
||||
});
|
||||
const result = await requestWithValidatedRedirects<string>(
|
||||
payload.url!,
|
||||
{
|
||||
agentFactory: createPlaylistAgentFactory(),
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
timeout: 30000,
|
||||
},
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
|
||||
checkpoint(payload);
|
||||
emitEvent(payload, { status: 'progress', phase: 'parsing' });
|
||||
@@ -129,7 +135,9 @@ async function fetchPlaylistFromFile(
|
||||
);
|
||||
}
|
||||
|
||||
async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist> {
|
||||
async function executeRefresh(
|
||||
payload: PlaylistRefreshPayload
|
||||
): Promise<Playlist> {
|
||||
const controller = new AbortController();
|
||||
activeRefreshes.set(payload.operationId, {
|
||||
cancelled: false,
|
||||
@@ -148,41 +156,45 @@ async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist
|
||||
}
|
||||
}
|
||||
|
||||
parentPort.on('message', async (message: PlaylistRefreshWorkerIncomingMessage) => {
|
||||
if (message.type === 'cancel') {
|
||||
const active = activeRefreshes.get(message.operationId);
|
||||
if (active) {
|
||||
active.cancelled = true;
|
||||
active.controller.abort();
|
||||
parentPort.on(
|
||||
'message',
|
||||
async (message: PlaylistRefreshWorkerIncomingMessage) => {
|
||||
if (message.type === 'cancel') {
|
||||
const active = activeRefreshes.get(message.operationId);
|
||||
if (active) {
|
||||
active.cancelled = true;
|
||||
active.controller.abort();
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeRefresh(message.payload);
|
||||
postMessage({
|
||||
type: 'response',
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
const payload = message.payload;
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
|
||||
} else {
|
||||
emitEvent(payload, {
|
||||
status: 'error',
|
||||
phase: payload.url ? 'fetching' : 'reading-file',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
try {
|
||||
const result = await executeRefresh(message.payload);
|
||||
postMessage({
|
||||
type: 'response',
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
const payload = message.payload;
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
|
||||
} else {
|
||||
emitEvent(payload, {
|
||||
status: 'error',
|
||||
phase: payload.url ? 'fetching' : 'reading-file',
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
postMessage({
|
||||
type: 'response',
|
||||
success: false,
|
||||
error: serializeError(error),
|
||||
});
|
||||
}
|
||||
|
||||
postMessage({
|
||||
type: 'response',
|
||||
success: false,
|
||||
error: serializeError(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
postMessage({ type: 'ready' });
|
||||
Reference in new issue
Block a user