ai-chatbot/lib/video-canvas/audio-source-proxy.ts

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,
);
}