import crypto from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; export const runtime = "nodejs"; const DEFAULT_QUALITY = 82; const MAX_WIDTH = 4096; const MIN_WIDTH = 64; const MIN_QUALITY = 40; const MAX_QUALITY = 100; const FETCH_TIMEOUT_MS = 15000; type OutputFormat = "webp" | "avif" | "jpeg" | "png"; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function parsePositiveInt(value: string | null): number | null { if (!value) return null; const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } function isPrivateIpv4(hostname: string): boolean { const parts = hostname.split(".").map((part) => Number.parseInt(part, 10)); if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) { return false; } if (parts[0] === 10) return true; if (parts[0] === 127) return true; if (parts[0] === 169 && parts[1] === 254) return true; if (parts[0] === 192 && parts[1] === 168) return true; if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; return false; } function isBlockedHost(hostname: string): boolean { const normalized = hostname.trim().toLowerCase(); if (!normalized) return true; if ( normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local") || normalized.endsWith(".internal") ) { return true; } if (isPrivateIpv4(normalized)) { return true; } if (normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80")) { return true; } return false; } function inferSourceFormat(contentType: string): OutputFormat { const lower = contentType.toLowerCase(); if (lower.includes("avif")) return "avif"; if (lower.includes("webp")) return "webp"; if (lower.includes("png")) return "png"; return "jpeg"; } function resolveOutputFormat( requested: string | null, sourceContentType: string, hasAlpha: boolean | undefined ): OutputFormat { if (requested === "avif" || requested === "webp" || requested === "jpeg" || requested === "png") { if (requested === "jpeg" && hasAlpha) { return "webp"; } return requested; } if (hasAlpha) { return "webp"; } return inferSourceFormat(sourceContentType); } function buildEtag(input: string): string { return `"${crypto.createHash("sha1").update(input).digest("hex")}"`; } function isBrowserImageRequest(request: NextRequest): boolean { return request.headers.get("sec-fetch-dest") === "image"; } function isAliyunOssHost(hostname: string): boolean { const normalized = hostname.toLowerCase(); return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-"); } function appendAliyunImageProcess( target: URL, width: number | null, quality: number | null, requestedFormat: string | null ): URL | null { if (target.searchParams.has("x-oss-process")) { return width || quality || requestedFormat ? null : target; } const processSteps: string[] = ["image"]; if (width) { processSteps.push(`resize,w_${width}`); } if (quality) { processSteps.push(`quality,q_${quality}`); } if (requestedFormat) { const format = requestedFormat === "jpeg" ? "jpg" : requestedFormat; if (format !== "webp" && format !== "jpg" && format !== "png") { return null; } processSteps.push(`format,${format}`); } const redirected = new URL(target.toString()); redirected.searchParams.set("x-oss-process", processSteps.join("/")); return redirected; } function resolveDirectImageTarget( request: NextRequest, target: URL, width: number | null, requestedQuality: number | null, requestedFormat: string | null ): URL | null { if (!isBrowserImageRequest(request)) { return null; } const hasTransformRequest = Boolean(width || requestedQuality || requestedFormat); if (!hasTransformRequest) { return target; } if (isAliyunOssHost(target.hostname)) { return appendAliyunImageProcess(target, width, requestedQuality, requestedFormat); } return null; } function redirectToDirectImage(target: URL): NextResponse { const response = NextResponse.redirect(target, 307); response.headers.set( "Cache-Control", "public, max-age=31536000, immutable, stale-while-revalidate=86400" ); response.headers.set("X-Image-Proxy-Bypass", "direct-image-request"); return response; } export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const rawUrl = (searchParams.get("url") || "").trim(); if (!rawUrl) { return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); } let target: URL; try { target = new URL(rawUrl); } catch { return NextResponse.json({ error: "Invalid image url" }, { status: 400 }); } if (target.protocol !== "http:" && target.protocol !== "https:") { return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 }); } if (isBlockedHost(target.hostname)) { return NextResponse.json({ error: "Blocked target host" }, { status: 403 }); } const requestedWidth = parsePositiveInt(searchParams.get("w")); const requestedQuality = parsePositiveInt(searchParams.get("q")); const width = requestedWidth ? clamp(requestedWidth, MIN_WIDTH, MAX_WIDTH) : null; const quality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : DEFAULT_QUALITY; const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null; const requestedFormat = searchParams.get("f"); const directImageTarget = resolveDirectImageTarget( request, target, width, directQuality, requestedFormat ); if (directImageTarget) { return redirectToDirectImage(directImageTarget); } const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`; const etag = buildEtag(cacheKey); const ifNoneMatch = request.headers.get("if-none-match"); if (ifNoneMatch && ifNoneMatch === etag) { return new NextResponse(null, { status: 304, headers: { ETag: etag, "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", }, }); } let upstreamResponse: Response; try { upstreamResponse = await fetch(target.toString(), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", }, }); } catch (error) { console.error("[ImageProxy] Upstream fetch failed:", error); return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 }); } if (!upstreamResponse.ok) { return NextResponse.json( { error: `Upstream responded with ${upstreamResponse.status}` }, { status: 502 } ); } const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream"; if (!contentType.toLowerCase().startsWith("image/")) { return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); } const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); const isSvg = contentType.toLowerCase().includes("image/svg"); const isGif = contentType.toLowerCase().includes("image/gif"); if ((isSvg || isGif) && !width && !requestedFormat) { return new NextResponse(originalBuffer, { status: 200, headers: { "Content-Type": contentType, "Content-Length": String(originalBuffer.length), ETag: etag, "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", }, }); } if (isSvg || isGif) { return new NextResponse(originalBuffer, { status: 200, headers: { "Content-Type": contentType, "Content-Length": String(originalBuffer.length), ETag: etag, "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", }, }); } try { const sharp = await import("sharp"); const image = sharp.default(originalBuffer, { failOn: "none" }).rotate(); const metadata = await image.metadata(); const outputFormat = resolveOutputFormat(requestedFormat, contentType, metadata.hasAlpha); const transformed = width ? image.resize({ width, fit: "inside", withoutEnlargement: true, }) : image; let outputBuffer: Buffer; let outputContentType = contentType; if (outputFormat === "avif") { outputBuffer = await transformed.avif({ quality }).toBuffer(); outputContentType = "image/avif"; } else if (outputFormat === "webp") { outputBuffer = await transformed.webp({ quality }).toBuffer(); outputContentType = "image/webp"; } else if (outputFormat === "png") { outputBuffer = await transformed.png({ compressionLevel: 9 }).toBuffer(); outputContentType = "image/png"; } else { outputBuffer = await transformed.jpeg({ quality, mozjpeg: true }).toBuffer(); outputContentType = "image/jpeg"; } return new NextResponse(outputBuffer, { status: 200, headers: { "Content-Type": outputContentType, "Content-Length": String(outputBuffer.length), ETag: etag, Vary: "Accept", "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", }, }); } catch (error) { console.error("[ImageProxy] Transform failed:", error); return new NextResponse(originalBuffer, { status: 200, headers: { "Content-Type": contentType, "Content-Length": String(originalBuffer.length), ETag: etag, "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", }, }); } }