/** * Persistenter Disk-Cache für CMS-Dateien (PDF, DOCX, ...). * Analog zu image-cache.server.ts: Datei einmal vom CMS holen, auf Disk * speichern, danach direkt von dort ausliefern. */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync } from 'node:fs'; import { readFile, readdir, writeFile } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { env } from '$env/dynamic/private'; const CACHE_DIR = env.FILE_CACHE_DIR?.trim() || '.cache/files'; const MAX_MEMORY_ENTRIES = Number(env.FILE_CACHE_MAX_MEMORY) || 50; export interface CachedFile { buffer: Buffer; contentType: string; ext: string; } 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; } export function buildFileCacheKey(src: string): string { return createHash('sha256').update(src).digest('hex').slice(0, 24); } const EXT_TO_CT: Record = { pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', odt: 'application/vnd.oasis.opendocument.text', ods: 'application/vnd.oasis.opendocument.spreadsheet', zip: 'application/zip', txt: 'text/plain; charset=utf-8', csv: 'text/csv; charset=utf-8', json: 'application/json', xml: 'application/xml', rtf: 'application/rtf', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml', }; export function contentTypeFromExt(ext: string): string { return EXT_TO_CT[ext.toLowerCase()] ?? 'application/octet-stream'; } export function extFromContentType(ct: string): string | null { const lower = ct.toLowerCase(); for (const [ext, mime] of Object.entries(EXT_TO_CT)) { if (lower.startsWith(mime.split(';')[0])) return ext; } return null; } export function extFromSrc(src: string): string | null { try { const path = src.split('?')[0].split('#')[0]; const e = extname(path).slice(1).toLowerCase(); return e || null; } catch { return null; } } function rememberInMemory(key: string, cached: CachedFile): CachedFile { if (memoryCache.size >= MAX_MEMORY_ENTRIES) { const oldest = memoryCache.keys().next().value; if (oldest !== undefined) memoryCache.delete(oldest); } memoryCache.set(key, cached); return cached; } export async function getCachedFile(key: string): Promise { const mem = memoryCache.get(key); if (mem) return mem; const dir = ensureCacheDir(); try { const files = await readdir(dir); const prefix = `${key}.`; const name = files.find((f) => f.startsWith(prefix)); if (!name) return null; const buf = await readFile(join(dir, name)); const ext = name.slice(prefix.length); return rememberInMemory(key, { buffer: buf, contentType: contentTypeFromExt(ext), ext, }); } catch { return null; } } export async function cacheFile( key: string, buffer: ArrayBuffer | Buffer, contentType: string, srcHint?: string, ): Promise { const dir = ensureCacheDir(); const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); const ext = extFromContentType(contentType) ?? (srcHint ? extFromSrc(srcHint) : null) ?? 'bin'; const cached: CachedFile = { buffer: buf, contentType, ext }; rememberInMemory(key, cached); await writeFile(join(dir, `${key}.${ext}`), buf); return cached; }