import { type NextRequest, NextResponse } from "next/server"; import sharp from "sharp"; import { resolveImagerBase } from "@/lib/runtime-asset-config"; const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i; const FIGURE_MAX_LEN = 512; const FIGURE_MAX_PARTS = 24; const UPSTREAM_TIMEOUT_MS = 10_000; 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 figure = ( searchParams.get("figure") ?? searchParams.get("look") ?? "" ).trim(); if (!figure) { return NextResponse.json( { error: "Missing figure parameter" }, { status: 400 }, ); } if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure)) { return NextResponse.json( { error: "Invalid figure format" }, { status: 400 }, ); } if (figure.split(".").length > FIGURE_MAX_PARTS) { return NextResponse.json( { error: "Figure has too many parts" }, { status: 400 }, ); } const sizeParam = searchParams.get("size"); const size: "s" | "m" | "l" = sizeParam === "s" || sizeParam === "l" ? sizeParam : "m"; const direction = clampInt(searchParams.get("direction"), 0, 7, 2); const headDirection = clampInt( searchParams.get("head_direction") ?? searchParams.get("headDirection"), 0, 7, 3, ); const headOnly = searchParams.get("headonly") === "1" || searchParams.get("headOnly") === "1"; const gesture = searchParams.get("gesture") ?? undefined; const action = searchParams.get("action") ?? undefined; const effect = searchParams.get("effect") ?? undefined; const params = new URLSearchParams({ figure, direction: String(direction), head_direction: String(headDirection), size, img_format: "png", }); if (headOnly) params.set("headonly", "1"); if (gesture) params.set("gesture", gesture); if (action) params.set("action", action); if (effect) params.set("effect", effect); const imgFormat = searchParams.get("img_format"); if (imgFormat) params.set("img_format", imgFormat); const format = resolveFormat(searchParams.get("format")); const upstream = resolveImagerBase(new URL(request.url).origin); const upstreamUrl = new URL(upstream); for (const [key, value] of params) upstreamUrl.searchParams.set(key, value); try { const res = await fetch(upstreamUrl, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, }); if (!res.ok) { return NextResponse.json( { error: "Upstream imager returned an error" }, { status: res.status }, ); } const buffer = Buffer.from(await res.arrayBuffer()); // Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth. // Falls back to the original PNG if conversion fails. let body: Uint8Array = new Uint8Array(buffer); let contentType = res.headers.get("content-type") || "image/png"; if (format !== "png") { try { if (format === "webp") { body = new Uint8Array(await sharp(buffer).webp().toBuffer()); contentType = "image/webp"; } else { body = new Uint8Array(await sharp(buffer).avif().toBuffer()); contentType = "image/avif"; } } catch { body = new Uint8Array(buffer); contentType = res.headers.get("content-type") || "image/png"; } } return new NextResponse(body as unknown as BodyInit, { status: 200, headers: { "Content-Type": contentType, "Content-Length": String(body.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. "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", "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", }, }); } function clampInt( raw: string | null, min: number, max: number, fallback: number, ): number { if (raw == null) return fallback; const n = Number.parseInt(raw, 10); if (Number.isNaN(n) || n < min || n > max) return fallback; return n; }