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