Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
const CACHE_DIR = process.env.IMAGE_CACHE_DIR?.trim() || '.cache/images';
|
||||
const MAX_MEMORY_ENTRIES = Number(process.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;
|
||||
}
|
||||
|
||||
const memoryCache = new Map<string, CachedImage>();
|
||||
|
||||
let resolvedDir: string | null = null;
|
||||
|
||||
function ensureCacheDir(): string {
|
||||
if (resolvedDir) return resolvedDir;
|
||||
const dir = 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.
|
||||
*/
|
||||
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}`);
|
||||
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<CachedImage | null> {
|
||||
const mem = memoryCache.get(key);
|
||||
if (mem) return mem;
|
||||
|
||||
const dir = ensureCacheDir();
|
||||
|
||||
const tryFile = async (ext: string): Promise<CachedImage | null> => {
|
||||
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<CachedImage> {
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user