Initial SvelteKit frontend port of windwiderstand.de
Deploy / verify (push) Failing after 32s
Deploy / deploy (push) Has been skipped

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:
Peter Meier
2026-04-17 22:01:30 +02:00
parent 852cef0980
commit 2fef91a548
137 changed files with 28585 additions and 0 deletions
+211
View File
@@ -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';
}