Files
EpicNext-Cms/src/app/api/imaging/avatar/route.ts
T
openhands 8a66db4ed7
CI / check (push) Successful in 4m11s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m51s
fix(imaging): make avatar and badge images resilient to upstream outages
- Add persistent disk cache for rendered avatars/badges (storage/imaging)
  so repeats never touch the flaky local renderer and cached renders
  survive upstream downtime
- Serve cache-first with stale-on-error; cut primary/fallback timeouts
  from 10s/6s to 4s/4s so failing images cannot stall pages
- Avatar proxy now returns a graceful 200 silhouette instead of 502 when
  no renderer can produce a figure, so no broken-image glyphs appear
- Badge endpoint becomes a caching proxy trying configured CDN, public
  Habbo CDN and local /swf copy in order, and drops the fragile IP rate
  limit that could blank badge streams
- Route all site badge images (profile, me, badges, apply pages) through
  the cached proxy instead of hot-linking images.habbo.com
2026-09-20 12:58:55 +02:00

86 lines
2.4 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
import {
fetchAvatarImage,
ImagerUnavailableError,
parseAvatarQuery,
} from "@/lib/imager-upstream";
// The same neutral silhouette the client fallback uses, served as a real 200
// response when no upstream can render the figure. Images on server-rendered
// pages therefore never show a broken-image glyph; browsers revalidate quickly
// (`no-cache`) and pick up real renders as soon as an upstream recovers.
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
decodeURIComponent(
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
),
);
function cacheControlFor(source: string): string {
switch (source) {
case "fallback":
case "cache-stale":
// Reuse the render briefly but revalidate the upstream soon.
return "public, max-age=300";
case "unavailable":
return "no-cache, max-age=0";
default:
return "public, max-age=3600";
}
}
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 });
}
try {
const { body, contentType, source } = await fetchAvatarImage(
new URL(request.url).origin,
parsed.params,
);
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": cacheControlFor(source),
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
if (error instanceof ImagerUnavailableError) {
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "image/svg+xml",
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
"Cache-Control": cacheControlFor("unavailable"),
"X-Imager-Source": "unavailable",
"Access-Control-Allow-Origin": "*",
},
});
}
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",
},
});
}