329 lines
9.8 KiB
TypeScript
329 lines
9.8 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;
|
|
|
|
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 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",
|
|
},
|
|
});
|
|
}
|
|
|
|
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",
|
|
},
|
|
});
|
|
}
|
|
}
|