Merge commit '80eca1bc7c1174f9c44a8df9af4ddc8ab855291b' as 'cms/core'

This commit is contained in:
2026-06-30 00:09:44 +02:00
53 changed files with 6896 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
// Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert
// (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert.
// SVG/GIF bleiben unangetastet (Vektor/Animation erhalten).
//
// Sicherheit: hartes Größenlimit (DoS / Decompression-Bombs), Raster wird über
// sharp-Metadaten als echtes Bild verifiziert, SVG nur wenn es wie SVG aussieht.
// Hochgeladene Dateien werden zudem mit strikter CSP (sandbox) ausgeliefert
// (siehe index.js, /images/*) → ein bösartiges SVG kann kein JS im Origin starten.
const SITE_DIR = process.env.SITE_DIR || '/site';
const MAX_UPLOAD = 8 * 1024 * 1024; // 8 MB Rohdatei
const ALLOWED_RASTER = new Set(['jpeg', 'png', 'webp', 'avif', 'tiff']);
const upload = new Hono();
upload.post('/', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
if (typeof file.size === 'number' && file.size > MAX_UPLOAD) {
return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
}
const buffer = Buffer.from(await file.arrayBuffer());
if (buffer.length > MAX_UPLOAD) return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
if (!buffer.length) return c.json({ error: 'Leere Datei' }, 400);
const dir = path.join(SITE_DIR, 'static', 'images');
await mkdir(dir, { recursive: true });
const ext = path.extname(file.name || '').toLowerCase();
const base = `${safeBase(file.name)}-${uid()}`;
let outName, outBuf;
if (ext === '.svg') {
// Muss wie SVG aussehen (Magie/Marker), sonst ablehnen.
const head = buffer.subarray(0, 512).toString('utf8').trimStart().toLowerCase();
if (!head.startsWith('<?xml') && !head.startsWith('<svg')) {
return c.json({ error: 'Keine gültige SVG-Datei' }, 400);
}
outName = `${base}.svg`;
outBuf = buffer;
} else if (ext === '.gif') {
// GIF-Magie prüfen (kann kein Skript ausführen → Passthrough ok).
const sig = buffer.subarray(0, 6).toString('latin1');
if (sig !== 'GIF87a' && sig !== 'GIF89a') return c.json({ error: 'Keine gültige GIF-Datei' }, 400);
outName = `${base}.gif`;
outBuf = buffer;
} else {
// Raster: über sharp als echtes Bild verifizieren, dann zu WebP.
let meta;
try { meta = await sharp(buffer).metadata(); } catch { meta = null; }
if (!meta || !ALLOWED_RASTER.has(meta.format)) {
return c.json({ error: 'Kein unterstütztes Bildformat' }, 400);
}
outName = `${base}.webp`;
outBuf = await sharp(buffer)
.rotate()
.resize({ width: 2000, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
}
await writeFile(path.join(dir, outName), outBuf);
return c.json({ url: `/images/${outName}` });
});
// Sicherer Basisname ohne Endung.
function safeBase(raw) {
const base = path.basename(String(raw || 'bild')).replace(/\.[^.]+$/, '');
const cleaned = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
return cleaned || 'bild';
}
// Kurze eindeutige Endung, damit gleichnamige Uploads nicht kollidieren.
function uid() {
return Date.now().toString(36).slice(-4) + Math.random().toString(36).slice(2, 5);
}
export default upload;