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

+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);
});