From 1df1ffc3e934e03f7a0de5a019b6aaea051e5f86 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 14 Sep 2026 17:35:36 +0200 Subject: [PATCH] Make avatar imager resilient with upstream fallback everywhere --- README.md | 6 +- src/app/admin/online/online-table.tsx | 10 +- .../permissions/[id]/rank-edit-client.tsx | 4 +- src/app/admin/users/_components/user-hero.tsx | 3 +- src/app/admin/users/users-table.tsx | 3 +- src/app/api/imaging/avatar/route.ts | 113 ++---------- src/app/imaging/route.ts | 134 ++++----------- src/app/mod/users/[id]/page.tsx | 3 +- src/app/mod/users/page.tsx | 3 +- src/components/shared/avatar-image.tsx | 7 +- src/lib/imager-upstream.ts | 161 ++++++++++++++++++ src/lib/imager.ts | 16 ++ src/lib/runtime-avatar-route.test.ts | 24 +++ 13 files changed, 277 insertions(+), 210 deletions(-) create mode 100644 src/lib/imager-upstream.ts diff --git a/README.md b/README.md index 050c36b2..80cc8d2e 100644 --- a/README.md +++ b/README.md @@ -682,7 +682,11 @@ The container runs `restart: unless-stopped` and ships a healthcheck that probes curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l' ``` -A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which takes the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`). +A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which used to take the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`). + +### Automatic upstream fallback + +Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph. ### Figure string validation (`src/app/api/imaging/avatar/route.ts`) diff --git a/src/app/admin/online/online-table.tsx b/src/app/admin/online/online-table.tsx index 4f71975e..32398c26 100644 --- a/src/app/admin/online/online-table.tsx +++ b/src/app/admin/online/online-table.tsx @@ -7,7 +7,7 @@ import { DataTable } from "@/components/admin/data-table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { formatDate } from "@/lib/format-date"; -import { getAvatarUrl } from "@/lib/imager"; +import { avatarImageOnError, getAvatarUrl } from "@/lib/imager"; import type { DataTableColumn, PaginatedResult } from "@/types/common"; interface OnlineUserRow { @@ -28,6 +28,7 @@ const columns: DataTableColumn[] = [ {row.username} @@ -51,7 +52,12 @@ const columns: DataTableColumn[] = [ key: "look", label: "Avatar", render: (_, row) => ( - {row.username} + {row.username} ), }, ]; diff --git a/src/app/admin/permissions/[id]/rank-edit-client.tsx b/src/app/admin/permissions/[id]/rank-edit-client.tsx index 8f218905..35617393 100644 --- a/src/app/admin/permissions/[id]/rank-edit-client.tsx +++ b/src/app/admin/permissions/[id]/rank-edit-client.tsx @@ -29,7 +29,7 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useServerAction } from "@/hooks/use-server-action"; -import { getAvatarUrl } from "@/lib/imager"; +import { avatarImageOnError, getAvatarUrl } from "@/lib/imager"; import { cn } from "@/lib/utils"; import { categorizePerms, PermToggle } from "./permission-controls"; import type { RankEditorProps } from "./rank-editor-types"; @@ -472,6 +472,7 @@ export function RankEditClient({ headDirection: 2, })} alt="" + onError={avatarImageOnError} className="w-8 h-12 object-cover" /> @@ -689,6 +690,7 @@ export function RankEditClient({ headDirection: 2, })} alt="" + onError={avatarImageOnError} className="w-8 h-12 object-cover" /> diff --git a/src/app/admin/users/_components/user-hero.tsx b/src/app/admin/users/_components/user-hero.tsx index d809f2b2..dac07e5b 100644 --- a/src/app/admin/users/_components/user-hero.tsx +++ b/src/app/admin/users/_components/user-hero.tsx @@ -18,7 +18,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { formatNumber } from "@/lib/format-date"; -import { getAvatarUrl } from "@/lib/imager"; +import { avatarImageOnError, getAvatarUrl } from "@/lib/imager"; import { userDetailHref, userListHref } from "../user-list-navigation"; interface UserHeroProps { @@ -83,6 +83,7 @@ export function UserHero({ {user.username} diff --git a/src/app/admin/users/users-table.tsx b/src/app/admin/users/users-table.tsx index 13cf3d7a..5007ecfe 100644 --- a/src/app/admin/users/users-table.tsx +++ b/src/app/admin/users/users-table.tsx @@ -32,7 +32,7 @@ import { SelectValue, } from "@/components/ui/select"; import { formatDate, formatNumber } from "@/lib/format-date"; -import { getAvatarUrl } from "@/lib/imager"; +import { avatarImageOnError, getAvatarUrl } from "@/lib/imager"; import type { DataTableColumn, PaginatedResult } from "@/types/common"; interface UserRow { @@ -81,6 +81,7 @@ function getColumns(canEdit: boolean): DataTableColumn[] { 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); +});