- 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
119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
import "server-only";
|
|
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdir,
|
|
readdir,
|
|
readFile,
|
|
rename,
|
|
stat,
|
|
unlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
/**
|
|
* Persistent disk cache for imaging renders (avatars, badges).
|
|
*
|
|
* Renders are immutable per URL, so a successful render is served again and
|
|
* again without touching the upstream renderer. This is what makes imaging
|
|
* resilient: repeated figure requests never exhaust the local imager, and an
|
|
* upstream outage cannot blank images that have rendered once.
|
|
*
|
|
* The tree lives under `storage/` (gitignored) so it survives restarts and
|
|
* stays out of the repository. Old entries are pruned opportunistically so
|
|
* the directory cannot grow without bound.
|
|
*/
|
|
|
|
const MAX_ENTRIES = 20_000;
|
|
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
const IMG_ROOT_DEFAULT = join(process.cwd(), "storage", "imaging");
|
|
|
|
function imagingCacheDir(kind: "avatars" | "badges"): string {
|
|
// Unit tests point this at a scratch root so they never read or pollute
|
|
// the runtime cache.
|
|
const root = (process.env.IMAGING_CACHE_ROOT || "").trim();
|
|
return root ? join(root, kind) : join(IMG_ROOT_DEFAULT, kind);
|
|
}
|
|
|
|
export function avatarCacheDir(): string {
|
|
return imagingCacheDir("avatars");
|
|
}
|
|
|
|
export function badgeCacheDir(): string {
|
|
return imagingCacheDir("badges");
|
|
}
|
|
|
|
export interface ImagingCacheRecord {
|
|
body: Uint8Array;
|
|
contentType: string;
|
|
}
|
|
|
|
export function imagingCacheKey(input: string): string {
|
|
return createHash("sha256").update(input).digest("hex");
|
|
}
|
|
|
|
export async function readImagingCache(
|
|
directory: string,
|
|
key: string,
|
|
): Promise<ImagingCacheRecord | null> {
|
|
try {
|
|
const [img, meta] = await Promise.all([
|
|
readFile(join(directory, `${key}.img`)),
|
|
readFile(join(directory, `${key}.json`), "utf8"),
|
|
]);
|
|
const parsed = JSON.parse(meta) as { contentType?: unknown };
|
|
const contentType =
|
|
typeof parsed.contentType === "string" ? parsed.contentType : "image/png";
|
|
return { body: new Uint8Array(img), contentType };
|
|
} catch {
|
|
// A torn write or a missing entry is simply a cache miss.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function writeImagingCache(
|
|
directory: string,
|
|
key: string,
|
|
body: Uint8Array,
|
|
contentType: string,
|
|
): Promise<void> {
|
|
try {
|
|
await mkdir(directory, { recursive: true });
|
|
const base = join(directory, key);
|
|
// Write to temp files and rename twice so a concurrent reader never
|
|
// observes a half-written record; an interrupted write self-heals.
|
|
await writeFile(`${base}.img.tmp`, body);
|
|
await writeFile(
|
|
`${base}.json.tmp`,
|
|
JSON.stringify({ contentType }),
|
|
"utf8",
|
|
);
|
|
await rename(`${base}.img.tmp`, `${base}.img`);
|
|
await rename(`${base}.json.tmp`, `${base}.json`);
|
|
await pruneImagingCache(directory);
|
|
} catch {
|
|
// Caching is best-effort; a failure must never break the render path.
|
|
}
|
|
}
|
|
|
|
async function pruneImagingCache(directory: string): Promise<void> {
|
|
try {
|
|
const names = await readdir(directory);
|
|
if (names.length <= MAX_ENTRIES) return;
|
|
const cutoffMs = Date.now() - MAX_AGE_MS;
|
|
for (const name of names) {
|
|
try {
|
|
const file = join(directory, name);
|
|
const info = await stat(file);
|
|
if (info.mtimeMs < cutoffMs) await unlink(file);
|
|
} catch {
|
|
// Skip entries that disappeared between listing and unlink.
|
|
}
|
|
}
|
|
} catch {
|
|
// Nothing to prune or directory missing.
|
|
}
|
|
}
|