Make avatar imager resilient with upstream fallback everywhere
CI / check (push) Failing after 24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
openhands committed 2026-09-14 17:35:36 +02:00
1 parent 1bbd809b43
commit 1df1ffc3e9
13 files changed
+275 -208

No files matched your search

+5 -1
View File
@@ -682,7 +682,11 @@ The container runs `restart: unless-stopped` and ships a healthcheck that probes
curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l'
```
A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which takes the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`).
A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which used to take the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`).
### Automatic upstream fallback
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`)
+8 -2
View File
@@ -7,7 +7,7 @@ import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/format-date";
import { getAvatarUrl } from "@/lib/imager";
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface OnlineUserRow {
@@ -28,6 +28,7 @@ const columns: DataTableColumn<OnlineUserRow>[] = [
<img
src={getAvatarUrl(row.look, { size: "s", headOnly: true })}
alt=""
onError={avatarImageOnError}
className="h-8 w-8 rounded"
/>
<span className="font-medium">{row.username}</span>
@@ -51,7 +52,12 @@ const columns: DataTableColumn<OnlineUserRow>[] = [
key: "look",
label: "Avatar",
render: (_, row) => (
<img src={getAvatarUrl(row.look)} alt={row.username} className="h-16" />
<img
src={getAvatarUrl(row.look)}
alt={row.username}
onError={avatarImageOnError}
className="h-16"
/>
),
},
];
@@ -29,7 +29,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useServerAction } from "@/hooks/use-server-action";
import { getAvatarUrl } from "@/lib/imager";
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import { cn } from "@/lib/utils";
import { categorizePerms, PermToggle } from "./permission-controls";
import type { RankEditorProps } from "./rank-editor-types";
@@ -472,6 +472,7 @@ export function RankEditClient({
headDirection: 2,
})}
alt=""
onError={avatarImageOnError}
className="w-8 h-12 object-cover"
/>
<span className="text-xs font-medium truncate">
@@ -689,6 +690,7 @@ export function RankEditClient({
headDirection: 2,
})}
alt=""
onError={avatarImageOnError}
className="w-8 h-12 object-cover"
/>
<span className="text-xs font-medium truncate">
@@ -18,7 +18,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatNumber } from "@/lib/format-date";
import { getAvatarUrl } from "@/lib/imager";
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import { userDetailHref, userListHref } from "../user-list-navigation";
interface UserHeroProps {
@@ -83,6 +83,7 @@ export function UserHero({
<img
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
alt={user.username}
onError={avatarImageOnError}
className="h-28 rounded-lg border bg-card shadow-sm"
style={{ imageRendering: "pixelated" }}
/>
+2 -1
View File
@@ -32,7 +32,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { formatDate, formatNumber } from "@/lib/format-date";
import { getAvatarUrl } from "@/lib/imager";
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface UserRow {
@@ -81,6 +81,7 @@ function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
alt=""
width={64}
height={110}
onError={avatarImageOnError}
className="h-[110px] w-16 shrink-0"
style={{ imageRendering: "pixelated" }}
/>
File diff suppressed because it is too large. Load diff
+28 -104
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { getAvatarUrl } from "@/lib/imager";
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
export default async function ModUserDetailPage({
@@ -49,6 +49,7 @@ export default async function ModUserDetailPage({
alt=""
width={64}
height={110}
onError={avatarImageOnError}
className="rounded"
/>
<div>
+2 -1
View File
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User } from "@/lib/db";
import { getAvatarUrl } from "@/lib/imager";
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import type { DataTableColumn } from "@/types/common";
@@ -38,6 +38,7 @@ const columns: DataTableColumn<ModUserRow>[] = [
alt=""
width={32}
height={32}
onError={avatarImageOnError}
className="rounded"
/>
<span className="font-medium">{row.username}</span>
+6 -1
View File
@@ -1,4 +1,8 @@
import { type AvatarOptions, getAvatarUrl } from "@/lib/imager";
import {
type AvatarOptions,
avatarImageOnError,
getAvatarUrl,
} from "@/lib/imager";
interface AvatarImageProps {
figure: string;
@@ -33,6 +37,7 @@ export function AvatarImage({
height={height}
loading="lazy"
decoding="async"
onError={avatarImageOnError}
style={{ maxWidth: width, maxHeight: height }}
className={`image-pixelated object-contain ${className}`}
/>
File diff suppressed because it is too large. Load diff
+16
View File
@@ -6,8 +6,24 @@
export type { AvatarOptions } from "@/types/admin";
import type { SyntheticEvent } from "react";
import type { AvatarOptions } from "@/types/admin";
/**
* Neutral silhouette (grayscale SVG) shown when even the fallback upstream
* cannot produce a render — keeps a broken avatar from showing a broken-image
* glyph anywhere on the site.
*/
export const AVATAR_FALLBACK_SRC =
"data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23e2e2e2'/%3E%3Ccircle cx='32' cy='24' r='11' fill='%239d9d9d'/%3E%3Cpath d='M10 60c5-10 13-14 22-14s17 4 22 14z' fill='%239d9d9d'/%3E%3C/svg%3E";
export function avatarImageOnError(event: SyntheticEvent<HTMLImageElement>) {
const img = event.currentTarget;
if (img.dataset.fallback) return;
img.dataset.fallback = "1";
img.src = AVATAR_FALLBACK_SRC;
}
/**
* Build an avatar image URL using the configured imager.
* Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility.
+24
View File
@@ -42,3 +42,27 @@ it("rejects malformed figure input without contacting a renderer", async () => {
).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});
it("falls back to the public Habbo renderer when the configured imager is down", async () => {
const fetcher = vi.fn(async (input: string | URL) => {
const url = new URL(String(input));
if (url.origin === "https://hotel.test")
return new Response("unavailable", { status: 503 });
return new Response(new Uint8Array([4, 5, 6]), {
headers: { "content-type": "image/png" },
});
});
vi.stubGlobal("fetch", fetcher);
vi.stubEnv("IMAGER_URL", "https://hotel.test/avatarimage");
const result = await GET(
new NextRequest(
"https://hotel.test/api/imaging/avatar?figure=hd-180-1&img_format=apng&effect=0",
),
);
expect(result.status).toBe(200);
expect(result.headers.get("x-imager-source")).toBe("fallback");
expect(result.headers.get("cache-control")).toContain("max-age=300");
const target = new URL(String(fetcher.mock.calls.at(-1)?.[0]));
expect(target.host).toBe("www.habbo.com");
expect(target.searchParams.get("img_format")).toBe("png");
expect(target.searchParams.has("effect")).toBe(false);
});