Make avatar imager resilient with upstream fallback everywhere
This commit is contained in:
1 parent
1bbd809b43
commit
1df1ffc3e9
13 files changed
+277
-210
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user