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
+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" }}
/>
+17 -96
View File
@@ -1,99 +1,32 @@
import { type NextRequest, NextResponse } from "next/server";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
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;
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const figure = (
searchParams.get("figure") ??
searchParams.get("look") ??
""
).trim();
if (!figure) {
return NextResponse.json(
{ error: "Missing figure parameter" },
{ status: 400 },
);
const parsed = parseAvatarQuery(searchParams);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.message }, { 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 {
const res = await fetch(upstreamUrl, {
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
const { body, contentType, source } = await fetchAvatarImage(
new URL(request.url).origin,
parsed.params,
);
if (!res.ok) {
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, {
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
"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": "*",
},
});
@@ -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
View File
@@ -1,11 +1,6 @@
import { type NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
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;
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
type AvatarFormat = "png" | "webp" | "avif";
@@ -23,116 +18,57 @@ function resolveFormat(raw: string | null): AvatarFormat {
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const figure = (
searchParams.get("figure") ??
searchParams.get("look") ??
""
).trim();
if (!figure) {
return NextResponse.json(
{ error: "Missing figure parameter" },
{ status: 400 },
);
const parsed = parseAvatarQuery(searchParams);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.message }, { 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 upstream = resolveImagerBase(new URL(request.url).origin);
const upstreamUrl = new URL(upstream);
for (const [key, value] of params) upstreamUrl.searchParams.set(key, value);
try {
const res = await fetch(upstreamUrl, {
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
headers: {
"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());
const { body, contentType, source } = await fetchAvatarImage(
new URL(request.url).origin,
parsed.params,
);
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
// Falls back to the original PNG if conversion fails.
let body: Uint8Array = new Uint8Array(buffer);
let contentType = res.headers.get("content-type") || "image/png";
let respBody: Uint8Array = body;
let respContentType = contentType;
if (format !== "png") {
try {
if (format === "webp") {
body = new Uint8Array(await sharp(buffer).webp().toBuffer());
contentType = "image/webp";
respBody = new Uint8Array(
await sharp(Buffer.from(body)).webp().toBuffer(),
);
respContentType = "image/webp";
} else {
body = new Uint8Array(await sharp(buffer).avif().toBuffer());
contentType = "image/avif";
respBody = new Uint8Array(
await sharp(Buffer.from(body)).avif().toBuffer(),
);
respContentType = "image/avif";
}
} catch {
body = new Uint8Array(buffer);
contentType = res.headers.get("content-type") || "image/png";
respBody = body;
respContentType = contentType;
}
}
return new NextResponse(body as unknown as BodyInit, {
return new NextResponse(respBody as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Content-Type": respContentType,
"Content-Length": String(respBody.length),
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
// stale-while-revalidate keeps them fresh without blocking requests.
// 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":
"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": "*",
},
});
@@ -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;
}
+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>