mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-04 11:54:28 -07:00
fix(anime): harden stream proxy and session lifecycle
- Reject non-origin-form proxy targets - Release stale anime browser sessions when senders move
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import type http from 'node:http';
|
||||
|
||||
export const TS_PACKET_LENGTH = 188;
|
||||
const TS_SYNC_BYTE = 0x47;
|
||||
/** Five sync bytes at exact packet spacing make an accidental match implausible. */
|
||||
const SYNC_RUN = 5;
|
||||
|
||||
export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts';
|
||||
const FFMPEG_SAFE_SEGMENT_EXTENSIONS = new Set([
|
||||
'3gp',
|
||||
'aac',
|
||||
'avi',
|
||||
'ac3',
|
||||
'eac3',
|
||||
'flac',
|
||||
'mkv',
|
||||
'm3u8',
|
||||
'm4a',
|
||||
'm4s',
|
||||
'm4v',
|
||||
'mpg',
|
||||
'mov',
|
||||
'mp2',
|
||||
'mp3',
|
||||
'mp4',
|
||||
'mpeg',
|
||||
'mpegts',
|
||||
'ogg',
|
||||
'ogv',
|
||||
'oga',
|
||||
'ts',
|
||||
'vob',
|
||||
'vtt',
|
||||
'wav',
|
||||
'webvtt',
|
||||
'cmfv',
|
||||
'cmfa',
|
||||
'ec3',
|
||||
'fmp4',
|
||||
]);
|
||||
|
||||
function needsTsSegmentAlias(pathname: string): boolean {
|
||||
const name = pathname.slice(pathname.lastIndexOf('/') + 1).toLowerCase();
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot === -1) return true;
|
||||
return !FFMPEG_SAFE_SEGMENT_EXTENSIONS.has(name.slice(dot + 1));
|
||||
}
|
||||
|
||||
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
|
||||
const DECISION_BYTES = DEFAULT_SCAN_LIMIT_BYTES + (SYNC_RUN - 1) * TS_PACKET_LENGTH + 1;
|
||||
|
||||
export function findTsSyncOffset(
|
||||
data: Buffer,
|
||||
scanLimit = DEFAULT_SCAN_LIMIT_BYTES,
|
||||
): number | null {
|
||||
const lastConfirmable = data.length - (SYNC_RUN - 1) * TS_PACKET_LENGTH - 1;
|
||||
const end = Math.min(lastConfirmable, scanLimit);
|
||||
for (let offset = 0; offset <= end; offset++) {
|
||||
if (data[offset] !== TS_SYNC_BYTE) continue;
|
||||
let confirmed = true;
|
||||
for (let packet = 1; packet < SYNC_RUN; packet++) {
|
||||
if (data[offset + packet * TS_PACKET_LENGTH] !== TS_SYNC_BYTE) {
|
||||
confirmed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confirmed) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rewritePlaylistOrigins(
|
||||
body: string,
|
||||
upstreamOrigin: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
const rebased = body.split(upstreamOrigin).join(proxyOrigin);
|
||||
return rebased
|
||||
.split(/(\r?\n)/)
|
||||
.map((line) => {
|
||||
const uri = line.trim();
|
||||
if (!uri || uri.startsWith('#')) return line;
|
||||
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(uri, proxyOrigin);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
if (resolved.origin !== proxyOrigin || !needsTsSegmentAlias(resolved.pathname)) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const queryIndex = uri.search(/[?#]/);
|
||||
const aliasIndex = queryIndex === -1 ? uri.length : queryIndex;
|
||||
const leadingWhitespace = line.slice(0, line.indexOf(uri));
|
||||
const trailingWhitespace = line.slice(leadingWhitespace.length + uri.length);
|
||||
return `${leadingWhitespace}${uri.slice(0, aliasIndex)}${TS_SEGMENT_ALIAS_SUFFIX}${uri.slice(aliasIndex)}${trailingWhitespace}`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
const DROPPED_RESPONSE_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
function forwardableResponseHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
|
||||
const result: http.OutgoingHttpHeaders = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined || DROPPED_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function handleUpstreamResponse(options: {
|
||||
req: http.IncomingMessage;
|
||||
res: http.ServerResponse;
|
||||
upstream: http.IncomingMessage;
|
||||
upstreamOrigin: () => string;
|
||||
proxyOrigin: () => string;
|
||||
log: (message: string) => void;
|
||||
}): void {
|
||||
const { req, res, upstream, log } = options;
|
||||
const status = upstream.statusCode ?? 502;
|
||||
const pathname = (req.url ?? '').split('?', 1)[0] ?? '';
|
||||
const contentType = String(upstream.headers['content-type'] ?? '');
|
||||
const isPlaylist = pathname.endsWith('.m3u8') || contentType.includes('mpegurl');
|
||||
|
||||
upstream.on('error', () => res.destroy());
|
||||
|
||||
if (status !== 200 || req.method === 'HEAD') {
|
||||
res.writeHead(status, forwardableResponseHeaders(upstream.headers));
|
||||
upstream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaylist) {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
buffered += chunk.length;
|
||||
if (buffered > DECISION_BYTES) {
|
||||
log(`[stream-proxy] playlist body over ${DECISION_BYTES} bytes; dropping`);
|
||||
upstream.destroy();
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
upstream.on('end', () => {
|
||||
const body = rewritePlaylistOrigins(
|
||||
Buffer.concat(chunks).toString('utf8'),
|
||||
options.upstreamOrigin(),
|
||||
options.proxyOrigin(),
|
||||
);
|
||||
res.writeHead(status, {
|
||||
...forwardableResponseHeaders(upstream.headers),
|
||||
'content-length': Buffer.byteLength(body, 'utf8'),
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
stripSegment(res, upstream, log);
|
||||
}
|
||||
|
||||
function stripSegment(
|
||||
res: http.ServerResponse,
|
||||
upstream: http.IncomingMessage,
|
||||
log: (message: string) => void,
|
||||
): void {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
|
||||
const respond = (data: Buffer, remainderFollows: boolean): void => {
|
||||
const offset = findTsSyncOffset(data) ?? 0;
|
||||
if (offset > 0) log(`[stream-proxy] stripped ${offset} disguise bytes off a segment`);
|
||||
const body = offset > 0 ? data.subarray(offset) : data;
|
||||
|
||||
const headers = forwardableResponseHeaders(upstream.headers);
|
||||
const upstreamLength = Number(upstream.headers['content-length']);
|
||||
if (remainderFollows) {
|
||||
if (Number.isFinite(upstreamLength)) headers['content-length'] = upstreamLength - offset;
|
||||
} else {
|
||||
headers['content-length'] = body.length;
|
||||
}
|
||||
|
||||
res.writeHead(upstream.statusCode ?? 200, headers);
|
||||
res.write(body);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer): void => {
|
||||
chunks.push(chunk);
|
||||
buffered += chunk.length;
|
||||
if (buffered < DECISION_BYTES) return;
|
||||
upstream.off('data', onData);
|
||||
upstream.off('end', onEnd);
|
||||
respond(Buffer.concat(chunks), true);
|
||||
upstream.pipe(res);
|
||||
};
|
||||
const onEnd = (): void => {
|
||||
respond(Buffer.concat(chunks), false);
|
||||
res.end();
|
||||
};
|
||||
upstream.on('data', onData);
|
||||
upstream.on('end', onEnd);
|
||||
}
|
||||
Reference in New Issue
Block a user