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
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:
1 parent
1e3406a7ae
commit
61107848f8
7 files changed
+106
-14
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user