[] {
alt=""
width={64}
height={110}
+ onError={avatarImageOnError}
className="h-[110px] w-16 shrink-0"
style={{ imageRendering: "pixelated" }}
/>
diff --git a/src/app/api/imaging/avatar/route.ts b/src/app/api/imaging/avatar/route.ts
index 60b447c4..d71365cd 100644
--- a/src/app/api/imaging/avatar/route.ts
+++ b/src/app/api/imaging/avatar/route.ts
@@ -1,99 +1,32 @@
import { type NextRequest, NextResponse } from "next/server";
-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;
+import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
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 },
- );
+ const parsed = parseAvatarQuery(searchParams);
+ if (!parsed.ok) {
+ return NextResponse.json({ error: parsed.message }, { 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 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",
- },
- });
+ const { body, contentType, source } = await fetchAvatarImage(
+ new URL(request.url).origin,
+ parsed.params,
+ );
- if (!res.ok) {
- return NextResponse.json(
- { error: "Upstream imager returned an error" },
- { status: res.status },
- );
- }
-
- const buffer = await res.arrayBuffer();
- const contentType = res.headers.get("content-type") || "image/png";
-
- return new NextResponse(buffer, {
+ return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
- "Cache-Control": "public, max-age=3600",
+ "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",
+ "X-Imager-Source": source,
"Access-Control-Allow-Origin": "*",
},
});
@@ -115,15 +48,3 @@ export async function OPTIONS() {
},
});
}
-
-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;
-}
diff --git a/src/app/imaging/route.ts b/src/app/imaging/route.ts
index e3c47e83..ef3fd61f 100644
--- a/src/app/imaging/route.ts
+++ b/src/app/imaging/route.ts
@@ -1,11 +1,6 @@
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;
+import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
type AvatarFormat = "png" | "webp" | "avif";
@@ -23,116 +18,57 @@ function resolveFormat(raw: string | null): AvatarFormat {
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 },
- );
+ const parsed = parseAvatarQuery(searchParams);
+ if (!parsed.ok) {
+ return NextResponse.json({ error: parsed.message }, { 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());
+ 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 body: Uint8Array = new Uint8Array(buffer);
- let contentType = res.headers.get("content-type") || "image/png";
+ let respBody: Uint8Array = body;
+ let respContentType = contentType;
if (format !== "png") {
try {
if (format === "webp") {
- body = new Uint8Array(await sharp(buffer).webp().toBuffer());
- contentType = "image/webp";
+ respBody = new Uint8Array(
+ await sharp(Buffer.from(body)).webp().toBuffer(),
+ );
+ respContentType = "image/webp";
} else {
- body = new Uint8Array(await sharp(buffer).avif().toBuffer());
- contentType = "image/avif";
+ respBody = new Uint8Array(
+ await sharp(Buffer.from(body)).avif().toBuffer(),
+ );
+ respContentType = "image/avif";
}
} catch {
- body = new Uint8Array(buffer);
- contentType = res.headers.get("content-type") || "image/png";
+ respBody = body;
+ respContentType = contentType;
}
}
- return new NextResponse(body as unknown as BodyInit, {
+ return new NextResponse(respBody as unknown as BodyInit, {
status: 200,
headers: {
- "Content-Type": contentType,
- "Content-Length": String(body.length),
+ "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":
- "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
+ 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": "*",
},
});
@@ -154,15 +90,3 @@ export async function OPTIONS() {
},
});
}
-
-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;
-}
diff --git a/src/app/mod/users/[id]/page.tsx b/src/app/mod/users/[id]/page.tsx
index 1661392b..de85abb2 100644
--- a/src/app/mod/users/[id]/page.tsx
+++ b/src/app/mod/users/[id]/page.tsx
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
-import { getAvatarUrl } from "@/lib/imager";
+import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
export default async function ModUserDetailPage({
@@ -49,6 +49,7 @@ export default async function ModUserDetailPage({
alt=""
width={64}
height={110}
+ onError={avatarImageOnError}
className="rounded"
/>
diff --git a/src/app/mod/users/page.tsx b/src/app/mod/users/page.tsx
index 7e37a595..b3dcb17c 100644
--- a/src/app/mod/users/page.tsx
+++ b/src/app/mod/users/page.tsx
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User } from "@/lib/db";
-import { getAvatarUrl } from "@/lib/imager";
+import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import type { DataTableColumn } from "@/types/common";
@@ -38,6 +38,7 @@ const columns: DataTableColumn[] = [
alt=""
width={32}
height={32}
+ onError={avatarImageOnError}
className="rounded"
/>
{row.username}
diff --git a/src/components/shared/avatar-image.tsx b/src/components/shared/avatar-image.tsx
index dc6f3e88..adc97efe 100644
--- a/src/components/shared/avatar-image.tsx
+++ b/src/components/shared/avatar-image.tsx
@@ -1,4 +1,8 @@
-import { type AvatarOptions, getAvatarUrl } from "@/lib/imager";
+import {
+ type AvatarOptions,
+ avatarImageOnError,
+ getAvatarUrl,
+} from "@/lib/imager";
interface AvatarImageProps {
figure: string;
@@ -33,6 +37,7 @@ export function AvatarImage({
height={height}
loading="lazy"
decoding="async"
+ onError={avatarImageOnError}
style={{ maxWidth: width, maxHeight: height }}
className={`image-pixelated object-contain ${className}`}
/>
diff --git a/src/lib/imager-upstream.ts b/src/lib/imager-upstream.ts
new file mode 100644
index 00000000..c8cc6e84
--- /dev/null
+++ b/src/lib/imager-upstream.ts
@@ -0,0 +1,161 @@
+import "server-only";
+
+import { resolveImagerBase } from "@/lib/runtime-asset-config";
+
+export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
+export const FIGURE_MAX_LEN = 512;
+export const FIGURE_MAX_PARTS = 24;
+
+const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
+
+const PRIMARY_TIMEOUT_MS = 10_000;
+const FALLBACK_TIMEOUT_MS = 6_000;
+
+export type ImagerSource = "primary" | "fallback";
+
+export interface ImagerResult {
+ body: Uint8Array;
+ contentType: string;
+ source: ImagerSource;
+}
+
+export type AvatarQuery =
+ | { ok: true; params: URLSearchParams }
+ | { ok: false; message: string };
+
+export class ImagerUnavailableError extends Error {
+ constructor(message = "Avatar imager unavailable") {
+ super(message);
+ this.name = "ImagerUnavailableError";
+ }
+}
+
+export function parseAvatarQuery(searchParams: URLSearchParams): AvatarQuery {
+ const figure = (
+ searchParams.get("figure") ??
+ searchParams.get("look") ??
+ ""
+ ).trim();
+ if (!figure) return { ok: false, message: "Missing figure parameter" };
+ if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure))
+ return { ok: false, message: "Invalid figure format" };
+ if (figure.split(".").length > FIGURE_MAX_PARTS)
+ return { ok: false, message: "Figure has too many parts" };
+
+ 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);
+
+ return { ok: true, params };
+}
+
+export async function fetchAvatarImage(
+ origin: string,
+ params: URLSearchParams,
+): Promise {
+ const primary = resolveImagerBase(origin);
+ const primaryResult = await fetchUpstream(
+ primary,
+ params,
+ PRIMARY_TIMEOUT_MS,
+ );
+ if (primaryResult) return { ...primaryResult, source: "primary" };
+
+ if (isSameUpstream(primary, HABBO_PUBLIC_UPSTREAM)) {
+ throw new ImagerUnavailableError(
+ "Configured imager is already the fallback upstream",
+ );
+ }
+
+ const fallbackResult = await fetchUpstream(
+ HABBO_PUBLIC_UPSTREAM,
+ buildFallbackParams(params),
+ FALLBACK_TIMEOUT_MS,
+ );
+ if (fallbackResult) return { ...fallbackResult, source: "fallback" };
+
+ throw new ImagerUnavailableError();
+}
+
+async function fetchUpstream(
+ baseUrl: string,
+ params: URLSearchParams,
+ timeoutMs: number,
+): Promise | null> {
+ const url = new URL(baseUrl);
+ for (const [key, value] of params) url.searchParams.set(key, value);
+
+ try {
+ const res = await fetch(url, {
+ signal: AbortSignal.timeout(timeoutMs),
+ cache: "no-store",
+ headers: {
+ "User-Agent":
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
+ },
+ });
+ if (!res.ok) return null;
+ return {
+ body: new Uint8Array(await res.arrayBuffer()),
+ contentType: res.headers.get("content-type") || "image/png",
+ };
+ } catch {
+ return null;
+ }
+}
+
+function buildFallbackParams(params: URLSearchParams): URLSearchParams {
+ const fallback = new URLSearchParams(params);
+ // The public Habbo renderer has no effect rendering support.
+ fallback.delete("effect");
+ // The public Habbo renderer emits PNG/GIF, not APNG.
+ fallback.set("img_format", "png");
+ return fallback;
+}
+
+function isSameUpstream(a: string, b: string): boolean {
+ const normalize = (value: string) =>
+ value.replace(/\/+$/, "").replace(/^https?:/, "");
+ return normalize(a) === normalize(b);
+}
+
+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;
+}
diff --git a/src/lib/imager.ts b/src/lib/imager.ts
index 3384be96..18ea68ce 100644
--- a/src/lib/imager.ts
+++ b/src/lib/imager.ts
@@ -6,8 +6,24 @@
export type { AvatarOptions } from "@/types/admin";
+import type { SyntheticEvent } from "react";
import type { AvatarOptions } from "@/types/admin";
+/**
+ * Neutral silhouette (grayscale SVG) shown when even the fallback upstream
+ * cannot produce a render — keeps a broken avatar from showing a broken-image
+ * glyph anywhere on the site.
+ */
+export const AVATAR_FALLBACK_SRC =
+ "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23e2e2e2'/%3E%3Ccircle cx='32' cy='24' r='11' fill='%239d9d9d'/%3E%3Cpath d='M10 60c5-10 13-14 22-14s17 4 22 14z' fill='%239d9d9d'/%3E%3C/svg%3E";
+
+export function avatarImageOnError(event: SyntheticEvent) {
+ const img = event.currentTarget;
+ if (img.dataset.fallback) return;
+ img.dataset.fallback = "1";
+ img.src = AVATAR_FALLBACK_SRC;
+}
+
/**
* Build an avatar image URL using the configured imager.
* Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility.
diff --git a/src/lib/runtime-avatar-route.test.ts b/src/lib/runtime-avatar-route.test.ts
index cb08ab96..993bdf39 100644
--- a/src/lib/runtime-avatar-route.test.ts
+++ b/src/lib/runtime-avatar-route.test.ts
@@ -42,3 +42,27 @@ it("rejects malformed figure input without contacting a renderer", async () => {
).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});
+it("falls back to the public Habbo renderer when the configured imager is down", async () => {
+ const fetcher = vi.fn(async (input: string | URL) => {
+ const url = new URL(String(input));
+ if (url.origin === "https://hotel.test")
+ return new Response("unavailable", { status: 503 });
+ return new Response(new Uint8Array([4, 5, 6]), {
+ headers: { "content-type": "image/png" },
+ });
+ });
+ vi.stubGlobal("fetch", fetcher);
+ vi.stubEnv("IMAGER_URL", "https://hotel.test/avatarimage");
+ const result = await GET(
+ new NextRequest(
+ "https://hotel.test/api/imaging/avatar?figure=hd-180-1&img_format=apng&effect=0",
+ ),
+ );
+ expect(result.status).toBe(200);
+ expect(result.headers.get("x-imager-source")).toBe("fallback");
+ expect(result.headers.get("cache-control")).toContain("max-age=300");
+ const target = new URL(String(fetcher.mock.calls.at(-1)?.[0]));
+ expect(target.host).toBe("www.habbo.com");
+ expect(target.searchParams.get("img_format")).toBe("png");
+ expect(target.searchParams.has("effect")).toBe(false);
+});