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

+5 -4
View File
@@ -5,6 +5,7 @@ import { after } from "next/server";
import { z } from "zod";
import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password";
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db";
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.
// The hair part needs a colour slot (`hr-100-1`, not `hr-100-`) or the imager
// rejects the whole figure with 400 "Invalid figure format" and every avatar
// for the user renders broken.
const DEFAULT_LOOK = "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
// Shared with the imager URL builder, which normalises malformed stored looks
// to the same figure (the hair part needs a colour slot: `hr-100-1`, not
// `hr-100-`).
const DEFAULT_LOOK = DEFAULT_AVATAR_FIGURE;
export interface RegisterState {
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 {
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 {
avatarCacheDir,
imagingCacheKey,
@@ -9,13 +14,9 @@ import {
import { resolveImagerBase } from "@/lib/runtime-asset-config";
// 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
// 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 FIGURE_RE = /^[a-z]{2}-\d+(?:[.-][a-z0-9]+)*$/i;
export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24;
// is the authority on what it can draw. The rules live in `avatar-figure.ts` so
// the URL builder applies the exact same ones; the parser only enforces them.
export { FIGURE_MAX_LEN, FIGURE_MAX_PARTS, FIGURE_RE };
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 { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AVATAR_FIGURE } from "./avatar-figure";
import { getAvatarUrl } from "./imager";
import {
FALLBACK_TIMEOUT_MS,
@@ -52,6 +53,11 @@ describe("getAvatarUrl", () => {
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", () => {
const url = getAvatarUrl("hd-180-1", { img_format: "png" });
expect(url).toContain("img_format=png");
+2 -1
View File
@@ -7,6 +7,7 @@
export type { AvatarOptions } from "@/types/admin";
import type { SyntheticEvent } from "react";
import { normalizeAvatarFigure } from "@/lib/avatar-figure";
import type { AvatarOptions } from "@/types/admin";
/**
@@ -46,7 +47,7 @@ export function getAvatarUrl(
} = options;
const params = new URLSearchParams({
figure,
figure: normalizeAvatarFigure(figure),
img_format,
effect: String(effect),
});