/** * Self-hosted avatar imager — Phase 1. * * Pipeline (cascade): * 1. In-memory LRU cache → instant * 2. Disk cache → fast * 3. In-flight dedup → coalesce thundering herd * 4. Upstream Habbo proxy → save to disk + memory * 5. Transparent fallback → never break the UI */ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { siteSettings } from "@/lib/services/site-settings"; import { InFlight, LruCache, NegativeCache } from "./memory-cache"; const CACHE_DIR = join(process.cwd(), "public", "cache", "avatars"); const DEFAULT_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage"; const MEM_CACHE_CAPACITY = 500; const NEGATIVE_CACHE_TTL_MS = 60_000; const UPSTREAM_TIMEOUT_MS = 10_000; // 1×1 transparent PNG (67 bytes) — served when both local and upstream fail. const TRANSPARENT_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=", "base64", ); export interface AvatarRenderOptions { figure: string; size?: "s" | "m" | "l"; direction?: number; headDirection?: number; headOnly?: boolean; gesture?: string; action?: string; } export interface AvatarRenderResult { buffer: Buffer; etag: string; source: "memory" | "disk" | "upstream" | "fallback"; } const memCache = new LruCache(MEM_CACHE_CAPACITY); const inFlight = new InFlight(); const negativeCache = new NegativeCache(NEGATIVE_CACHE_TTL_MS); function getCacheKey(opts: AvatarRenderOptions): string { const raw = [ opts.figure, opts.size ?? "m", opts.direction ?? 2, opts.headDirection ?? 3, opts.headOnly ? "h" : "f", opts.gesture ?? "", opts.action ?? "", ].join("_"); return createHash("md5").update(raw).digest("hex"); } function getCachePath(key: string): string { return join(CACHE_DIR, key.slice(0, 2), `${key}.png`); } function makeEtag(key: string): string { return `W/"${key}"`; } function saveToCache(cachePath: string, data: Buffer): void { try { const dir = dirname(cachePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(cachePath, data); } catch { /* non-fatal */ } } function isPng(buf: Buffer): boolean { return ( buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 ); } async function resolveUpstreamBase(): Promise { const fromEnv = process.env.IMAGING_UPSTREAM_URL?.trim(); if (fromEnv) return fromEnv.replace(/\/+$/, ""); const fromSettings = (await siteSettings.get("habbo_imaging_url", DEFAULT_UPSTREAM)) ?? DEFAULT_UPSTREAM; return fromSettings.replace(/\/+$/, "") || DEFAULT_UPSTREAM; } async function fetchUpstream(opts: AvatarRenderOptions): Promise { try { const params = new URLSearchParams({ figure: opts.figure, direction: String(opts.direction ?? 2), head_direction: String(opts.headDirection ?? 3), size: opts.size ?? "m", }); if (opts.headOnly) params.set("headonly", "1"); if (opts.gesture) params.set("gesture", opts.gesture); if (opts.action) params.set("action", opts.action); const base = await resolveUpstreamBase(); const url = `${base}?${params.toString()}`; const res = await fetch(url, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }, }); if (!res.ok) return null; const buffer = Buffer.from(await res.arrayBuffer()); return isPng(buffer) ? buffer : null; } catch { return null; } } /** * Get an avatar image. Always returns a result — never throws, never returns * null. Worst case is a 1×1 transparent PNG so the UI never shows broken * images. */ export async function renderAvatar( opts: AvatarRenderOptions, ): Promise { const key = getCacheKey(opts); const etag = makeEtag(key); const fromMem = memCache.get(key); if (fromMem) return { buffer: fromMem, etag, source: "memory" }; const cachePath = getCachePath(key); if (existsSync(cachePath)) { try { const buf = readFileSync(cachePath); memCache.set(key, buf); return { buffer: buf, etag, source: "disk" }; } catch { /* fall through to fetch */ } } if (negativeCache.isMarked(key)) { return { buffer: TRANSPARENT_PNG, etag, source: "fallback" }; } return inFlight.dedup(key, async () => { if (existsSync(cachePath)) { try { const buf = readFileSync(cachePath); memCache.set(key, buf); return { buffer: buf, etag, source: "disk" }; } catch { /* fall through */ } } const useUpstream = (await siteSettings.get("imaging_use_habbo_fallback", "1")) === "1"; if (useUpstream) { const upstream = await fetchUpstream(opts); if (upstream) { saveToCache(cachePath, upstream); memCache.set(key, upstream); return { buffer: upstream, etag, source: "upstream" }; } } negativeCache.mark(key); return { buffer: TRANSPARENT_PNG, etag, source: "fallback" }; }); } export function getImagerStats() { return { memCacheSize: memCache.size, memCacheCapacity: MEM_CACHE_CAPACITY, cacheDir: CACHE_DIR, }; }