126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
import { createHash } from "crypto";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { ensurePublicBucket, getMinioClient } from "@/app/digital/lib/minio/client";
|
|
import { getMinioConfig } from "@/app/digital/lib/minio/config";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const DEFAULT_MAX_BYTES = 250 * 1024 * 1024;
|
|
const MAX_BYTES = Number(process.env.MEDIA_PROXY_MAX_BYTES || DEFAULT_MAX_BYTES);
|
|
const BLOCKED_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
|
|
|
|
const EXT_BY_TYPE: Record<string, string> = {
|
|
"image/jpeg": "jpg",
|
|
"image/png": "png",
|
|
"image/webp": "webp",
|
|
"image/gif": "gif",
|
|
"image/svg+xml": "svg",
|
|
"video/mp4": "mp4",
|
|
"video/webm": "webm",
|
|
"video/quicktime": "mov",
|
|
"audio/mpeg": "mp3",
|
|
"audio/mp4": "m4a",
|
|
"audio/wav": "wav",
|
|
"application/pdf": "pdf",
|
|
"application/zip": "zip",
|
|
};
|
|
|
|
function safeSource(raw: string | null): URL | null {
|
|
if (!raw) return null;
|
|
try {
|
|
const url = new URL(raw);
|
|
if (!["http:", "https:"].includes(url.protocol)) return null;
|
|
if (BLOCKED_HOSTS.has(url.hostname)) return null;
|
|
if (/^(10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[0-1])\.|192\.168\.)/.test(url.hostname)) return null;
|
|
return url;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extensionFromSource(url: URL, contentType?: string | null): string {
|
|
const type = (contentType || "").split(";")[0].trim().toLowerCase();
|
|
if (EXT_BY_TYPE[type]) return EXT_BY_TYPE[type];
|
|
const match = url.pathname.match(/\.([a-z0-9]{2,8})$/i);
|
|
return match?.[1]?.toLowerCase() || "bin";
|
|
}
|
|
|
|
function publicObjectUrl(publicUrl: string, objectKey: string): string {
|
|
return `${publicUrl.replace(/\/$/, "")}/${objectKey}`;
|
|
}
|
|
|
|
async function objectExists(client: ReturnType<typeof getMinioClient>, bucket: string, objectKey: string): Promise<boolean> {
|
|
try {
|
|
await client.statObject(bucket, objectKey);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function passthroughResponse(buffer: Buffer, contentType: string): NextResponse {
|
|
return new NextResponse(new Uint8Array(buffer), {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": "public, max-age=86400",
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const source = safeSource(request.nextUrl.searchParams.get("src"));
|
|
if (!source) {
|
|
return NextResponse.json({ ok: false, error: "Invalid media source" }, { status: 400 });
|
|
}
|
|
|
|
let remote: Response;
|
|
try {
|
|
remote = await fetch(source, { redirect: "follow" });
|
|
} catch {
|
|
return NextResponse.json({ ok: false, error: "Media fetch failed" }, { status: 502 });
|
|
}
|
|
if (!remote.ok) {
|
|
return NextResponse.json({ ok: false, error: `Media fetch failed: ${remote.status}` }, { status: 502 });
|
|
}
|
|
|
|
const contentLength = Number(remote.headers.get("content-length") || 0);
|
|
if (contentLength > MAX_BYTES) {
|
|
return NextResponse.json({ ok: false, error: "Media is too large" }, { status: 413 });
|
|
}
|
|
|
|
const contentType = remote.headers.get("content-type") || "application/octet-stream";
|
|
const buffer = Buffer.from(await remote.arrayBuffer());
|
|
if (buffer.length > MAX_BYTES) {
|
|
return NextResponse.json({ ok: false, error: "Media is too large" }, { status: 413 });
|
|
}
|
|
|
|
const cfg = getMinioConfig();
|
|
if (!cfg.enabled) {
|
|
return passthroughResponse(buffer, contentType);
|
|
}
|
|
|
|
try {
|
|
const client = getMinioClient(cfg);
|
|
await ensurePublicBucket(client, cfg.bucket);
|
|
|
|
const prefix = `${cfg.uploadPrefix.replace(/\/$/, "")}/remote`;
|
|
const hash = createHash("sha256").update(source.toString()).digest("hex").slice(0, 32);
|
|
const ext = extensionFromSource(source, contentType);
|
|
const objectKey = `${prefix}/${hash}.${ext}`;
|
|
|
|
if (await objectExists(client, cfg.bucket, objectKey)) {
|
|
return NextResponse.redirect(publicObjectUrl(cfg.publicUrl, objectKey), 307);
|
|
}
|
|
|
|
await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
});
|
|
|
|
return NextResponse.redirect(publicObjectUrl(cfg.publicUrl, objectKey), 307);
|
|
} catch {
|
|
return passthroughResponse(buffer, contentType);
|
|
}
|
|
}
|