From 9d4d409b77af525151cd0f135f7cdaa855077936 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 15 Jul 2026 20:41:18 +0200 Subject: [PATCH] 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 --- .gitignore | 5 +- src/app/api/imaging/avatar/route.ts | 125 ++++++++++++++ src/app/api/imaging/badge/route.ts | 46 +++++ src/lib/services/imager/avatar-renderer.ts | 190 +++++++++++++++++++++ src/lib/services/imager/memory-cache.ts | 97 +++++++++++ 5 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 src/app/api/imaging/avatar/route.ts create mode 100644 src/app/api/imaging/badge/route.ts create mode 100644 src/lib/services/imager/avatar-renderer.ts create mode 100644 src/lib/services/imager/memory-cache.ts diff --git a/.gitignore b/.gitignore index d2327426..f5b8be99 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,9 @@ next-env.d.ts *.tsbuildinfo # Prisma client is generated by `prisma generate` src/generated/ -# Runtime logs (not part of the codebase) -storage/logs/ +# Runtime avatar/badge imaging disk cache +public/cache/ + prod.log *.log diff --git a/src/app/api/imaging/avatar/route.ts b/src/app/api/imaging/avatar/route.ts new file mode 100644 index 00000000..96b1945a --- /dev/null +++ b/src/app/api/imaging/avatar/route.ts @@ -0,0 +1,125 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { + type AvatarRenderResult, + renderAvatar, +} from "@/lib/services/imager/avatar-renderer"; + +/** + * GET /api/imaging/avatar?figure=hr-115-42.hd-195-19&size=l&direction=2&... + * + * Self-hosted avatar imager with disk + memory cache and Habbo upstream fallback. + */ + +const FIGURE_RE = /^[a-z]{2}-\d+/i; +const FIGURE_MAX_LEN = 512; +const FIGURE_MAX_PARTS = 24; + +export async function GET(request: NextRequest) { + const ip = await clientIp(); + const limited = await rateLimit(`avatar-imaging:${ip}`, 60, 60_000); + if (!limited.ok) { + return new NextResponse(null, { + status: 429, + headers: { "Retry-After": String(limited.retryAfter) }, + }); + } + + const { searchParams } = new URL(request.url); + + const figure = ( + searchParams.get("figure") ?? + searchParams.get("look") ?? + "" + ).trim(); + if (!figure) { + return NextResponse.json( + { error: "Missing figure parameter" }, + { status: 400 }, + ); + } + if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure)) { + return NextResponse.json( + { error: "Invalid figure format" }, + { status: 400 }, + ); + } + if (figure.split(".").length > FIGURE_MAX_PARTS) { + return NextResponse.json( + { error: "Figure has too many parts" }, + { status: 400 }, + ); + } + + 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 result: AvatarRenderResult = await renderAvatar({ + figure, + size, + direction, + headDirection, + headOnly, + gesture, + action, + }); + + const ifNoneMatch = request.headers.get("if-none-match"); + if (ifNoneMatch && ifNoneMatch === result.etag) { + return new NextResponse(null, { + status: 304, + headers: { + ETag: result.etag, + "Cache-Control": "public, max-age=604800, immutable", + }, + }); + } + + return new NextResponse(new Uint8Array(result.buffer), { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=604800, immutable", + "Access-Control-Allow-Origin": "*", + ETag: result.etag, + "X-Imager-Source": result.source, + }, + }); +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, If-None-Match", + }, + }); +} + +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; +} diff --git a/src/app/api/imaging/badge/route.ts b/src/app/api/imaging/badge/route.ts new file mode 100644 index 00000000..6c7b31b5 --- /dev/null +++ b/src/app/api/imaging/badge/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { siteSettings } from "@/lib/services/site-settings"; + +/** + * GET /api/imaging/badge?code=ADM + * + * Resolves a Habbo badge code to its image URL and redirects there. + */ + +const CODE_RE = /^[A-Za-z0-9_+.-]+$/; +const CODE_MAX_LEN = 50; +const DEFAULT_BASE = "https://images.habbo.com/c_images/album1584"; + +export async function GET(request: NextRequest) { + const ip = await clientIp(); + const limited = await rateLimit(`badge-imaging:${ip}`, 60, 60_000); + if (!limited.ok) { + return new NextResponse(null, { + status: 429, + headers: { "Retry-After": String(limited.retryAfter) }, + }); + } + + const code = request.nextUrl.searchParams.get("code")?.trim(); + if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) { + return NextResponse.json({ error: "Invalid badge code" }, { status: 400 }); + } + + const configured = ( + (await siteSettings.get("badge_base_url", "")) ?? "" + ).trim(); + const base = (configured || DEFAULT_BASE).replace(/\/+$/, ""); + const isAbsolute = /^https?:\/\//i.test(base); + const target = `${base}/${code}.gif`; + const absoluteTarget = isAbsolute + ? target + : new URL(target, request.nextUrl.origin).toString(); + + return NextResponse.redirect(absoluteTarget, { + status: 302, + headers: { + "Cache-Control": "public, max-age=300", + }, + }); +} diff --git a/src/lib/services/imager/avatar-renderer.ts b/src/lib/services/imager/avatar-renderer.ts new file mode 100644 index 00000000..8e44df52 --- /dev/null +++ b/src/lib/services/imager/avatar-renderer.ts @@ -0,0 +1,190 @@ +/** + * 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, + }; +} diff --git a/src/lib/services/imager/memory-cache.ts b/src/lib/services/imager/memory-cache.ts new file mode 100644 index 00000000..c65afe49 --- /dev/null +++ b/src/lib/services/imager/memory-cache.ts @@ -0,0 +1,97 @@ +/** + * Tiny LRU + in-flight dedup utilities for the avatar imager. + * Pure in-house, zero deps. Map preserves insertion order, so we + * implement LRU by deleting + re-inserting on access. + */ + +export class LruCache { + private map = new Map(); + + constructor(private readonly capacity: number) {} + + get(key: string): V | undefined { + const v = this.map.get(key); + if (v === undefined) return undefined; + this.map.delete(key); + this.map.set(key, v); + return v; + } + + set(key: string, value: V): void { + if (this.map.has(key)) this.map.delete(key); + this.map.set(key, value); + if (this.map.size > this.capacity) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + } + + has(key: string): boolean { + return this.map.has(key); + } + + delete(key: string): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } + + get size(): number { + return this.map.size; + } +} + +/** + * In-flight request deduplication. If N callers ask for the same key + * concurrently, only the first one runs `loader` and the rest await + * the same Promise. + */ +export class InFlight { + private pending = new Map>(); + + async dedup(key: string, loader: () => Promise): Promise { + const existing = this.pending.get(key); + if (existing) return existing; + const p = loader().finally(() => { + this.pending.delete(key); + }); + this.pending.set(key, p); + return p; + } +} + +/** + * Negative cache: marks a key as "known to fail" for `ttlMs` so we don't + * hammer the upstream / re-attempt expensive renders for broken figures. + */ +export class NegativeCache { + private map = new Map(); + + constructor(private readonly ttlMs: number) {} + + isMarked(key: string): boolean { + const exp = this.map.get(key); + if (exp === undefined) return false; + if (Date.now() > exp) { + this.map.delete(key); + return false; + } + return true; + } + + mark(key: string): void { + this.map.set(key, Date.now() + this.ttlMs); + if (this.map.size > 1000) { + const now = Date.now(); + for (const [k, exp] of this.map) { + if (now > exp) this.map.delete(k); + } + } + } + + clear(): void { + this.map.clear(); + } +}