fix(imaging): normalise avatar figures before building URLs
CI / check (push) Successful in 39s
CI / tests-integration (push) Successful in 2m2s
CI / tests-unit (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m46s
CI / preflight (push) Skipped
CI / tests-imaging-browsers (push) Successful in 3m9s
CI / tests-quality (push) Successful in 1m24s
CI / deploy (push) Successful in 4m9s

Legacy accounts can carry a malformed stored look — the old register default
`hr-100-.hd-180-1...` has an empty colour slot — which the imager rejects with
400 "Invalid figure format", so the avatar renders broken everywhere the look
appears. Normalise the figure in the shared URL builder (`getAvatarUrl`, used by
every avatar on the site): an unrenderable look becomes `DEFAULT_AVATAR_FIGURE`
while the proxy route keeps rejecting malformed input with 400.

The rule set and default now live in one client-safe module
(`src/lib/avatar-figure.ts`), shared by the URL builder and the server parser.
This commit is contained in:
openhands committed 2026-10-15 18:00:05 +02:00
1 parent 1e3406a7ae
commit 61107848f8
7 files changed
+106 -14

No files matched your search

+13 -2
View File
@@ -639,9 +639,20 @@ A production issue that this section documents: **the `avatar-imaging-pixinode`
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph. Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
### Figure string validation (`src/app/api/imaging/avatar/route.ts`) ### Figure string validation
The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens. The URL builder (`src/lib/imager.ts`) and the proxy routes share one rule set
(`src/lib/avatar-figure.ts`): a figure must look like `part-id` segments
separated by dots (`hd-180-1.ch-210-66`). Some clients append extra segments the
renderer still accepts (e.g. a trailing `.0`), so the pattern is deliberately
loose; over-strict validation once turned valid avatars into 400s.
Before a URL is built, the stored look is normalised: anything the imager would
reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a
path-traversal attempt, an over-long value — is replaced with
`DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken
avatars without weakening the route, which still answers `400` to a malformed
figure (`src/app/api/imaging/avatar/route.ts`).
--- ---
+5 -4
View File
@@ -5,6 +5,7 @@ import { after } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { sendVerification } from "@/lib/auth/email-verification"; import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
import { invalidateKey } from "@/lib/cached-db"; import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -169,10 +170,10 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
}; };
// A valid starter Habbo figure so the avatar renders in-client immediately. // A valid starter Habbo figure so the avatar renders in-client immediately.
// The hair part needs a colour slot (`hr-100-1`, not `hr-100-`) or the imager // Shared with the imager URL builder, which normalises malformed stored looks
// rejects the whole figure with 400 "Invalid figure format" and every avatar // to the same figure (the hair part needs a colour slot: `hr-100-1`, not
// for the user renders broken. // `hr-100-`).
const DEFAULT_LOOK = "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; const DEFAULT_LOOK = DEFAULT_AVATAR_FIGURE;
export interface RegisterState { export interface RegisterState {
error: string | null; error: string | null;
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_AVATAR_FIGURE, normalizeAvatarFigure } from "./avatar-figure";
describe("normalizeAvatarFigure", () => {
it("keeps a figure the imager can draw", () => {
const figure =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0.0";
expect(normalizeAvatarFigure(figure)).toBe(figure);
});
it("trims surrounding whitespace", () => {
expect(normalizeAvatarFigure(" hd-180-1 ")).toBe("hd-180-1");
});
it("replaces a stored look with an empty colour slot", () => {
// The legacy register default: `hr-100-` has no colour, so the imager
// rejects the whole figure with 400 and the avatar renders broken.
expect(
normalizeAvatarFigure("hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"),
).toBe(DEFAULT_AVATAR_FIGURE);
});
it.each([
"",
" ",
"0",
"../bad",
"hd-180-1/../x",
`hd-180-${"x".repeat(600)}`,
`hd-180${".a-1".repeat(30)}`,
])("replaces %j with the default", (value) => {
expect(normalizeAvatarFigure(value)).toBe(DEFAULT_AVATAR_FIGURE);
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Client-safe avatar figure rules, shared by the URL builder (`imager.ts`) and
* the server-side query parser (`imager-upstream.ts`).
*
* The imager is the authority on what it can draw: a figure with an empty part
* slot (e.g. `hr-100-.hd-180-1.ch-255-66`) is rejected with 400 "Invalid figure
* format". Legacy accounts can still carry such stored looks, so the URL
* builder normalises them to a valid default instead of emitting a request that
* renders as a broken image.
*/
/** Valid starter figure — also the default look new accounts are given. */
export const DEFAULT_AVATAR_FIGURE =
"hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
/**
* The first segment must look like a real part (`hd-180`); the rest is
* deliberately loose because some clients append extra segments (e.g. a
* trailing `.0`) that the renderer still accepts. Over-strict validation here
* turned valid avatars into 400s.
*/
export const AVATAR_FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
export const AVATAR_FIGURE_MAX_LEN = 512;
export const AVATAR_FIGURE_MAX_PARTS = 24;
/**
* Return `figure` unchanged when the imager can draw it, otherwise the default
* figure. Trims surrounding whitespace either way.
*/
export function normalizeAvatarFigure(figure: string): string {
const trimmed = figure.trim();
if (!trimmed || trimmed.length > AVATAR_FIGURE_MAX_LEN)
return DEFAULT_AVATAR_FIGURE;
if (!AVATAR_FIGURE_RE.test(trimmed)) return DEFAULT_AVATAR_FIGURE;
if (trimmed.split(".").length > AVATAR_FIGURE_MAX_PARTS)
return DEFAULT_AVATAR_FIGURE;
return trimmed;
}
+8 -7
View File
@@ -1,5 +1,10 @@
import "server-only"; import "server-only";
import {
AVATAR_FIGURE_MAX_LEN as FIGURE_MAX_LEN,
AVATAR_FIGURE_MAX_PARTS as FIGURE_MAX_PARTS,
AVATAR_FIGURE_RE as FIGURE_RE,
} from "@/lib/avatar-figure";
import { import {
avatarCacheDir, avatarCacheDir,
imagingCacheKey, imagingCacheKey,
@@ -9,13 +14,9 @@ import {
import { resolveImagerBase } from "@/lib/runtime-asset-config"; import { resolveImagerBase } from "@/lib/runtime-asset-config";
// The client's figure strings are passed straight through to the imager, which // The client's figure strings are passed straight through to the imager, which
// is the authority on what it can draw. Only the first segment is required to // is the authority on what it can draw. The rules live in `avatar-figure.ts` so
// look like a real part (`hd-180`); the rest is deliberately loose because some // the URL builder applies the exact same ones; the parser only enforces them.
// clients append extra segments (e.g. a trailing `.0`) that the renderer still export { FIGURE_MAX_LEN, FIGURE_MAX_PARTS, FIGURE_RE };
// accepts. Over-strict validation here turned valid avatars into 400s.
export const FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24;
const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage"; const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
+6
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AVATAR_FIGURE } from "./avatar-figure";
import { getAvatarUrl } from "./imager"; import { getAvatarUrl } from "./imager";
import { import {
FALLBACK_TIMEOUT_MS, FALLBACK_TIMEOUT_MS,
@@ -52,6 +53,11 @@ describe("getAvatarUrl", () => {
expect(url).not.toContain("&x=1"); expect(url).not.toContain("&x=1");
}); });
it("falls back to the default when the stored look is malformed", () => {
const url = getAvatarUrl("hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62");
expect(url).toContain(`figure=${DEFAULT_AVATAR_FIGURE}`);
});
it("allows custom img_format", () => { it("allows custom img_format", () => {
const url = getAvatarUrl("hd-180-1", { img_format: "png" }); const url = getAvatarUrl("hd-180-1", { img_format: "png" });
expect(url).toContain("img_format=png"); expect(url).toContain("img_format=png");
+2 -1
View File
@@ -7,6 +7,7 @@
export type { AvatarOptions } from "@/types/admin"; export type { AvatarOptions } from "@/types/admin";
import type { SyntheticEvent } from "react"; import type { SyntheticEvent } from "react";
import { normalizeAvatarFigure } from "@/lib/avatar-figure";
import type { AvatarOptions } from "@/types/admin"; import type { AvatarOptions } from "@/types/admin";
/** /**
@@ -46,7 +47,7 @@ export function getAvatarUrl(
} = options; } = options;
const params = new URLSearchParams({ const params = new URLSearchParams({
figure, figure: normalizeAvatarFigure(figure),
img_format, img_format,
effect: String(effect), effect: String(effect),
}); });