Make avatar imager resilient with upstream fallback everywhere

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

No files matched your search

+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
+29 -105
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);
});