Your Name 769862cee9
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: improve detail stitch and model recovery
2026-05-14 16:35:16 +08:00

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