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
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()}`;
}