From 71f154cf5275fadca91fc16af8c1ec1bfb3a283a Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 15 Jul 2026 22:14:58 +0200 Subject: [PATCH] Add /imaging route for avatar proxy and prevent redirect loops --- src/app/imaging/route.ts | 110 +++++++++++++++++++++++++++++++++++++++ src/lib/imager.test.ts | 4 ++ src/lib/imager.ts | 4 +- 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/app/imaging/route.ts diff --git a/src/app/imaging/route.ts b/src/app/imaging/route.ts new file mode 100644 index 00000000..436ccaf9 --- /dev/null +++ b/src/app/imaging/route.ts @@ -0,0 +1,110 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { HABBO_IMAGER_URL, resolveImagerBase } from "@/lib/imager"; +import { siteSettings } from "@/lib/services/site-settings"; + +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 { 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 params = new URLSearchParams({ + figure, + direction: String(direction), + head_direction: String(headDirection), + size, + img_format: "png", + }); + if (headOnly) params.set("headonly", "1"); + if (gesture) params.set("gesture", gesture); + if (action) params.set("action", action); + + 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; + } + + // Preserve any img_format passed by the caller + const imgFormat = searchParams.get("img_format"); + if (imgFormat) params.set("img_format", imgFormat); + + const target = `${base}?${params.toString()}`; + return NextResponse.redirect(target, { + status: 302, + headers: { + "Cache-Control": "public, max-age=3600", + "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", + }, + }); +} + +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/lib/imager.test.ts b/src/lib/imager.test.ts index 27ecd66b..095924f6 100644 --- a/src/lib/imager.test.ts +++ b/src/lib/imager.test.ts @@ -13,6 +13,10 @@ describe("resolveImagerBase", () => { expect( resolveImagerBase("https://www.epicnabbo.nl/api/imaging/avatar"), ).toBe(HABBO_IMAGER_URL); + expect(resolveImagerBase("/imaging")).toBe(HABBO_IMAGER_URL); + expect( + resolveImagerBase("https://www.epicnabbo.nl/imaging"), + ).toBe(HABBO_IMAGER_URL); }); it("keeps a custom Habbo-compatible base", () => { diff --git a/src/lib/imager.ts b/src/lib/imager.ts index 2616e3bc..3661d396 100644 --- a/src/lib/imager.ts +++ b/src/lib/imager.ts @@ -20,12 +20,12 @@ export const HABBO_IMAGER_URL = */ export function resolveImagerBase(configured?: string | null): string { const fromEnv = process.env.NEXT_PUBLIC_IMAGER_URL?.trim(); - if (fromEnv && !fromEnv.includes("/api/imaging/avatar")) { + if (fromEnv && !fromEnv.includes("/api/imaging/avatar") && !fromEnv.includes("/imaging")) { return fromEnv.replace(/\/+$/, ""); } const v = (configured ?? "").trim(); - if (!v || v.includes("/api/imaging/avatar")) { + if (!v || v.includes("/api/imaging/avatar") || v.includes("/imaging")) { return HABBO_IMAGER_URL; } return v.replace(/\/+$/, "");