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 { 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 { 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 { 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. } }