93 lines
2.6 KiB
TypeScript
93 lines
2.6 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server";
|
|
import sharp from "sharp";
|
|
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
|
|
|
|
type AvatarFormat = "png" | "webp" | "avif";
|
|
|
|
function resolveFormat(raw: string | null): AvatarFormat {
|
|
switch (raw?.toLowerCase()) {
|
|
case "webp":
|
|
return "webp";
|
|
case "avif":
|
|
return "avif";
|
|
default:
|
|
return "png";
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const { searchParams } = new URL(request.url);
|
|
|
|
const parsed = parseAvatarQuery(searchParams);
|
|
if (!parsed.ok) {
|
|
return NextResponse.json({ error: parsed.message }, { status: 400 });
|
|
}
|
|
|
|
const format = resolveFormat(searchParams.get("format"));
|
|
|
|
try {
|
|
const { body, contentType, source } = await fetchAvatarImage(
|
|
new URL(request.url).origin,
|
|
parsed.params,
|
|
);
|
|
|
|
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
|
|
// Falls back to the original PNG if conversion fails.
|
|
let respBody: Uint8Array = body;
|
|
let respContentType = contentType;
|
|
if (format !== "png") {
|
|
try {
|
|
if (format === "webp") {
|
|
respBody = new Uint8Array(
|
|
await sharp(Buffer.from(body)).webp().toBuffer(),
|
|
);
|
|
respContentType = "image/webp";
|
|
} else {
|
|
respBody = new Uint8Array(
|
|
await sharp(Buffer.from(body)).avif().toBuffer(),
|
|
);
|
|
respContentType = "image/avif";
|
|
}
|
|
} catch {
|
|
respBody = body;
|
|
respContentType = contentType;
|
|
}
|
|
}
|
|
|
|
return new NextResponse(respBody as unknown as BodyInit, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": respContentType,
|
|
"Content-Length": String(respBody.length),
|
|
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
|
// stale-while-revalidate keeps them fresh without blocking requests.
|
|
// The ?format= param is part of the URL, so variants cache separately.
|
|
// Fallback renders use a shorter TTL so the configured imager is
|
|
// retried soon instead of being masked for hours.
|
|
"Cache-Control":
|
|
source === "fallback"
|
|
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
|
|
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
|
"X-Imager-Source": source,
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
});
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: "Failed to fetch avatar from upstream" },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function OPTIONS() {
|
|
return new NextResponse(null, {
|
|
status: 204,
|
|
headers: {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
|
|
},
|
|
});
|
|
}
|