/** * Persistenter Disk-Cache für CMS-Bilder. * Bilder werden einmal vom CMS geholt, auf Disk gespeichert und danach * direkt von dort ausgeliefert – kein erneuter CMS-Request nötig. * * Zwei Ebenen: In-Memory (LRU) für häufig angefragte Bilder, * Disk für Persistenz über Neustarts hinweg. */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync } from 'node:fs'; import { readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { env } from '$env/dynamic/private'; const CACHE_DIR = env.IMAGE_CACHE_DIR?.trim() || '.cache/images'; const MAX_MEMORY_ENTRIES = Number(env.IMAGE_CACHE_MAX_MEMORY) || 200; export interface CachedImage { buffer: Buffer; contentType: string; } export interface ImageTransformParams { w?: number; h?: number; q?: number; format?: string; fit?: string; ar?: string; fx?: number; fy?: number; dpr?: number; } const memoryCache = new Map(); let resolvedDir: string | null = null; function ensureCacheDir(): string { const dir = resolvedDir ?? join(process.cwd(), CACHE_DIR); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); resolvedDir = dir; return dir; } /** * Deterministischer Cache-Key aus src + allen Transform-Parametern. * Gleiche Eingabe = gleicher Key, unabhängig von Reihenfolge. */ function roundFocal(v: number): number { return Math.round(v * 10000) / 10000; } export function buildCacheKey(src: string, params: ImageTransformParams): string { const parts = [src]; if (params.w != null) parts.push(`w=${params.w}`); if (params.h != null) parts.push(`h=${params.h}`); if (params.q != null) parts.push(`q=${params.q}`); if (params.format) parts.push(`f=${params.format}`); if (params.fit) parts.push(`fit=${params.fit}`); if (params.ar) parts.push(`ar=${params.ar}`); if (params.fx != null) parts.push(`fx=${roundFocal(params.fx)}`); if (params.fy != null) parts.push(`fy=${roundFocal(params.fy)}`); if (params.dpr != null) parts.push(`dpr=${roundFocal(params.dpr)}`); return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16); } function extForContentType(ct: string): string { if (ct.includes('webp')) return 'webp'; if (ct.includes('avif')) return 'avif'; if (ct.includes('png')) return 'png'; if (ct.includes('svg')) return 'svg'; if (ct.includes('gif')) return 'gif'; return 'jpg'; } /** Content-Type für HTTP-Header aus Dateiendung (eine Datei pro Cache-Eintrag). */ export function contentTypeFromExtension(ext: string): string { switch (ext.toLowerCase()) { case 'webp': return 'image/webp'; case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'avif': return 'image/avif'; case 'gif': return 'image/gif'; case 'svg': return 'image/svg+xml'; default: return 'image/jpeg'; } } const IMAGE_FILE_EXT = new Set(['webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'svg']); function rememberInMemory(key: string, cached: CachedImage): CachedImage { if (memoryCache.size >= MAX_MEMORY_ENTRIES) { const oldest = memoryCache.keys().next().value; if (oldest !== undefined) memoryCache.delete(oldest); } memoryCache.set(key, cached); return cached; } /** * Liest einen Cache-Eintrag: eine Datei `{key}.{webp|jpg|…}` (ein I/O). * Fallback: altes Paar `.meta` + `.bin`. Optional `params.format`, um die Endung vorab zu probieren. */ export async function getCachedImage( key: string, params?: ImageTransformParams, ): Promise { const mem = memoryCache.get(key); if (mem) return mem; const dir = ensureCacheDir(); const tryFile = async (ext: string): Promise => { try { const buf = await readFile(join(dir, `${key}.${ext}`)); const cached: CachedImage = { buffer: buf, contentType: contentTypeFromExtension(ext), }; return rememberInMemory(key, cached); } catch { return null; } }; if (params?.format) { const ext = resolveExtension(params); const hit = await tryFile(ext); if (hit) return hit; if (ext === 'jpg') { const hitJpeg = await tryFile('jpeg'); if (hitJpeg) return hitJpeg; } } try { const files = await readdir(dir); const prefix = `${key}.`; const imageName = files.find((f) => { if (!f.startsWith(prefix)) return false; const rest = f.slice(prefix.length).toLowerCase(); if (rest === 'meta' || rest === 'bin') return false; return IMAGE_FILE_EXT.has(rest); }); if (imageName) { try { const buf = await readFile(join(dir, imageName)); const ext = imageName.slice(prefix.length); return rememberInMemory(key, { buffer: buf, contentType: contentTypeFromExtension(ext), }); } catch { // weiter zu Legacy } } } catch { // ignore } const metaPath = join(dir, `${key}.meta`); const dataPath = join(dir, `${key}.bin`); try { const [contentType, buf] = await Promise.all([ readFile(metaPath, 'utf8'), readFile(dataPath), ]); return rememberInMemory(key, { buffer: buf, contentType }); } catch { return null; } } export async function cacheImage( key: string, buffer: ArrayBuffer | Buffer, contentType: string, ): Promise { const dir = ensureCacheDir(); const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); const ext = extForContentType(contentType); const cached: CachedImage = { buffer: buf, contentType }; rememberInMemory(key, cached); const base = join(dir, key); try { const files = await readdir(dir); const prefix = `${key}.`; for (const f of files) { if (!f.startsWith(prefix)) continue; await unlink(join(dir, f)).catch(() => {}); } } catch { await unlink(`${base}.meta`).catch(() => {}); await unlink(`${base}.bin`).catch(() => {}); } await writeFile(join(dir, `${key}.${ext}`), buf); return cached; } /** * Dateiendung aus den Transform-Parametern oder Content-Type ableiten. */ export function resolveExtension(params: ImageTransformParams, contentType?: string): string { if (params.format) return params.format === 'jpeg' ? 'jpg' : params.format; if (contentType) return extForContentType(contentType); return 'jpg'; } /** * Wipe in-memory + on-disk image cache. Returns counts cleared. */ export async function clearImageCache(): Promise<{ memory: number; disk: number }> { const memory = memoryCache.size; memoryCache.clear(); let disk = 0; try { const dir = ensureCacheDir(); const files = await readdir(dir); for (const f of files) { try { await unlink(join(dir, f)); disk++; } catch { /* ignore */ } } } catch { /* ignore */ } return { memory, disk }; }