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
@@ -0,0 +1,118 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user