Files
gitlab-instance-0a899031_cn…/app/api/media/proxy/route.ts

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