Make avatar imager resilient with upstream fallback everywhere
This commit is contained in:
1 parent
1bbd809b43
commit
1df1ffc3e9
13 files changed
+275
-208
No files matched your search
@@ -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`)
|
||||
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user