Make avatar imager resilient with upstream fallback everywhere
This commit is contained in:
1 parent
1bbd809b43
commit
1df1ffc3e9
13 files changed
+277
-210
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'
|
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`)
|
### 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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
|
||||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||||
|
|
||||||
interface OnlineUserRow {
|
interface OnlineUserRow {
|
||||||
@@ -28,6 +28,7 @@ const columns: DataTableColumn<OnlineUserRow>[] = [
|
|||||||
<img
|
<img
|
||||||
src={getAvatarUrl(row.look, { size: "s", headOnly: true })}
|
src={getAvatarUrl(row.look, { size: "s", headOnly: true })}
|
||||||
alt=""
|
alt=""
|
||||||
|
onError={avatarImageOnError}
|
||||||
className="h-8 w-8 rounded"
|
className="h-8 w-8 rounded"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{row.username}</span>
|
<span className="font-medium">{row.username}</span>
|
||||||
@@ -51,7 +52,12 @@ const columns: DataTableColumn<OnlineUserRow>[] = [
|
|||||||
key: "look",
|
key: "look",
|
||||||
label: "Avatar",
|
label: "Avatar",
|
||||||
render: (_, row) => (
|
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 { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { categorizePerms, PermToggle } from "./permission-controls";
|
import { categorizePerms, PermToggle } from "./permission-controls";
|
||||||
import type { RankEditorProps } from "./rank-editor-types";
|
import type { RankEditorProps } from "./rank-editor-types";
|
||||||
@@ -472,6 +472,7 @@ export function RankEditClient({
|
|||||||
headDirection: 2,
|
headDirection: 2,
|
||||||
})}
|
})}
|
||||||
alt=""
|
alt=""
|
||||||
|
onError={avatarImageOnError}
|
||||||
className="w-8 h-12 object-cover"
|
className="w-8 h-12 object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-medium truncate">
|
<span className="text-xs font-medium truncate">
|
||||||
@@ -689,6 +690,7 @@ export function RankEditClient({
|
|||||||
headDirection: 2,
|
headDirection: 2,
|
||||||
})}
|
})}
|
||||||
alt=""
|
alt=""
|
||||||
|
onError={avatarImageOnError}
|
||||||
className="w-8 h-12 object-cover"
|
className="w-8 h-12 object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-medium truncate">
|
<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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatNumber } from "@/lib/format-date";
|
import { formatNumber } from "@/lib/format-date";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
|
||||||
import { userDetailHref, userListHref } from "../user-list-navigation";
|
import { userDetailHref, userListHref } from "../user-list-navigation";
|
||||||
|
|
||||||
interface UserHeroProps {
|
interface UserHeroProps {
|
||||||
@@ -83,6 +83,7 @@ export function UserHero({
|
|||||||
<img
|
<img
|
||||||
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
|
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
|
onError={avatarImageOnError}
|
||||||
className="h-28 rounded-lg border bg-card shadow-sm"
|
className="h-28 rounded-lg border bg-card shadow-sm"
|
||||||
style={{ imageRendering: "pixelated" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { formatDate, formatNumber } from "@/lib/format-date";
|
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";
|
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
@@ -81,6 +81,7 @@ function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
|
|||||||
alt=""
|
alt=""
|
||||||
width={64}
|
width={64}
|
||||||
height={110}
|
height={110}
|
||||||
|
onError={avatarImageOnError}
|
||||||
className="h-[110px] w-16 shrink-0"
|
className="h-[110px] w-16 shrink-0"
|
||||||
style={{ imageRendering: "pixelated" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,99 +1,32 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
|
||||||
|
|
||||||
const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
|
|
||||||
const FIGURE_MAX_LEN = 512;
|
|
||||||
const FIGURE_MAX_PARTS = 24;
|
|
||||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const figure = (
|
const parsed = parseAvatarQuery(searchParams);
|
||||||
searchParams.get("figure") ??
|
if (!parsed.ok) {
|
||||||
searchParams.get("look") ??
|
return NextResponse.json({ error: parsed.message }, { status: 400 });
|
||||||
""
|
|
||||||
).trim();
|
|
||||||
if (!figure) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Missing figure parameter" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid figure format" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (figure.split(".").length > FIGURE_MAX_PARTS) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Figure has too many parts" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeParam = searchParams.get("size");
|
|
||||||
const size: "s" | "m" | "l" =
|
|
||||||
sizeParam === "s" || sizeParam === "l" ? sizeParam : "m";
|
|
||||||
|
|
||||||
const direction = clampInt(searchParams.get("direction"), 0, 7, 2);
|
|
||||||
const headDirection = clampInt(
|
|
||||||
searchParams.get("head_direction") ?? searchParams.get("headDirection"),
|
|
||||||
0,
|
|
||||||
7,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
const headOnly =
|
|
||||||
searchParams.get("headonly") === "1" ||
|
|
||||||
searchParams.get("headOnly") === "1";
|
|
||||||
const gesture = searchParams.get("gesture") ?? undefined;
|
|
||||||
const action = searchParams.get("action") ?? undefined;
|
|
||||||
const effect = searchParams.get("effect") ?? undefined;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
figure,
|
|
||||||
direction: String(direction),
|
|
||||||
head_direction: String(headDirection),
|
|
||||||
size,
|
|
||||||
img_format: "png",
|
|
||||||
});
|
|
||||||
if (headOnly) params.set("headonly", "1");
|
|
||||||
if (gesture) params.set("gesture", gesture);
|
|
||||||
if (action) params.set("action", action);
|
|
||||||
if (effect) params.set("effect", effect);
|
|
||||||
|
|
||||||
const imgFormat = searchParams.get("img_format");
|
|
||||||
if (imgFormat) params.set("img_format", imgFormat);
|
|
||||||
|
|
||||||
const upstream = resolveImagerBase(new URL(request.url).origin);
|
|
||||||
const upstreamUrl = new URL(upstream);
|
|
||||||
for (const [key, value] of params) upstreamUrl.searchParams.set(key, value);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(upstreamUrl, {
|
const { body, contentType, source } = await fetchAvatarImage(
|
||||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
new URL(request.url).origin,
|
||||||
headers: {
|
parsed.params,
|
||||||
"User-Agent":
|
);
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
return new NextResponse(body as unknown as BodyInit, {
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Upstream imager returned an error" },
|
|
||||||
{ status: res.status },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await res.arrayBuffer();
|
|
||||||
const contentType = res.headers.get("content-type") || "image/png";
|
|
||||||
|
|
||||||
return new NextResponse(buffer, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=3600",
|
"Content-Length": String(body.length),
|
||||||
|
// Fallback renders are served with a short TTL so the configured
|
||||||
|
// imager is retried soon instead of being masked for hours.
|
||||||
|
"Cache-Control":
|
||||||
|
source === "fallback"
|
||||||
|
? "public, max-age=300"
|
||||||
|
: "public, max-age=3600",
|
||||||
|
"X-Imager-Source": source,
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -115,15 +48,3 @@ export async function OPTIONS() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampInt(
|
|
||||||
raw: string | null,
|
|
||||||
min: number,
|
|
||||||
max: number,
|
|
||||||
fallback: number,
|
|
||||||
): number {
|
|
||||||
if (raw == null) return fallback;
|
|
||||||
const n = Number.parseInt(raw, 10);
|
|
||||||
if (Number.isNaN(n) || n < min || n > max) return fallback;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
+29
-105
@@ -1,11 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
|
||||||
|
|
||||||
const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
|
|
||||||
const FIGURE_MAX_LEN = 512;
|
|
||||||
const FIGURE_MAX_PARTS = 24;
|
|
||||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
type AvatarFormat = "png" | "webp" | "avif";
|
type AvatarFormat = "png" | "webp" | "avif";
|
||||||
|
|
||||||
@@ -23,116 +18,57 @@ function resolveFormat(raw: string | null): AvatarFormat {
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const figure = (
|
const parsed = parseAvatarQuery(searchParams);
|
||||||
searchParams.get("figure") ??
|
if (!parsed.ok) {
|
||||||
searchParams.get("look") ??
|
return NextResponse.json({ error: parsed.message }, { status: 400 });
|
||||||
""
|
|
||||||
).trim();
|
|
||||||
if (!figure) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Missing figure parameter" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid figure format" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (figure.split(".").length > FIGURE_MAX_PARTS) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Figure has too many parts" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeParam = searchParams.get("size");
|
|
||||||
const size: "s" | "m" | "l" =
|
|
||||||
sizeParam === "s" || sizeParam === "l" ? sizeParam : "m";
|
|
||||||
|
|
||||||
const direction = clampInt(searchParams.get("direction"), 0, 7, 2);
|
|
||||||
const headDirection = clampInt(
|
|
||||||
searchParams.get("head_direction") ?? searchParams.get("headDirection"),
|
|
||||||
0,
|
|
||||||
7,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
const headOnly =
|
|
||||||
searchParams.get("headonly") === "1" ||
|
|
||||||
searchParams.get("headOnly") === "1";
|
|
||||||
const gesture = searchParams.get("gesture") ?? undefined;
|
|
||||||
const action = searchParams.get("action") ?? undefined;
|
|
||||||
const effect = searchParams.get("effect") ?? undefined;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
figure,
|
|
||||||
direction: String(direction),
|
|
||||||
head_direction: String(headDirection),
|
|
||||||
size,
|
|
||||||
img_format: "png",
|
|
||||||
});
|
|
||||||
if (headOnly) params.set("headonly", "1");
|
|
||||||
if (gesture) params.set("gesture", gesture);
|
|
||||||
if (action) params.set("action", action);
|
|
||||||
if (effect) params.set("effect", effect);
|
|
||||||
|
|
||||||
const imgFormat = searchParams.get("img_format");
|
|
||||||
if (imgFormat) params.set("img_format", imgFormat);
|
|
||||||
|
|
||||||
const format = resolveFormat(searchParams.get("format"));
|
const format = resolveFormat(searchParams.get("format"));
|
||||||
|
|
||||||
const upstream = resolveImagerBase(new URL(request.url).origin);
|
|
||||||
const upstreamUrl = new URL(upstream);
|
|
||||||
for (const [key, value] of params) upstreamUrl.searchParams.set(key, value);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(upstreamUrl, {
|
const { body, contentType, source } = await fetchAvatarImage(
|
||||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
new URL(request.url).origin,
|
||||||
headers: {
|
parsed.params,
|
||||||
"User-Agent":
|
);
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Upstream imager returned an error" },
|
|
||||||
{ status: res.status },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
|
|
||||||
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
|
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
|
||||||
// Falls back to the original PNG if conversion fails.
|
// Falls back to the original PNG if conversion fails.
|
||||||
let body: Uint8Array = new Uint8Array(buffer);
|
let respBody: Uint8Array = body;
|
||||||
let contentType = res.headers.get("content-type") || "image/png";
|
let respContentType = contentType;
|
||||||
if (format !== "png") {
|
if (format !== "png") {
|
||||||
try {
|
try {
|
||||||
if (format === "webp") {
|
if (format === "webp") {
|
||||||
body = new Uint8Array(await sharp(buffer).webp().toBuffer());
|
respBody = new Uint8Array(
|
||||||
contentType = "image/webp";
|
await sharp(Buffer.from(body)).webp().toBuffer(),
|
||||||
|
);
|
||||||
|
respContentType = "image/webp";
|
||||||
} else {
|
} else {
|
||||||
body = new Uint8Array(await sharp(buffer).avif().toBuffer());
|
respBody = new Uint8Array(
|
||||||
contentType = "image/avif";
|
await sharp(Buffer.from(body)).avif().toBuffer(),
|
||||||
|
);
|
||||||
|
respContentType = "image/avif";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
body = new Uint8Array(buffer);
|
respBody = body;
|
||||||
contentType = res.headers.get("content-type") || "image/png";
|
respContentType = contentType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(body as unknown as BodyInit, {
|
return new NextResponse(respBody as unknown as BodyInit, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": respContentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(respBody.length),
|
||||||
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
||||||
// stale-while-revalidate keeps them fresh without blocking requests.
|
// stale-while-revalidate keeps them fresh without blocking requests.
|
||||||
// The ?format= param is part of the URL, so variants cache separately.
|
// The ?format= param is part of the URL, so variants cache separately.
|
||||||
|
// Fallback renders use a shorter TTL so the configured imager is
|
||||||
|
// retried soon instead of being masked for hours.
|
||||||
"Cache-Control":
|
"Cache-Control":
|
||||||
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
source === "fallback"
|
||||||
|
? "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600"
|
||||||
|
: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
||||||
|
"X-Imager-Source": source,
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -154,15 +90,3 @@ export async function OPTIONS() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampInt(
|
|
||||||
raw: string | null,
|
|
||||||
min: number,
|
|
||||||
max: number,
|
|
||||||
fallback: number,
|
|
||||||
): number {
|
|
||||||
if (raw == null) return fallback;
|
|
||||||
const n = Number.parseInt(raw, 10);
|
|
||||||
if (Number.isNaN(n) || n < min || n > max) return fallback;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { requireModPermission } from "@/lib/admin/guard";
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
export default async function ModUserDetailPage({
|
export default async function ModUserDetailPage({
|
||||||
@@ -49,6 +49,7 @@ export default async function ModUserDetailPage({
|
|||||||
alt=""
|
alt=""
|
||||||
width={64}
|
width={64}
|
||||||
height={110}
|
height={110}
|
||||||
|
onError={avatarImageOnError}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { requireModPermission } from "@/lib/admin/guard";
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { avatarImageOnError, getAvatarUrl } from "@/lib/imager";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
import type { DataTableColumn } from "@/types/common";
|
import type { DataTableColumn } from "@/types/common";
|
||||||
@@ -38,6 +38,7 @@ const columns: DataTableColumn<ModUserRow>[] = [
|
|||||||
alt=""
|
alt=""
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
|
onError={avatarImageOnError}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{row.username}</span>
|
<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 {
|
interface AvatarImageProps {
|
||||||
figure: string;
|
figure: string;
|
||||||
@@ -33,6 +37,7 @@ export function AvatarImage({
|
|||||||
height={height}
|
height={height}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onError={avatarImageOnError}
|
||||||
style={{ maxWidth: width, maxHeight: height }}
|
style={{ maxWidth: width, maxHeight: height }}
|
||||||
className={`image-pixelated object-contain ${className}`}
|
className={`image-pixelated object-contain ${className}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { resolveImagerBase } from "@/lib/runtime-asset-config";
|
||||||
|
|
||||||
|
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
|
||||||
|
export const FIGURE_MAX_LEN = 512;
|
||||||
|
export const FIGURE_MAX_PARTS = 24;
|
||||||
|
|
||||||
|
const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||||
|
|
||||||
|
const PRIMARY_TIMEOUT_MS = 10_000;
|
||||||
|
const FALLBACK_TIMEOUT_MS = 6_000;
|
||||||
|
|
||||||
|
export type ImagerSource = "primary" | "fallback";
|
||||||
|
|
||||||
|
export interface ImagerResult {
|
||||||
|
body: Uint8Array;
|
||||||
|
contentType: string;
|
||||||
|
source: ImagerSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarQuery =
|
||||||
|
| { ok: true; params: URLSearchParams }
|
||||||
|
| { ok: false; message: string };
|
||||||
|
|
||||||
|
export class ImagerUnavailableError extends Error {
|
||||||
|
constructor(message = "Avatar imager unavailable") {
|
||||||
|
super(message);
|
||||||
|
this.name = "ImagerUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAvatarQuery(searchParams: URLSearchParams): AvatarQuery {
|
||||||
|
const figure = (
|
||||||
|
searchParams.get("figure") ??
|
||||||
|
searchParams.get("look") ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
if (!figure) return { ok: false, message: "Missing figure parameter" };
|
||||||
|
if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure))
|
||||||
|
return { ok: false, message: "Invalid figure format" };
|
||||||
|
if (figure.split(".").length > FIGURE_MAX_PARTS)
|
||||||
|
return { ok: false, message: "Figure has too many parts" };
|
||||||
|
|
||||||
|
const sizeParam = searchParams.get("size");
|
||||||
|
const size: "s" | "m" | "l" =
|
||||||
|
sizeParam === "s" || sizeParam === "l" ? sizeParam : "m";
|
||||||
|
|
||||||
|
const direction = clampInt(searchParams.get("direction"), 0, 7, 2);
|
||||||
|
const headDirection = clampInt(
|
||||||
|
searchParams.get("head_direction") ?? searchParams.get("headDirection"),
|
||||||
|
0,
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
const headOnly =
|
||||||
|
searchParams.get("headonly") === "1" ||
|
||||||
|
searchParams.get("headOnly") === "1";
|
||||||
|
const gesture = searchParams.get("gesture") ?? undefined;
|
||||||
|
const action = searchParams.get("action") ?? undefined;
|
||||||
|
const effect = searchParams.get("effect") ?? undefined;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
figure,
|
||||||
|
direction: String(direction),
|
||||||
|
head_direction: String(headDirection),
|
||||||
|
size,
|
||||||
|
img_format: "png",
|
||||||
|
});
|
||||||
|
if (headOnly) params.set("headonly", "1");
|
||||||
|
if (gesture) params.set("gesture", gesture);
|
||||||
|
if (action) params.set("action", action);
|
||||||
|
if (effect) params.set("effect", effect);
|
||||||
|
|
||||||
|
const imgFormat = searchParams.get("img_format");
|
||||||
|
if (imgFormat) params.set("img_format", imgFormat);
|
||||||
|
|
||||||
|
return { ok: true, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAvatarImage(
|
||||||
|
origin: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
): Promise<ImagerResult> {
|
||||||
|
const primary = resolveImagerBase(origin);
|
||||||
|
const primaryResult = await fetchUpstream(
|
||||||
|
primary,
|
||||||
|
params,
|
||||||
|
PRIMARY_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (primaryResult) return { ...primaryResult, source: "primary" };
|
||||||
|
|
||||||
|
if (isSameUpstream(primary, HABBO_PUBLIC_UPSTREAM)) {
|
||||||
|
throw new ImagerUnavailableError(
|
||||||
|
"Configured imager is already the fallback upstream",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackResult = await fetchUpstream(
|
||||||
|
HABBO_PUBLIC_UPSTREAM,
|
||||||
|
buildFallbackParams(params),
|
||||||
|
FALLBACK_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (fallbackResult) return { ...fallbackResult, source: "fallback" };
|
||||||
|
|
||||||
|
throw new ImagerUnavailableError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUpstream(
|
||||||
|
baseUrl: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Omit<ImagerResult, "source"> | null> {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return {
|
||||||
|
body: new Uint8Array(await res.arrayBuffer()),
|
||||||
|
contentType: res.headers.get("content-type") || "image/png",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackParams(params: URLSearchParams): URLSearchParams {
|
||||||
|
const fallback = new URLSearchParams(params);
|
||||||
|
// The public Habbo renderer has no effect rendering support.
|
||||||
|
fallback.delete("effect");
|
||||||
|
// The public Habbo renderer emits PNG/GIF, not APNG.
|
||||||
|
fallback.set("img_format", "png");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameUpstream(a: string, b: string): boolean {
|
||||||
|
const normalize = (value: string) =>
|
||||||
|
value.replace(/\/+$/, "").replace(/^https?:/, "");
|
||||||
|
return normalize(a) === normalize(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(
|
||||||
|
raw: string | null,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
fallback: number,
|
||||||
|
): number {
|
||||||
|
if (raw == null) return fallback;
|
||||||
|
const n = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(n) || n < min || n > max) return fallback;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
@@ -6,8 +6,24 @@
|
|||||||
|
|
||||||
export type { AvatarOptions } from "@/types/admin";
|
export type { AvatarOptions } from "@/types/admin";
|
||||||
|
|
||||||
|
import type { SyntheticEvent } from "react";
|
||||||
import type { AvatarOptions } from "@/types/admin";
|
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.
|
* Build an avatar image URL using the configured imager.
|
||||||
* Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility.
|
* 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);
|
).toBe(400);
|
||||||
expect(fetcher).not.toHaveBeenCalled();
|
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