fix(imaging): make avatar and badge images resilient to upstream outages
- Add persistent disk cache for rendered avatars/badges (storage/imaging) so repeats never touch the flaky local renderer and cached renders survive upstream downtime - Serve cache-first with stale-on-error; cut primary/fallback timeouts from 10s/6s to 4s/4s so failing images cannot stall pages - Avatar proxy now returns a graceful 200 silhouette instead of 502 when no renderer can produce a figure, so no broken-image glyphs appear - Badge endpoint becomes a caching proxy trying configured CDN, public Habbo CDN and local /swf copy in order, and drops the fragile IP rate limit that could blank badge streams - Route all site badge images (profile, me, badges, apply pages) through the cached proxy instead of hot-linking images.habbo.com
This commit is contained in:
1 parent
c3ff497050
commit
8a66db4ed7
12 files changed
+458
-58
No files matched your search
@@ -28,8 +28,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
// Badge codes map to a .gif served through the cached proxy.
|
||||
const BADGE_IMG_BASE = "/api/imaging/badge?code";
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
@@ -144,7 +144,7 @@ export default async function ApplyStaffPage({
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -22,8 +22,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
// Badge codes map to a .gif served through the cached proxy.
|
||||
const BADGE_IMG_BASE = "/api/imaging/badge?code";
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
@@ -122,7 +122,7 @@ export default async function ApplyTeamPage({
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={team.rankName}
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -17,8 +17,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
// A badge_key (e.g. "ADM") maps to a .gif served through the cached proxy.
|
||||
const BADGE_IMG_BASE = "/api/imaging/badge?code";
|
||||
|
||||
export default async function BadgesPage() {
|
||||
const t = await getTranslations("pages.badges");
|
||||
@@ -50,7 +50,7 @@ export default async function BadgesPage() {
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`}
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
|
||||
alt={badge.badgeName}
|
||||
title={badge.badgeKey}
|
||||
width={40}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||
import { avatarImageUrl, badgeImageUrl, slugify } from "@/lib/format";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadDailyRewardState } from "@/lib/services/daily-rewards";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
@@ -456,7 +456,7 @@ async function MePage({
|
||||
{badges.map((badge) => (
|
||||
<img
|
||||
key={badge.badgeCode}
|
||||
src={`https://images.habbo.com/c_images/album1584/${encodeURIComponent(badge.badgeCode)}.gif`}
|
||||
src={badgeImageUrl(badge.badgeCode)}
|
||||
alt={badge.badgeCode}
|
||||
title={badge.badgeCode}
|
||||
width={40}
|
||||
|
||||
@@ -71,8 +71,8 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN. Badge codes (e.g. "ADM") map to a .gif here.
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
// Badge codes (e.g. "ADM") map to a .gif served through the cached proxy.
|
||||
const BADGE_IMG_BASE = "/api/imaging/badge?code";
|
||||
|
||||
// Emulator currency type ids (see UsersCurrency model): Duckets=0, Diamonds=5.
|
||||
const CURRENCY_DUCKETS = 0;
|
||||
@@ -514,12 +514,7 @@ async function ProfilePage({
|
||||
<details key={b.id} className={styles.badge}>
|
||||
<summary title={name}>
|
||||
<img
|
||||
src={
|
||||
BADGE_IMG_BASE +
|
||||
"/" +
|
||||
encodeURIComponent(b.badgeCode) +
|
||||
".gif"
|
||||
}
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(b.badgeCode)}`}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { fetchAvatarImage, parseAvatarQuery } from "@/lib/imager-upstream";
|
||||
import { AVATAR_FALLBACK_SRC } from "@/lib/imager";
|
||||
import {
|
||||
fetchAvatarImage,
|
||||
ImagerUnavailableError,
|
||||
parseAvatarQuery,
|
||||
} from "@/lib/imager-upstream";
|
||||
|
||||
// The same neutral silhouette the client fallback uses, served as a real 200
|
||||
// response when no upstream can render the figure. Images on server-rendered
|
||||
// pages therefore never show a broken-image glyph; browsers revalidate quickly
|
||||
// (`no-cache`) and pick up real renders as soon as an upstream recovers.
|
||||
const PLACEHOLDER_SVG_BYTES = new TextEncoder().encode(
|
||||
decodeURIComponent(
|
||||
AVATAR_FALLBACK_SRC.slice("data:image/svg+xml;utf8,".length),
|
||||
),
|
||||
);
|
||||
|
||||
function cacheControlFor(source: string): string {
|
||||
switch (source) {
|
||||
case "fallback":
|
||||
case "cache-stale":
|
||||
// Reuse the render briefly but revalidate the upstream soon.
|
||||
return "public, max-age=300";
|
||||
case "unavailable":
|
||||
return "no-cache, max-age=0";
|
||||
default:
|
||||
return "public, max-age=3600";
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -20,17 +48,24 @@ export async function GET(request: NextRequest) {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"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",
|
||||
"Cache-Control": cacheControlFor(source),
|
||||
"X-Imager-Source": source,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof ImagerUnavailableError) {
|
||||
return new NextResponse(PLACEHOLDER_SVG_BYTES as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Content-Length": String(PLACEHOLDER_SVG_BYTES.length),
|
||||
"Cache-Control": cacheControlFor("unavailable"),
|
||||
"X-Imager-Source": "unavailable",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch avatar from upstream" },
|
||||
{ status: 502 },
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
badgeCacheDir,
|
||||
imagingCacheKey,
|
||||
readImagingCache,
|
||||
writeImagingCache,
|
||||
} from "@/lib/imager-cache";
|
||||
import { resolveBadgeBase } from "@/lib/runtime-asset-config";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* GET /api/imaging/badge?code=ADM
|
||||
* GET /api/imaging/badge?code=ADM[&source=local]
|
||||
*
|
||||
* Resolves a Habbo badge code to its image URL and redirects there.
|
||||
* Resolves a Habbo badge code to its image and serves it through a local
|
||||
* caching proxy. Candidates are tried in order (configured CDN, public Habbo
|
||||
* CDN, local `/swf` copy), so a flaky images.habbo.com never blanks a badge
|
||||
* and every badge renders exactly once anywhere on the site.
|
||||
*/
|
||||
|
||||
const CODE_RE = /^[A-Za-z0-9_+.-]+$/;
|
||||
const CODE_MAX_LEN = 50;
|
||||
const DEFAULT_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
const LOCAL_SWF_BASE = "/swf/c_images/album1584";
|
||||
const FETCH_TIMEOUT_MS = 5_000;
|
||||
|
||||
// Neutral placeholder (grey rounded square) served as a 200 so broken badge
|
||||
// glyphs can never appear, even when every candidate is unavailable.
|
||||
const BADGE_PLACEHOLDER_SVG =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 25'><rect width='40' height='25' rx='4' fill='%23e2e2e2'/></svg>";
|
||||
|
||||
type BadgeSource = "upstream" | "local" | "cache" | "unavailable";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ip = await clientIp();
|
||||
const limited = await rateLimit(`badge-imaging:${ip}`, 60, 60_000);
|
||||
if (!limited.ok) {
|
||||
return new NextResponse(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(limited.retryAfter) },
|
||||
});
|
||||
}
|
||||
|
||||
const code = request.nextUrl.searchParams.get("code")?.trim();
|
||||
if (!code || code.length > CODE_MAX_LEN || !CODE_RE.test(code)) {
|
||||
return NextResponse.json({ error: "Invalid badge code" }, { status: 400 });
|
||||
@@ -32,21 +42,110 @@ export async function GET(request: NextRequest) {
|
||||
(resolveBadgeBase() || (await siteSettings.get("badge_base_url", ""))) ??
|
||||
""
|
||||
).trim();
|
||||
const fallback =
|
||||
request.nextUrl.searchParams.get("source") === "local"
|
||||
? "/swf/c_images/album1584"
|
||||
: DEFAULT_BASE;
|
||||
const base = (configured || fallback).replace(/\/+$/, "");
|
||||
const isAbsolute = /^https?:\/\//i.test(base);
|
||||
const target = `${base}/${code}.gif`;
|
||||
const absoluteTarget = isAbsolute
|
||||
? target
|
||||
: new URL(target, request.nextUrl.origin).toString();
|
||||
const sourceLocal = request.nextUrl.searchParams.get("source") === "local";
|
||||
|
||||
return NextResponse.redirect(absoluteTarget, {
|
||||
status: 302,
|
||||
const candidates: string[] = sourceLocal
|
||||
? [LOCAL_SWF_BASE, DEFAULT_BASE]
|
||||
: [
|
||||
...((configured && [configured]) as string[]),
|
||||
DEFAULT_BASE,
|
||||
LOCAL_SWF_BASE,
|
||||
];
|
||||
|
||||
const cacheKey = imagingCacheKey(`${code}\n${candidates.join("\n")}`);
|
||||
const cached = await readImagingCache(badgeCacheDir(), cacheKey);
|
||||
if (cached) return badgeResponse(cached.body, cached.contentType, "cache");
|
||||
|
||||
for (const base of candidates) {
|
||||
const result = await fetchCandidate(base, code);
|
||||
if (result) {
|
||||
await writeImagingCache(
|
||||
badgeCacheDir(),
|
||||
cacheKey,
|
||||
result.body,
|
||||
result.contentType,
|
||||
);
|
||||
return badgeResponse(result.body, result.contentType, result.source);
|
||||
}
|
||||
}
|
||||
|
||||
return badgeResponse(
|
||||
new TextEncoder().encode(BADGE_PLACEHOLDER_SVG),
|
||||
"image/svg+xml",
|
||||
"unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchCandidate(
|
||||
base: string,
|
||||
code: string,
|
||||
): Promise<{
|
||||
body: Uint8Array;
|
||||
contentType: string;
|
||||
source: BadgeSource;
|
||||
} | null> {
|
||||
const normalized = base.replace(/\/+$/, "");
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
try {
|
||||
const res = await fetch(`${normalized}/${encodeURIComponent(code)}.gif`, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const contentType = res.headers.get("content-type") ?? "image/gif";
|
||||
if (!contentType.startsWith("image/")) return null;
|
||||
return {
|
||||
body: new Uint8Array(await res.arrayBuffer()),
|
||||
contentType,
|
||||
source: "upstream",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Local `/swf/...` path — read straight from the deployed public folder.
|
||||
try {
|
||||
const file = join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
normalized.replace(/^\//, ""),
|
||||
`${code}.gif`,
|
||||
);
|
||||
const body = new Uint8Array(await readFile(file));
|
||||
return { body, contentType: "image/gif", source: "local" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function badgeResponse(
|
||||
body: Uint8Array,
|
||||
contentType: string,
|
||||
source: BadgeSource,
|
||||
) {
|
||||
return new NextResponse(body as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=300",
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(body.length),
|
||||
"Cache-Control":
|
||||
source === "unavailable"
|
||||
? "no-cache, max-age=0"
|
||||
: "public, max-age=3600",
|
||||
"X-Imager-Source": source,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user