CI / check (push) Successful in 30s
Gitea Actions Runner Test / test-job (push) Successful in 2s
Gitea Runner Clean Test / test-job (push) Successful in 2s
CI / tests-unit (push) Successful in 1m46s
CI / tests-integration (push) Successful in 1m38s
CI / tests-ui (push) Successful in 2m26s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m18s
The badge GIF files exist at /var/www/Gamedata/c_images/album1584/ but the /api/imaging/badge endpoint only looked in /swf/c_images/album1584/ (relative to process.cwd()). Added GEDATA_IMAGES_BASE candidate and fetchCandidate handler so badges render correctly in the admin UI and on the site.
169 lines
4.7 KiB
TypeScript
169 lines
4.7 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { type NextRequest, NextResponse } from "next/server";
|
|
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[&source=local]
|
|
*
|
|
* 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 GEDATA_IMAGES_BASE = "/var/www/Gamedata/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 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 });
|
|
}
|
|
|
|
const configured = (
|
|
(resolveBadgeBase() || (await siteSettings.get("badge_base_url", ""))) ??
|
|
""
|
|
).trim();
|
|
const sourceLocal = request.nextUrl.searchParams.get("source") === "local";
|
|
|
|
const candidates: string[] = sourceLocal
|
|
? [LOCAL_SWF_BASE, GEDATA_IMAGES_BASE, DEFAULT_BASE]
|
|
: [
|
|
...((configured && [configured]) as string[]),
|
|
DEFAULT_BASE,
|
|
GEDATA_IMAGES_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.
|
|
if (normalized.startsWith("/swf")) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Local Gamedata path — read from /var/www/Gamedata/c_images/album1584/
|
|
if (normalized.startsWith("/var/www/Gamedata")) {
|
|
try {
|
|
const file = join(normalized, `${code}.gif`);
|
|
const body = new Uint8Array(await readFile(file));
|
|
return { body, contentType: "image/gif", source: "local" };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function badgeResponse(
|
|
body: Uint8Array,
|
|
contentType: string,
|
|
source: BadgeSource,
|
|
) {
|
|
return new NextResponse(body as unknown as BodyInit, {
|
|
status: 200,
|
|
headers: {
|
|
"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",
|
|
},
|
|
});
|
|
}
|