172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
import { lookup } from "node:dns/promises";
|
|
import { isIP } from "node:net";
|
|
|
|
const MAX_MEDIA_SOURCE_BYTES = 100 * 1024 * 1024;
|
|
|
|
const PRIVATE_IPV4_RANGES = [
|
|
/^10\./,
|
|
/^0\./,
|
|
/^127\./,
|
|
/^169\.254\./,
|
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
/^192\.168\./,
|
|
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
/^(22[4-9]|23\d|24\d|25[0-5])\./,
|
|
] as const;
|
|
|
|
function isPrivateAddress(address: string) {
|
|
const normalized = address.toLowerCase();
|
|
if (isIP(normalized) === 4) {
|
|
return PRIVATE_IPV4_RANGES.some((pattern) => pattern.test(normalized));
|
|
}
|
|
if (isIP(normalized) === 6) {
|
|
const mappedIpv4 = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
return (
|
|
normalized === "::" ||
|
|
normalized === "::1" ||
|
|
normalized.startsWith("fc") ||
|
|
normalized.startsWith("fd") ||
|
|
normalized.startsWith("fe80:") ||
|
|
Boolean(mappedIpv4 && PRIVATE_IPV4_RANGES.some((pattern) => pattern.test(mappedIpv4)))
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function resolvesToPrivateAddress(hostname: string) {
|
|
const normalized = hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
|
|
if (
|
|
normalized === "localhost" ||
|
|
normalized.endsWith(".localhost") ||
|
|
normalized.endsWith(".local") ||
|
|
normalized.endsWith(".internal")
|
|
) {
|
|
return true;
|
|
}
|
|
if (isIP(normalized)) return isPrivateAddress(normalized);
|
|
const addresses = await lookup(normalized, { all: true, verbatim: true });
|
|
return addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address));
|
|
}
|
|
|
|
type MediaSourceProxyOptions = {
|
|
accept: string;
|
|
contentTypePrefix: "audio/" | "video/";
|
|
extensionContentTypes: Readonly<Record<string, string>>;
|
|
};
|
|
|
|
function inferMediaContentType(sourceUrl: URL, options: MediaSourceProxyOptions) {
|
|
const extension = sourceUrl.pathname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
|
return extension ? options.extensionContentTypes[extension] : undefined;
|
|
}
|
|
|
|
async function proxyCanvasMediaSource(
|
|
request: Request,
|
|
options: MediaSourceProxyOptions,
|
|
fetcher: typeof fetch = fetch,
|
|
) {
|
|
const source = new URL(request.url).searchParams.get("url");
|
|
if (!source) {
|
|
return Response.json({ error: "Audio URL is required" }, { status: 400 });
|
|
}
|
|
|
|
let sourceUrl: URL;
|
|
try {
|
|
sourceUrl = new URL(source);
|
|
} catch {
|
|
return Response.json({ error: "Invalid audio URL" }, { status: 400 });
|
|
}
|
|
if (sourceUrl.protocol !== "https:") {
|
|
return Response.json({ error: "Unsupported audio URL" }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
if (await resolvesToPrivateAddress(sourceUrl.hostname)) {
|
|
return Response.json({ error: "Unsupported audio URL" }, { status: 400 });
|
|
}
|
|
const upstream = await fetcher(sourceUrl, {
|
|
headers: { Accept: options.accept },
|
|
redirect: "error",
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!upstream.ok || !upstream.body) {
|
|
return Response.json({ error: "Audio source unavailable" }, { status: 502 });
|
|
}
|
|
const upstreamContentType = upstream.headers.get("content-type")?.toLowerCase() || "";
|
|
const inferredContentType = inferMediaContentType(sourceUrl, options);
|
|
const isGenericOrMislabeled =
|
|
upstreamContentType === "application/octet-stream" || upstreamContentType === "text/plain";
|
|
if (
|
|
!upstreamContentType.startsWith(options.contentTypePrefix) &&
|
|
!(isGenericOrMislabeled && inferredContentType)
|
|
) {
|
|
return Response.json({ error: "Unsupported media response" }, { status: 415 });
|
|
}
|
|
const contentType = upstreamContentType.startsWith(options.contentTypePrefix)
|
|
? upstreamContentType
|
|
: inferredContentType || "application/octet-stream";
|
|
const contentLength = Number(upstream.headers.get("content-length") || 0);
|
|
if (contentLength > MAX_MEDIA_SOURCE_BYTES) {
|
|
return Response.json({ error: "Media source is too large" }, { status: 413 });
|
|
}
|
|
|
|
let transferredBytes = 0;
|
|
const limitedBody = upstream.body.pipeThrough(
|
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, controller) {
|
|
transferredBytes += chunk.byteLength;
|
|
if (transferredBytes > MAX_MEDIA_SOURCE_BYTES) {
|
|
throw new Error("Media source is too large");
|
|
}
|
|
controller.enqueue(chunk);
|
|
},
|
|
}),
|
|
);
|
|
|
|
return new Response(limitedBody, {
|
|
headers: {
|
|
"Cache-Control": "private, max-age=300",
|
|
"Content-Type": contentType || "application/octet-stream",
|
|
},
|
|
});
|
|
} catch {
|
|
return Response.json({ error: "Audio source unavailable" }, { status: 502 });
|
|
}
|
|
}
|
|
|
|
export function proxyCanvasAudioSource(request: Request, fetcher: typeof fetch = fetch) {
|
|
return proxyCanvasMediaSource(
|
|
request,
|
|
{
|
|
accept: "audio/*,application/octet-stream;q=0.8",
|
|
contentTypePrefix: "audio/",
|
|
extensionContentTypes: {
|
|
aac: "audio/aac",
|
|
flac: "audio/flac",
|
|
m4a: "audio/mp4",
|
|
mp3: "audio/mpeg",
|
|
ogg: "audio/ogg",
|
|
wav: "audio/wav",
|
|
},
|
|
},
|
|
fetcher,
|
|
);
|
|
}
|
|
|
|
export function proxyCanvasVideoSource(request: Request, fetcher: typeof fetch = fetch) {
|
|
return proxyCanvasMediaSource(
|
|
request,
|
|
{
|
|
accept: "video/*,application/octet-stream;q=0.8",
|
|
contentTypePrefix: "video/",
|
|
extensionContentTypes: {
|
|
m4v: "video/mp4",
|
|
mov: "video/quicktime",
|
|
mp4: "video/mp4",
|
|
ogv: "video/ogg",
|
|
webm: "video/webm",
|
|
},
|
|
},
|
|
fetcher,
|
|
);
|
|
}
|