Adapted cookie, origin, URL validation and secret handling from MikaelKW/nodecast-tv-plus. Protect API roles, revoke logins across restart, preserve existing accounts, and isolate transcode sessions by account. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
142 lines
6.3 KiB
JavaScript
142 lines
6.3 KiB
JavaScript
const { Readable } = require('node:stream');
|
|
const { pipeline } = require('node:stream/promises');
|
|
const { validateHttpUrl } = require('./urlSecurity');
|
|
|
|
const MAX_HLS_MANIFEST_BYTES = 5 * 1024 * 1024;
|
|
|
|
async function* prepend(first, iterator) {
|
|
try {
|
|
yield first;
|
|
for (let next = await iterator.next(); !next.done; next = await iterator.next()) {
|
|
yield Buffer.from(next.value);
|
|
}
|
|
} finally {
|
|
try { await iterator.return?.(); } catch { /* Upstream may already be aborted. */ }
|
|
}
|
|
}
|
|
|
|
async function collectManifest(first, iterator) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of prepend(first, iterator)) {
|
|
size += chunk.length;
|
|
if (size > MAX_HLS_MANIFEST_BYTES) {
|
|
const error = new Error('HLS manifest exceeds the proxy size limit');
|
|
error.statusCode = 502;
|
|
throw error;
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
return Buffer.concat(chunks, size).toString('utf8');
|
|
}
|
|
|
|
function rewriteManifest(manifest, sourceUrl, proxyPath) {
|
|
const proxied = value => `${proxyPath}?url=${encodeURIComponent(new URL(value, sourceUrl).href)}`;
|
|
return manifest.split('\n').map(line => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return line;
|
|
if (trimmed.startsWith('#')) {
|
|
return line.replace(/URI=["']([^"']+)["']/g, (match, value) => {
|
|
try { return `URI="${proxied(value)}"`; } catch { return match; }
|
|
});
|
|
}
|
|
try { return proxied(trimmed); } catch { return line; }
|
|
}).join('\n');
|
|
}
|
|
|
|
// Adapted from NodeCast TV Plus's streaming proxy. Binary media is never
|
|
// buffered in full; only manifests are collected, under a fixed size limit.
|
|
async function proxyStream(req, res) {
|
|
try { validateHttpUrl(req.query.url); }
|
|
catch (error) { return res.status(400).json({ error: error.message }); }
|
|
let url;
|
|
try {
|
|
const raw = req.query.url;
|
|
if (typeof raw !== 'string' || /[\x00-\x1f\x7f]/.test(raw)) throw new Error();
|
|
const parsed = new URL(raw);
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
|
|
url = parsed.href;
|
|
} catch {
|
|
return res.status(400).json({ error: 'A valid HTTP(S) URL is required' });
|
|
}
|
|
|
|
let lastError;
|
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
if (res.destroyed) return;
|
|
const controller = new AbortController();
|
|
const abort = () => { if (!res.writableEnded) controller.abort(); };
|
|
res.once('close', abort);
|
|
try {
|
|
const origin = new URL(url).origin;
|
|
const pluto = ['pluto.tv', 'pluto.io', 'plutotv.net', 'service-stitcher'].some(domain => url.includes(domain));
|
|
const headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'identity',
|
|
Origin: pluto ? 'https://pluto.tv' : origin,
|
|
Referer: pluto ? 'https://pluto.tv/' : `${origin}/`
|
|
};
|
|
const range = req.get('range');
|
|
if (range) headers.Range = range;
|
|
const upstream = await fetch(url, { headers, signal: controller.signal });
|
|
if (upstream.status >= 500 && attempt < 2) {
|
|
await upstream.body?.cancel();
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
continue;
|
|
}
|
|
if (!upstream.ok) return res.status(upstream.status).send(`Failed to fetch stream: ${upstream.statusText}`);
|
|
|
|
const type = upstream.headers.get('content-type') || 'application/octet-stream';
|
|
const length = upstream.headers.get('content-length');
|
|
const encoding = upstream.headers.get('content-encoding');
|
|
const contentRange = upstream.headers.get('content-range');
|
|
const accepts = upstream.headers.get('accept-ranges');
|
|
res.set('Access-Control-Allow-Origin', '*');
|
|
res.status(upstream.status);
|
|
if (length && (!encoding || encoding === 'identity')) res.set('Content-Length', length);
|
|
if (contentRange) res.set('Content-Range', contentRange);
|
|
if (range && !(upstream.status === 206 && contentRange)) res.set('Accept-Ranges', 'none');
|
|
else if (accepts) res.set('Accept-Ranges', accepts);
|
|
|
|
if (!upstream.body) return res.end();
|
|
const iterator = upstream.body[Symbol.asyncIterator]();
|
|
const prefix = [];
|
|
let size = 0;
|
|
// The HLS signature can be split across network chunks.
|
|
while (size < 7) {
|
|
const next = await iterator.next();
|
|
if (next.done) break;
|
|
const chunk = Buffer.from(next.value);
|
|
prefix.push(chunk);
|
|
size += chunk.length;
|
|
}
|
|
const first = Buffer.concat(prefix, size);
|
|
const isManifest = first.subarray(0, 7).toString() === '#EXTM3U'
|
|
|| /(?:mpegurl|m3u8)/i.test(type);
|
|
if (isManifest) {
|
|
const manifest = await collectManifest(first, iterator);
|
|
res.removeHeader('Content-Length');
|
|
res.removeHeader('Content-Range');
|
|
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
|
return res.send(rewriteManifest(manifest, upstream.url || url, `${req.baseUrl}/stream`));
|
|
}
|
|
res.set('Content-Type', type);
|
|
await pipeline(Readable.from(prepend(first, iterator), { objectMode: false }), res);
|
|
return;
|
|
} catch (error) {
|
|
if (res.destroyed || controller.signal.aborted) return;
|
|
lastError = error;
|
|
if (res.headersSent) { res.destroy(error); return; }
|
|
res.removeHeader('Content-Length');
|
|
res.removeHeader('Content-Range');
|
|
if (error.statusCode) return res.status(error.statusCode).json({ error: error.message });
|
|
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 500));
|
|
} finally {
|
|
res.off('close', abort);
|
|
controller.abort();
|
|
}
|
|
}
|
|
if (!res.headersSent && !res.destroyed) res.status(502).json({ error: lastError?.message || 'Stream proxy failed' });
|
|
}
|
|
|
|
module.exports = proxyStream;
|