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 = { "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, bucket: string, objectKey: string): Promise { 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); } }