Files
Epicnabbo-Catalogus-Updated…/src/lib/services/imager/avatar-renderer.ts
T
SimoandCursor 9d4d409b77 Restore self-hosted /api/imaging/avatar (and badge) endpoints.
The clothing importer and imager helpers pointed at a missing route; proxy Habbo with disk/memory cache so avatars work again.

Co-authored-by: Cursor <[email protected]>
2026-07-15 20:41:18 +02:00

191 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Buffer>(MEM_CACHE_CAPACITY);
const inFlight = new InFlight<AvatarRenderResult>();
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<string> {
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<Buffer | null> {
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<AvatarRenderResult> {
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,
};
}