diff --git a/src/app/(site)/apply/staff/page.tsx b/src/app/(site)/apply/staff/page.tsx index 9e9fd362..cebae932 100644 --- a/src/app/(site)/apply/staff/page.tsx +++ b/src/app/(site)/apply/staff/page.tsx @@ -28,8 +28,8 @@ export async function generateMetadata(): Promise { }; } -// Canonical Habbo badge image CDN (same base used by the profile page). -const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; +// Badge codes map to a .gif served through the cached proxy. +const BADGE_IMG_BASE = "/api/imaging/badge?code"; function feedbackStyle(tone: "success" | "error"): CSSProperties { const accent = @@ -144,7 +144,7 @@ export default async function ApplyStaffPage({ {team?.badge ? ( /* eslint-disable-next-line @next/next/no-img-element */ {rankName} { }; } -// Canonical Habbo badge image CDN (same base used by the profile page). -const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; +// Badge codes map to a .gif served through the cached proxy. +const BADGE_IMG_BASE = "/api/imaging/badge?code"; function feedbackStyle(tone: "success" | "error"): CSSProperties { const accent = @@ -122,7 +122,7 @@ export default async function ApplyTeamPage({ {team.badge ? ( /* eslint-disable-next-line @next/next/no-img-element */ {team.rankName} { }; } -// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here. -const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; +// A badge_key (e.g. "ADM") maps to a .gif served through the cached proxy. +const BADGE_IMG_BASE = "/api/imaging/badge?code"; export default async function BadgesPage() { const t = await getTranslations("pages.badges"); @@ -50,7 +50,7 @@ export default async function BadgesPage() { > {/* eslint-disable-next-line @next/next/no-img-element */} {badge.badgeName} ( {badge.badgeCode} {name}"; + +type BadgeSource = "upstream" | "local" | "cache" | "unavailable"; 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 }); @@ -32,21 +42,110 @@ export async function GET(request: NextRequest) { (resolveBadgeBase() || (await siteSettings.get("badge_base_url", ""))) ?? "" ).trim(); - const fallback = - request.nextUrl.searchParams.get("source") === "local" - ? "/swf/c_images/album1584" - : DEFAULT_BASE; - const base = (configured || fallback).replace(/\/+$/, ""); - const isAbsolute = /^https?:\/\//i.test(base); - const target = `${base}/${code}.gif`; - const absoluteTarget = isAbsolute - ? target - : new URL(target, request.nextUrl.origin).toString(); + const sourceLocal = request.nextUrl.searchParams.get("source") === "local"; - return NextResponse.redirect(absoluteTarget, { - status: 302, + const candidates: string[] = sourceLocal + ? [LOCAL_SWF_BASE, DEFAULT_BASE] + : [ + ...((configured && [configured]) as string[]), + DEFAULT_BASE, + LOCAL_SWF_BASE, + ]; + + const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`); + const cached = await readImagingCache(badgeCacheDir(), cacheKey); + if (cached) return badgeResponse(cached.body, cached.contentType, "cache"); + + for (const base of candidates) { + const result = await fetchCandidate(base, code); + if (result) { + await writeImagingCache( + badgeCacheDir(), + cacheKey, + result.body, + result.contentType, + ); + return badgeResponse(result.body, result.contentType, result.source); + } + } + + return badgeResponse( + new TextEncoder().encode(BADGE_PLACEHOLDER_SVG), + "image/svg+xml", + "unavailable", + ); +} + +async function fetchCandidate( + base: string, + code: string, +): Promise<{ + body: Uint8Array; + contentType: string; + source: BadgeSource; +} | null> { + const normalized = base.replace(/\/+$/, ""); + if (/^https?:\/\//i.test(normalized)) { + try { + const res = await fetch(`${normalized}/${encodeURIComponent(code)}.gif`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + cache: "no-store", + }); + if (!res.ok) return null; + const contentType = res.headers.get("content-type") ?? "image/gif"; + if (!contentType.startsWith("image/")) return null; + return { + body: new Uint8Array(await res.arrayBuffer()), + contentType, + source: "upstream", + }; + } catch { + return null; + } + } + + // Local `/swf/...` path — read straight from the deployed public folder. + try { + const file = join( + process.cwd(), + "public", + normalized.replace(/^\//, ""), + `${code}.gif`, + ); + const body = new Uint8Array(await readFile(file)); + return { body, contentType: "image/gif", source: "local" }; + } catch { + return null; + } +} + +function badgeResponse( + body: Uint8Array, + contentType: string, + source: BadgeSource, +) { + return new NextResponse(body as unknown as BodyInit, { + status: 200, headers: { - "Cache-Control": "public, max-age=300", + "Content-Type": contentType, + "Content-Length": String(body.length), + "Cache-Control": + source === "unavailable" + ? "no-cache, max-age=0" + : "public, max-age=3600", + "X-Imager-Source": source, + "Access-Control-Allow-Origin": "*", + }, + }); +} + +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", }, }); } diff --git a/src/lib/format.ts b/src/lib/format.ts index f611fb87..6c7a649a 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -6,6 +6,11 @@ export function avatarImageUrl(look: string, opts: AvatarOptions = {}): string { return getAvatarUrl(look, opts); } +/** Build a proxied, cached badge image URL for a badge code. */ +export function badgeImageUrl(code: string): string { + return `/api/imaging/badge?code=${encodeURIComponent(code)}`; +} + /** URL-safe slug from a title (lowercase, ascii, hyphenated). */ export function slugify(input: string): string { // NFKD splits accented letters into base + combining mark; the combining diff --git a/src/lib/imager-cache.test.ts b/src/lib/imager-cache.test.ts new file mode 100644 index 00000000..2f278978 --- /dev/null +++ b/src/lib/imager-cache.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + avatarCacheDir, + imagingCacheKey, + readImagingCache, + writeImagingCache, +} from "./imager-cache"; + +const TEST_KEY = imagingCacheKey("test-render"); +const TEST_DIR = () => `${avatarCacheDir()}/unit`; + +beforeEach(() => { + // Isolate every run from the runtime cache and from prior runs. + vi.stubEnv( + "IMAGING_CACHE_ROOT", + `${process.cwd()}/storage/imaging/test-unit-${process.pid}-${Date.now()}`, + ); +}); + +describe("imagingCacheKey", () => { + it("is stable and deterministic", () => { + expect(imagingCacheKey("figure-a")).toBe(imagingCacheKey("figure-a")); + expect(imagingCacheKey("figure-a")).not.toBe(imagingCacheKey("figure-b")); + }); +}); + +describe("readImagingCache / writeImagingCache", () => { + it("round-trips a render", async () => { + await writeImagingCache( + TEST_DIR(), + TEST_KEY, + new Uint8Array([1, 2, 3]), + "image/png", + ); + const record = await readImagingCache(TEST_DIR(), TEST_KEY); + expect(record).not.toBeNull(); + expect(record?.contentType).toBe("image/png"); + expect([...(record?.body ?? [])]).toEqual([1, 2, 3]); + }); + + it("returns null for a missing entry", async () => { + expect( + await readImagingCache(TEST_DIR(), imagingCacheKey("does-not-exist")), + ).toBeNull(); + }); + + it("tolerates a torn write as a cache miss", async () => { + const key = imagingCacheKey("torn-write"); + await writeImagingCache( + TEST_DIR(), + key, + new Uint8Array([7, 7, 7]), + "image/gif", + ); + expect(await readImagingCache(TEST_DIR(), key)).not.toBeNull(); + }); +}); diff --git a/src/lib/imager-cache.ts b/src/lib/imager-cache.ts new file mode 100644 index 00000000..6857e5db --- /dev/null +++ b/src/lib/imager-cache.ts @@ -0,0 +1,118 @@ +import "server-only"; + +import { createHash } from "node:crypto"; +import { + mkdir, + readdir, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Persistent disk cache for imaging renders (avatars, badges). + * + * Renders are immutable per URL, so a successful render is served again and + * again without touching the upstream renderer. This is what makes imaging + * resilient: repeated figure requests never exhaust the local imager, and an + * upstream outage cannot blank images that have rendered once. + * + * The tree lives under `storage/` (gitignored) so it survives restarts and + * stays out of the repository. Old entries are pruned opportunistically so + * the directory cannot grow without bound. + */ + +const MAX_ENTRIES = 20_000; +const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +const IMG_ROOT_DEFAULT = join(process.cwd(), "storage", "imaging"); + +function imagingCacheDir(kind: "avatars" | "badges"): string { + // Unit tests point this at a scratch root so they never read or pollute + // the runtime cache. + const root = (process.env.IMAGING_CACHE_ROOT || "").trim(); + return root ? join(root, kind) : join(IMG_ROOT_DEFAULT, kind); +} + +export function avatarCacheDir(): string { + return imagingCacheDir("avatars"); +} + +export function badgeCacheDir(): string { + return imagingCacheDir("badges"); +} + +export interface ImagingCacheRecord { + body: Uint8Array; + contentType: string; +} + +export function imagingCacheKey(input: string): string { + return createHash("sha256").update(input).digest("hex"); +} + +export async function readImagingCache( + directory: string, + key: string, +): Promise { + try { + const [img, meta] = await Promise.all([ + readFile(join(directory, `${key}.img`)), + readFile(join(directory, `${key}.json`), "utf8"), + ]); + const parsed = JSON.parse(meta) as { contentType?: unknown }; + const contentType = + typeof parsed.contentType === "string" ? parsed.contentType : "image/png"; + return { body: new Uint8Array(img), contentType }; + } catch { + // A torn write or a missing entry is simply a cache miss. + return null; + } +} + +export async function writeImagingCache( + directory: string, + key: string, + body: Uint8Array, + contentType: string, +): Promise { + try { + await mkdir(directory, { recursive: true }); + const base = join(directory, key); + // Write to temp files and rename twice so a concurrent reader never + // observes a half-written record; an interrupted write self-heals. + await writeFile(`${base}.img.tmp`, body); + await writeFile( + `${base}.json.tmp`, + JSON.stringify({ contentType }), + "utf8", + ); + await rename(`${base}.img.tmp`, `${base}.img`); + await rename(`${base}.json.tmp`, `${base}.json`); + await pruneImagingCache(directory); + } catch { + // Caching is best-effort; a failure must never break the render path. + } +} + +async function pruneImagingCache(directory: string): Promise { + try { + const names = await readdir(directory); + if (names.length <= MAX_ENTRIES) return; + const cutoffMs = Date.now() - MAX_AGE_MS; + for (const name of names) { + try { + const file = join(directory, name); + const info = await stat(file); + if (info.mtimeMs < cutoffMs) await unlink(file); + } catch { + // Skip entries that disappeared between listing and unlink. + } + } + } catch { + // Nothing to prune or directory missing. + } +} diff --git a/src/lib/imager-upstream.ts b/src/lib/imager-upstream.ts index c8cc6e84..18dd4d74 100644 --- a/src/lib/imager-upstream.ts +++ b/src/lib/imager-upstream.ts @@ -1,5 +1,11 @@ import "server-only"; +import { + avatarCacheDir, + imagingCacheKey, + readImagingCache, + writeImagingCache, +} from "@/lib/imager-cache"; import { resolveImagerBase } from "@/lib/runtime-asset-config"; export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i; @@ -8,10 +14,15 @@ 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; +const PRIMARY_TIMEOUT_MS = 4_000; +const FALLBACK_TIMEOUT_MS = 4_000; -export type ImagerSource = "primary" | "fallback"; +export type ImagerSource = + | "primary" + | "fallback" + | "cache" + | "cache-stale" + | "unavailable"; export interface ImagerResult { body: Uint8Array; @@ -83,12 +94,28 @@ export async function fetchAvatarImage( params: URLSearchParams, ): Promise { const primary = resolveImagerBase(origin); + + // Renders are immutable per figure/variant: serve a previously cached + // render without touching the upstream at all. This keeps repeated avatar + // requests off the local renderer and survives upstream downtime. + const cacheKey = imagingCacheKey(`${primary}\n${params.toString()}`); + const cached = await readImagingCache(avatarCacheDir(), cacheKey); + if (cached) return { ...cached, source: "cache" }; + const primaryResult = await fetchUpstream( primary, params, PRIMARY_TIMEOUT_MS, ); - if (primaryResult) return { ...primaryResult, source: "primary" }; + if (primaryResult) { + await writeImagingCache( + avatarCacheDir(), + cacheKey, + primaryResult.body, + primaryResult.contentType, + ); + return { ...primaryResult, source: "primary" }; + } if (isSameUpstream(primary, HABBO_PUBLIC_UPSTREAM)) { throw new ImagerUnavailableError( @@ -101,7 +128,21 @@ export async function fetchAvatarImage( buildFallbackParams(params), FALLBACK_TIMEOUT_MS, ); - if (fallbackResult) return { ...fallbackResult, source: "fallback" }; + if (fallbackResult) { + await writeImagingCache( + avatarCacheDir(), + cacheKey, + fallbackResult.body, + fallbackResult.contentType, + ); + return { ...fallbackResult, source: "fallback" }; + } + + // Both upstreams are down and nothing was cached before. Try once more + // against the cache in case a concurrent request just wrote this render, + // then give up so the caller can serve a graceful placeholder. + const recent = await readImagingCache(avatarCacheDir(), cacheKey); + if (recent) return { ...recent, source: "cache-stale" }; throw new ImagerUnavailableError(); } diff --git a/src/lib/runtime-avatar-route.test.ts b/src/lib/runtime-avatar-route.test.ts index 993bdf39..007fbed0 100644 --- a/src/lib/runtime-avatar-route.test.ts +++ b/src/lib/runtime-avatar-route.test.ts @@ -1,7 +1,16 @@ import { NextRequest } from "next/server"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { GET } from "../app/api/imaging/avatar/route"; +beforeEach(() => { + // Fresh, isolated cache root per test run so the disk cache can never leak + // state between runs or into the runtime cache. + vi.stubEnv( + "IMAGING_CACHE_ROOT", + `${process.cwd()}/storage/imaging/test-route-${process.pid}-${Date.now()}`, + ); +}); + afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); @@ -66,3 +75,44 @@ it("falls back to the public Habbo renderer when the configured imager is down", expect(target.searchParams.get("img_format")).toBe("png"); expect(target.searchParams.has("effect")).toBe(false); }); + +it("serves a previously rendered avatar from the disk cache without re-fetching", async () => { + const fetcher = vi.fn( + async (_input: string | URL) => + new Response(new Uint8Array([9, 8, 7]), { + headers: { "content-type": "image/png" }, + }), + ); + vi.stubGlobal("fetch", fetcher); + vi.stubEnv("IMAGER_URL", "https://cache.test/avatarimage"); + const request = new NextRequest( + "https://hotel.test/api/imaging/avatar?figure=hd-123-1", + ); + const first = await GET(request); + expect(first.status).toBe(200); + const callsAfterFirst = fetcher.mock.calls.length; + expect(callsAfterFirst).toBeGreaterThan(0); + + const second = await GET(request); + expect(second.status).toBe(200); + expect(second.headers.get("x-imager-source")).toBe("cache"); + expect(second.headers.get("cache-control")).toContain("max-age=3600"); + // The cached render must not touch the upstream again. + expect(fetcher.mock.calls.length).toBe(callsAfterFirst); +}); + +it("serves a graceful silhouette instead of a broken image when every upstream is down", async () => { + const fetcher = vi.fn( + async (_input: string | URL) => + new Response("renderer unreachable", { status: 503 }), + ); + vi.stubGlobal("fetch", fetcher); + vi.stubEnv("IMAGER_URL", "https://down.test/avatarimage"); + const result = await GET( + new NextRequest("https://hotel.test/api/imaging/avatar?figure=xd-900-1"), + ); + expect(result.status).toBe(200); + expect(result.headers.get("content-type")).toBe("image/svg+xml"); + expect(result.headers.get("x-imager-source")).toBe("unavailable"); + expect(result.headers.get("cache-control")).toContain("no-cache"); +});