mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-04 11:54:28 -07:00
- Reject non-origin-form proxy targets - Release stale anime browser sessions when senders move
213 lines
5.8 KiB
TypeScript
213 lines
5.8 KiB
TypeScript
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);
|
|
}
|