feat(cms): AVIF und sprechende Dateinamen fuer Uploads

Uploads werden nach AVIF gewandelt statt nach WebP und heissen jetzt nach
dem Inhalt statt nach Zeitstempel und Zufall:
- Events uebernehmen den Event-Titel, die Gallery den Alt-Text, Bilder in
  den Textbereichen ihren Zweck, PDFs den urspruenglichen Dateinamen.
- Ohne solche Angabe wird der Dateiname vor dem Upload verwendet.
- Umlaute werden ausgeschrieben (Getraenkekarte, nicht Getrnkekarte),
  Sonderzeichen und Pfadanteile fallen weg.
- Gleiche Namen werden durchnummeriert, damit zwei Events namens
  "Karaoke" sich nicht gegenseitig ueberschreiben.

Besitz statt Namensmuster:
Die Loeschsicherheit haing bisher am Namensmuster <zeitstempel>-<zufall>.
Mit sprechenden Namen traegt der Name diese Information nicht mehr - ein
hochgeladenes karaoke-abend.avif ist von einem handgepflegten
event_karaoke.jpg nicht zu unterscheiden. Deshalb fuehrt das CMS jetzt in
managed_assets Buch darueber, welche Dateien es selbst angelegt hat, und
loescht ausschliesslich diese. Uploads von vor der Umstellung werden
weiterhin am alten Muster erkannt, damit sie aufraeumbar bleiben.

initDatabase() legte Tabellen nur an, wenn users noch fehlte. Auf einer
bestehenden Datenbank waere managed_assets damit nie entstanden. Der
Block laeuft jetzt bei jedem Start; alle Anweisungen sind IF NOT EXISTS.

Nebenbei repariert: die Formulare schickten den Alt-Text NACH der Datei.
Zu dem Zeitpunkt hat der Server ihn noch nicht geparst, in der Gallery
landete deshalb immer der Dateiname als Alt-Text. Textfelder gehen jetzt
vor der Datei raus.

Gemessen an einem 4032x3024-Bild: 0.90 MB rein, 36 KB AVIF bei 1600x1200
raus, 579 ms. Das Testbild ist synthetisch und komprimiert besser als ein
echtes Foto - die Verkleinerung auf 1600px greift aber immer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-08-11 13:39:42 +02:00
co-authored by Claude Opus 5
parent 1a7a4f8a02
commit f9193eebfd
12 changed files with 258 additions and 101 deletions
+6 -1
View File
@@ -116,7 +116,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'content', fastify.log);
const preferredName = (file.fields?.name?.value as string | undefined) || '';
const saved = await saveUploadedImage(file, 'content', {
preferredName,
log: fastify.log,
});
return reply.code(201).send({ imageUrl: saved.imageUrl });
+8 -1
View File
@@ -112,7 +112,14 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'events', fastify.log);
// Der Titel kommt als Formularfeld VOR der Datei, sonst ist er hier
// noch nicht geparst
const preferredName = (file.fields?.title?.value as string | undefined) || '';
const saved = await saveUploadedImage(file, 'events', {
preferredName,
log: fastify.log,
});
return reply.code(201).send({ imageUrl: saved.imageUrl });
+4 -1
View File
@@ -95,7 +95,10 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'gallery', fastify.log);
const saved = await saveUploadedImage(file, 'gallery', {
preferredName: altText,
log: fastify.log,
});
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({
+5 -12
View File
@@ -1,10 +1,10 @@
import { FastifyPluginAsync } from 'fastify';
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { AssetService } from '../services/asset.service.js';
import { isManagedAsset, forgetManagedAsset } from '../services/managed-assets.service.js';
import { saveUploadedPdf } from '../services/upload.service.js';
import { env } from '../config/env.js';
const assets = new AssetService();
@@ -72,15 +72,7 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'File is not a valid PDF' });
}
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
fs.mkdirSync(uploadDir, { recursive: true });
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
const filename = `${stamp}-${rand}.pdf`;
fs.writeFileSync(path.join(uploadDir, filename), buffer);
const pdfUrl = `/pdf/${filename}`;
const pdfUrl = await saveUploadedPdf(file, buffer);
// URL in der Content-Section hinterlegen
const [existing] = await db
@@ -106,11 +98,12 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
if (previousUrl && previousUrl !== pdfUrl) {
if (previousUrl && previousUrl !== pdfUrl && (await isManagedAsset(previousUrl))) {
try {
if (assets.deletePdf(previousUrl)) {
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
}
await forgetManagedAsset(previousUrl);
} catch (err) {
fastify.log.warn({ err }, 'Could not remove replaced PDF');
}
+3 -8
View File
@@ -2,8 +2,7 @@ import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { GitService } from '../services/git.service.js';
import { FileGeneratorService } from '../services/file-generator.service.js';
import { AssetService } from '../services/asset.service.js';
import { collectReferencedImageUrls } from '../services/image-refs.service.js';
import { sweepOrphanedImages } from '../services/image-refs.service.js';
import { db } from '../config/database.js';
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm';
@@ -36,12 +35,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.log.info('Git repository initialized');
// Verwaiste Uploads entfernen, bevor committet wird. Die Referenzliste
// deckt bewusst alle Zeilen ab, auch unveroeffentlichte, und zusaetzlich
// die Bildpfade aus den Textbereichen.
const assetService = new AssetService();
const referencedImages = await collectReferencedImageUrls();
const removedImages = assetService.sweepOrphanedImages([...referencedImages]);
// Verwaiste Uploads entfernen, bevor committet wird
const removedImages = await sweepOrphanedImages();
if (removedImages.length > 0) {
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);