fix(imaging): make avatar and badge images resilient to upstream outages
CI / check (push) Successful in 4m11s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m51s

- 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
This commit is contained in:
openhands committed 2026-09-20 12:58:55 +02:00
1 parent c3ff497050
commit 8a66db4ed7
12 files changed
+458 -58

No files matched your search

+43 -8
View File
@@ -1,5 +1,33 @@
import { type NextRequest, NextResponse } from "next/server";
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
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);
@@ -20,17 +48,24 @@ export async function GET(request: NextRequest) {
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
// Fallback renders are served with a short TTL so the configured
// imager is retried soon instead of being masked for hours.
"Cache-Control":
source === "fallback"
? "public, max-age=300"
: "public, max-age=3600",
"Cache-Control": cacheControlFor(source),
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
} catch {
} 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 },
+124 -25
View File
@@ -1,28 +1,38 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { type NextRequest, NextResponse } from "next/server";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import {
badgeCacheDir,
imagingCacheKey,
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
import { siteSettings } from "@/lib/services/site-settings";
/**
* GET /api/imaging/badge?code=ADM
* GET /api/imaging/badge?code=ADM[&source=local]
*
* Resolves a Habbo badge code to its image URL and redirects there.
* Resolves a Habbo badge code to its image and serves it through a local
* caching proxy. Candidates are tried in order (configured CDN, public Habbo
* CDN, local `/swf` copy), so a flaky images.habbo.com never blanks a badge
* and every badge renders exactly once anywhere on the site.
*/
const CODE_RE = /^[A-Za-z0-9_+.-]+$/;
const CODE_MAX_LEN = 50;
const DEFAULT_BASE = "https://images.habbo.com/c_images/album1584";
const LOCAL_SWF_BASE = "/swf/c_images/album1584";
const FETCH_TIMEOUT_MS = 5_000;
// Neutral placeholder (grey rounded square) served as a 200 so broken badge
// glyphs can never appear, even when every candidate is unavailable.
const BADGE_PLACEHOLDER_SVG =
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 25'><rect width='40' height='25' rx='4' fill='%23e2e2e2'/></svg>";
type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
export async function GET(request: NextRequest) {
const ip = await clientIp();
const limited = await rateLimit(`badge-imaging:${ip}`, 60, 60_000);
if (!limited.ok) {
return new NextResponse(null, {
status: 429,
headers: { "Retry-After": String(limited.retryAfter) },
});
}
const code = request.nextUrl.searchParams.get("code")?.trim();
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
return NextResponse.json({ error: "Invalid badge code" }, { status: 400 });
@@ -32,21 +42,110 @@ export async function GET(request: NextRequest) {
(resolveBadgeBase() || (await siteSettings.get("badge_base_url", ""))) ??
""
).trim();
const fallback =
request.nextUrl.searchParams.get("source") === "local"
? "/swf/c_images/album1584"
: DEFAULT_BASE;
const base = (configured || fallback).replace(/\/+$/, "");
const isAbsolute = /^https?:\/\//i.test(base);
const target = `${base}/${code}.gif`;
const absoluteTarget = isAbsolute
? target
: new URL(target, request.nextUrl.origin).toString();
const sourceLocal = request.nextUrl.searchParams.get("source") === "local";
return NextResponse.redirect(absoluteTarget, {
status: 302,
const candidates: string[] = sourceLocal
? [LOCAL_SWF_BASE, DEFAULT_BASE]
: [
...((configured && [configured]) as string[]),
DEFAULT_BASE,
LOCAL_SWF_BASE,
];
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
if (cached) return badgeResponse(cached.body, cached.contentType, "cache");
for (const base of candidates) {
const result = await fetchCandidate(base, code);
if (result) {
await writeImagingCache(
badgeCacheDir(),
cacheKey,
result.body,
result.contentType,
);
return badgeResponse(result.body, result.contentType, result.source);
}
}
return badgeResponse(
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
"image/svg+xml",
"unavailable",
);
}
async function fetchCandidate(
base: string,
code: string,
): Promise<{
body: Uint8Array;
contentType: string;
source: BadgeSource;
} | null> {
const normalized = base.replace(/\/+$/, "");
if (/^https?:\/\//i.test(normalized)) {
try {
const res = await fetch(`${normalized}/${encodeURIComponent(code)}.gif`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
cache: "no-store",
});
if (!res.ok) return null;
const contentType = res.headers.get("content-type") ?? "image/gif";
if (!contentType.startsWith("image/")) return null;
return {
body: new Uint8Array(await res.arrayBuffer()),
contentType,
source: "upstream",
};
} catch {
return null;
}
}
// Local `/swf/...` path — read straight from the deployed public folder.
try {
const file = join(
process.cwd(),
"public",
normalized.replace(/^\//, ""),
`${code}.gif`,
);
const body = new Uint8Array(await readFile(file));
return { body, contentType: "image/gif", source: "local" };
} catch {
return null;
}
}
function badgeResponse(
body: Uint8Array,
contentType: string,
source: BadgeSource,
) {
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Cache-Control": "public, max-age=300",
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control":
source === "unavailable"
? "no-cache, max-age=0"
: "public, max-age=3600",
"X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
}
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",
},
});
}