diff --git a/src/app/admin/import/clothing/import-clothing-client.tsx b/src/app/admin/import/clothing/import-clothing-client.tsx index cd556d10..3b423b8d 100644 --- a/src/app/admin/import/clothing/import-clothing-client.tsx +++ b/src/app/admin/import/clothing/import-clothing-client.tsx @@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { getAvatarUrl } from "@/lib/imager"; // ── Shared types ───────────────────────────────────────────────────────────── @@ -115,11 +116,15 @@ function AvatarPreview({ type, id }: { type?: string; id?: number }) { return ; } const figure = buildPreviewFigure(type, id); - const headOnly = HEAD_TYPES.has(type) ? "&headonly=1" : ""; return ( // biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset {`${type}-${id}`} setFailed(true)} diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts index 1fbe5a91..33952d72 100644 --- a/src/app/admin/settings/cms-settings-config.ts +++ b/src/app/admin/settings/cms-settings-config.ts @@ -91,13 +91,15 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [ fields: [ { key: "habbo_imaging_url", - label: "Habbo imaging upstream", + label: "Habbo imager URL", type: "url", defaultValue: "https://www.habbo.com/habbo-imaging/avatarimage", + description: + "Public Habbo avatar endpoint used in the browser. Do not point this at /api/imaging/avatar.", }, { key: "imaging_use_habbo_fallback", - label: "Use Habbo.com as avatar fallback", + label: "Use Habbo.com as avatar fallback (legacy proxy)", type: "boolean", defaultValue: "1", }, diff --git a/src/app/api/imaging/avatar/route.ts b/src/app/api/imaging/avatar/route.ts index 96b1945a..5c3ab318 100644 --- a/src/app/api/imaging/avatar/route.ts +++ b/src/app/api/imaging/avatar/route.ts @@ -1,14 +1,12 @@ import { type NextRequest, NextResponse } from "next/server"; -import { clientIp, rateLimit } from "@/lib/rate-limit"; -import { - type AvatarRenderResult, - renderAvatar, -} from "@/lib/services/imager/avatar-renderer"; +import { HABBO_IMAGER_URL, resolveImagerBase } from "@/lib/imager"; +import { siteSettings } from "@/lib/services/site-settings"; /** - * GET /api/imaging/avatar?figure=hr-115-42.hd-195-19&size=l&direction=2&... + * GET /api/imaging/avatar?figure=…&size=l&direction=2&… * - * Self-hosted avatar imager with disk + memory cache and Habbo upstream fallback. + * Redirects to Habbo's public imager. Server-side proxying fails in production + * (Habbo blocks datacenter IPs), so the browser loads the image from Habbo. */ const FIGURE_RE = /^[a-z]{2}-\d+/i; @@ -16,15 +14,6 @@ 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 = ( @@ -68,35 +57,33 @@ export async function GET(request: NextRequest) { const gesture = searchParams.get("gesture") ?? undefined; const action = searchParams.get("action") ?? undefined; - const result: AvatarRenderResult = await renderAvatar({ + const params = new URLSearchParams({ figure, + direction: String(direction), + head_direction: String(headDirection), size, - direction, - headDirection, - headOnly, - gesture, - action, }); + if (headOnly) params.set("headonly", "1"); + if (gesture) params.set("gesture", gesture); + if (action) params.set("action", 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", - }, - }); + let base = HABBO_IMAGER_URL; + try { + const configured = await siteSettings.get( + "habbo_imaging_url", + HABBO_IMAGER_URL, + ); + base = resolveImagerBase(configured); + } catch { + base = HABBO_IMAGER_URL; } - return new NextResponse(new Uint8Array(result.buffer), { - status: 200, + const target = `${base}?${params.toString()}`; + return NextResponse.redirect(target, { + status: 302, headers: { - "Content-Type": "image/png", - "Cache-Control": "public, max-age=604800, immutable", + "Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*", - ETag: result.etag, - "X-Imager-Source": result.source, }, }); } diff --git a/src/lib/format.ts b/src/lib/format.ts index f9c5fc81..c7855e09 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,16 +1,19 @@ +import { resolveImagerBase } from "@/lib/imager"; + /** Build a Habbo avatar-imager URL for a figure string. */ export function avatarImageUrl( base: string, look: string, opts: { size?: "s" | "m" | "l"; headOnly?: boolean; direction?: number } = {}, ): string { + const resolved = resolveImagerBase(base); const params = new URLSearchParams({ figure: look }); if (opts.size) params.set("size", opts.size); if (opts.headOnly) params.set("headonly", "1"); if (opts.direction !== undefined) params.set("direction", String(opts.direction)); - const sep = base.includes("?") ? "&" : "?"; - return `${base}${sep}${params.toString()}`; + const sep = resolved.includes("?") ? "&" : "?"; + return `${resolved}${sep}${params.toString()}`; } /** URL-safe slug from a title (lowercase, ascii, hyphenated). */ diff --git a/src/lib/imager.test.ts b/src/lib/imager.test.ts new file mode 100644 index 00000000..be3d76e0 --- /dev/null +++ b/src/lib/imager.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { getAvatarUrl, HABBO_IMAGER_URL, resolveImagerBase } from "./imager"; + +describe("resolveImagerBase", () => { + it("defaults to Habbo imager", () => { + expect(resolveImagerBase()).toBe(HABBO_IMAGER_URL); + expect(resolveImagerBase("")).toBe(HABBO_IMAGER_URL); + expect(resolveImagerBase(null)).toBe(HABBO_IMAGER_URL); + }); + + it("rejects the broken self-hosted proxy", () => { + expect(resolveImagerBase("/api/imaging/avatar")).toBe(HABBO_IMAGER_URL); + expect( + resolveImagerBase("https://www.epicnabbo.nl/api/imaging/avatar"), + ).toBe(HABBO_IMAGER_URL); + }); + + it("keeps a custom Habbo-compatible base", () => { + expect( + resolveImagerBase("https://www.habbo.com/habbo-imaging/avatarimage/"), + ).toBe("https://www.habbo.com/habbo-imaging/avatarimage"); + }); +}); + +describe("getAvatarUrl", () => { + it("builds a Habbo imager URL", () => { + const url = getAvatarUrl("hr-115-42.hd-180-1", { + size: "l", + headOnly: true, + direction: 2, + }); + expect(url.startsWith(`${HABBO_IMAGER_URL}?`)).toBe(true); + expect(url).toContain("figure=hr-115-42.hd-180-1"); + expect(url).toContain("size=l"); + expect(url).toContain("headonly=1"); + expect(url).toContain("direction=2"); + }); +}); diff --git a/src/lib/imager.ts b/src/lib/imager.ts index 61395404..f3da88db 100644 --- a/src/lib/imager.ts +++ b/src/lib/imager.ts @@ -1,18 +1,38 @@ /** - * Centralized Habbo avatar imager utility. - * Points to the self-hosted imager API (`/api/imaging/avatar`) which handles - * disk + memory caching and (Phase 2) local rendering from .nitro bundles. - * Override with NEXT_PUBLIC_IMAGER_URL when needed (e.g. CDN in front). + * Habbo avatar imager helpers. + * + * Images are loaded directly from Habbo's public imager. The old self-hosted + * `/api/imaging/avatar` proxy fails in production (Habbo blocks server-side + * fetches), so the browser always hits Habbo.com instead. */ -const IMAGER_URL = process.env.NEXT_PUBLIC_IMAGER_URL || "/api/imaging/avatar"; - export type { AvatarOptions } from "@/types/admin"; import type { AvatarOptions } from "@/types/admin"; +/** Official Habbo avatar imaging endpoint. */ +export const HABBO_IMAGER_URL = + "https://www.habbo.com/habbo-imaging/avatarimage"; + /** - * Build an avatar image URL served by the self-hosted imager. + * Resolve a usable imager base URL. + * Rejects the broken self-hosted proxy if it was stored in settings. + */ +export function resolveImagerBase(configured?: string | null): string { + const fromEnv = process.env.NEXT_PUBLIC_IMAGER_URL?.trim(); + if (fromEnv && !fromEnv.includes("/api/imaging/avatar")) { + return fromEnv.replace(/\/+$/, ""); + } + + const v = (configured ?? "").trim(); + if (!v || v.includes("/api/imaging/avatar")) { + return HABBO_IMAGER_URL; + } + return v.replace(/\/+$/, ""); +} + +/** + * Build an avatar image URL (Habbo imager by default). * * @example * getAvatarUrl('hr-893-45.hd-180-1.ch-210-66.lg-270-82') @@ -43,5 +63,6 @@ export function getAvatarUrl( if (gesture) params.set("gesture", gesture); if (action) params.set("action", action); - return `${IMAGER_URL}?${params.toString()}`; + const base = resolveImagerBase(); + return `${base}?${params.toString()}`; }