436 lines
12 KiB
TypeScript
436 lines
12 KiB
TypeScript
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;
|
|
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
|
|
|
|
type OutputFormat = "webp" | "avif" | "jpeg" | "png";
|
|
type CachedImageProxyResponse = {
|
|
body: Buffer;
|
|
contentType: string;
|
|
etag: string;
|
|
vary?: string;
|
|
};
|
|
|
|
const memoryCache = new Map<string, CachedImageProxyResponse>();
|
|
let memoryCacheBytes = 0;
|
|
|
|
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 getEnvPositiveInt(name: string, fallback: number) {
|
|
const parsed = Number.parseInt(process.env[name] || "", 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function getMemoryCacheMaxBytes() {
|
|
return getEnvPositiveInt(
|
|
"IMAGE_PROXY_MEMORY_CACHE_MAX_BYTES",
|
|
DEFAULT_MEMORY_CACHE_MAX_BYTES
|
|
);
|
|
}
|
|
|
|
function getMemoryCacheMaxItems() {
|
|
return getEnvPositiveInt(
|
|
"IMAGE_PROXY_MEMORY_CACHE_MAX_ITEMS",
|
|
DEFAULT_MEMORY_CACHE_MAX_ITEMS
|
|
);
|
|
}
|
|
|
|
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 buildImageResponse(
|
|
body: Buffer,
|
|
params: {
|
|
contentType: string;
|
|
etag: string;
|
|
vary?: string;
|
|
cacheStatus?: "HIT" | "MISS";
|
|
}
|
|
) {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": params.contentType,
|
|
"Content-Length": String(body.length),
|
|
ETag: params.etag,
|
|
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
|
};
|
|
if (params.vary) {
|
|
headers.Vary = params.vary;
|
|
}
|
|
if (params.cacheStatus) {
|
|
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
|
|
}
|
|
return new NextResponse(body, {
|
|
status: 200,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
function getCachedImageResponse(cacheKey: string) {
|
|
const cached = memoryCache.get(cacheKey);
|
|
if (!cached) return null;
|
|
memoryCache.delete(cacheKey);
|
|
memoryCache.set(cacheKey, cached);
|
|
return cached;
|
|
}
|
|
|
|
function setCachedImageResponse(cacheKey: string, cached: CachedImageProxyResponse) {
|
|
const maxBytes = getMemoryCacheMaxBytes();
|
|
const maxItems = getMemoryCacheMaxItems();
|
|
if (maxBytes <= 0 || maxItems <= 0 || cached.body.length > maxBytes) {
|
|
return;
|
|
}
|
|
|
|
const existing = memoryCache.get(cacheKey);
|
|
if (existing) {
|
|
memoryCacheBytes -= existing.body.length;
|
|
memoryCache.delete(cacheKey);
|
|
}
|
|
|
|
memoryCache.set(cacheKey, cached);
|
|
memoryCacheBytes += cached.body.length;
|
|
|
|
while (memoryCache.size > maxItems || memoryCacheBytes > maxBytes) {
|
|
const firstKey = memoryCache.keys().next().value;
|
|
if (!firstKey) break;
|
|
const removed = memoryCache.get(firstKey);
|
|
memoryCache.delete(firstKey);
|
|
if (removed) {
|
|
memoryCacheBytes -= removed.body.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 forceProxy = searchParams.get("proxy") === "1";
|
|
|
|
if (!forceProxy) {
|
|
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",
|
|
},
|
|
});
|
|
}
|
|
|
|
const cached = getCachedImageResponse(cacheKey);
|
|
if (cached) {
|
|
return buildImageResponse(cached.body, {
|
|
contentType: cached.contentType,
|
|
etag: cached.etag,
|
|
vary: cached.vary,
|
|
cacheStatus: "HIT",
|
|
});
|
|
}
|
|
|
|
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) {
|
|
setCachedImageResponse(cacheKey, {
|
|
body: originalBuffer,
|
|
contentType,
|
|
etag,
|
|
});
|
|
return buildImageResponse(originalBuffer, {
|
|
contentType,
|
|
etag,
|
|
cacheStatus: "MISS",
|
|
});
|
|
}
|
|
|
|
if (isSvg || isGif) {
|
|
setCachedImageResponse(cacheKey, {
|
|
body: originalBuffer,
|
|
contentType,
|
|
etag,
|
|
});
|
|
return buildImageResponse(originalBuffer, {
|
|
contentType,
|
|
etag,
|
|
cacheStatus: "MISS",
|
|
});
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
setCachedImageResponse(cacheKey, {
|
|
body: outputBuffer,
|
|
contentType: outputContentType,
|
|
etag,
|
|
vary: "Accept",
|
|
});
|
|
return buildImageResponse(outputBuffer, {
|
|
contentType: outputContentType,
|
|
etag,
|
|
vary: "Accept",
|
|
cacheStatus: "MISS",
|
|
});
|
|
} catch (error) {
|
|
console.error("[ImageProxy] Transform failed:", error);
|
|
setCachedImageResponse(cacheKey, {
|
|
body: originalBuffer,
|
|
contentType,
|
|
etag,
|
|
});
|
|
return buildImageResponse(originalBuffer, {
|
|
contentType,
|
|
etag,
|
|
cacheStatus: "MISS",
|
|
});
|
|
}
|
|
}
|