Make avatar imager resilient with upstream fallback everywhere
CI / check (push) Failing after 24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
openhands committed 2026-09-14 17:35:36 +02:00
1 parent 1bbd809b43
commit 1df1ffc3e9
13 files changed
+277 -210

No files matched your search

+8 -2
View File
@@ -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<OnlineUserRow>[] = [
<img
src={getAvatarUrl(row.look, { size: "s", headOnly: true })}
alt=""
onError={avatarImageOnError}
className="h-8 w-8 rounded"
/>
<span className="font-medium">{row.username}</span>
@@ -51,7 +52,12 @@ const columns: DataTableColumn<OnlineUserRow>[] = [
key: "look",
label: "Avatar",
render: (_, row) => (
<img src={getAvatarUrl(row.look)} alt={row.username} className="h-16" />
<img
src={getAvatarUrl(row.look)}
alt={row.username}
onError={avatarImageOnError}
className="h-16"
/>
),
},
];
@@ -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"
/>
<span className="text-xs font-medium truncate">
@@ -689,6 +690,7 @@ export function RankEditClient({
headDirection: 2,
})}
alt=""
onError={avatarImageOnError}
className="w-8 h-12 object-cover"
/>
<span className="text-xs font-medium truncate">
@@ -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({
<img
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
alt={user.username}
onError={avatarImageOnError}
className="h-28 rounded-lg border bg-card shadow-sm"
style={{ imageRendering: "pixelated" }}
/>
+2 -1
View File
@@ -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<UserRow>[] {
alt=""
width={64}
height={110}
onError={avatarImageOnError}
className="h-[110px] w-16 shrink-0"
style={{ imageRendering: "pixelated" }}
/>
+17 -96
View File
@@ -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;
}
+29 -105
View File
@@ -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;
}
+2 -1
View File
@@ -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"
/>
<div>
+2 -1
View File
@@ -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<ModUserRow>[] = [
alt=""
width={32}
height={32}
onError={avatarImageOnError}
className="rounded"
/>
<span className="font-medium">{row.username}</span>
+6 -1
View File
@@ -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}`}
/>
+161
View File
@@ -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<ImagerResult> {
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<Omit<ImagerResult, "source"> | 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;
}
+16
View File
@@ -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<HTMLImageElement>) {
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.
+24
View File
@@ -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);
});