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
+26 -46
View File
@@ -3,21 +3,16 @@ import path from 'path';
import { env } from '../config/env.js';
/**
* Verwaltet die Dateien, die das CMS in den Git-Workspace schreibt.
* Kennt die Verzeichnisse, in die das CMS schreibt, und sorgt dafuer, dass
* kein Pfad ausserhalb davon angefasst wird.
*
* Grundregel: geloescht wird ausschliesslich, was das CMS selbst angelegt hat.
* Uploads bekommen den Namen <base36-zeitstempel>-<6 zufaellige zeichen>.<ext>
* (siehe events.ts / gallery.ts). Handgepflegte Assets aus dem Repo heissen
* anders - event_karaoke.jpg, Welcome.png, Gallery1.webp - und werden dadurch
* nie angefasst, auch wenn sie in keinem Datensatz mehr vorkommen.
* Ob eine konkrete Datei geloescht werden DARF, entscheidet diese Klasse
* bewusst nicht - das steht in managed-assets.service.ts.
*/
// Unterordner unterhalb von public/, in denen das CMS aufraeumen darf
const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
const MANAGED_PDF_DIR = 'pdf';
// Namensmuster der CMS-Uploads
const GENERATED_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
// Unterordner unterhalb von public/
export const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
export const MANAGED_PDF_DIR = 'pdf';
export class AssetService {
private publicDir: string;
@@ -26,7 +21,7 @@ export class AssetService {
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
}
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb */
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb. */
private resolveInPublic(url: string): string | null {
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
@@ -41,31 +36,31 @@ export class AssetService {
return absolute;
}
/**
* Pfad einer Datei, die das CMS loeschen darf.
* Liefert null fuer fremde Pfade und fuer handgepflegte Dateien.
*/
resolveDeletable(url: string, dirs: string[]): string | null {
/** Absoluter Pfad, sofern die Datei in einem der erlaubten Ordner liegt. */
resolveInDirs(url: string, dirs: string[]): string | null {
const absolute = this.resolveInPublic(url);
if (!absolute) return null;
const relative = path.relative(this.publicDir, absolute);
const dir = path.dirname(relative).split(path.sep).join('/');
if (!dirs.includes(dir)) return null;
if (!GENERATED_NAME.test(path.basename(absolute))) return null;
return absolute;
return dirs.includes(dir) ? absolute : null;
}
/** Loescht ein hochgeladenes Bild. Gibt zurueck, ob wirklich etwas weg ist. */
/** Existiert die Datei bereits? Fuer die Namensvergabe. */
exists(url: string, dirs: string[]): boolean {
const absolute = this.resolveInDirs(url, dirs);
return absolute ? fs.existsSync(absolute) : false;
}
/** Loescht eine Bilddatei. Die Besitzfrage muss vorher geklaert sein. */
deleteImage(url: string): boolean {
return this.unlink(this.resolveDeletable(url, MANAGED_IMAGE_DIRS));
return this.unlink(this.resolveInDirs(url, MANAGED_IMAGE_DIRS));
}
/** Loescht ein hochgeladenes PDF. */
/** Loescht ein PDF. Die Besitzfrage muss vorher geklaert sein. */
deletePdf(url: string): boolean {
return this.unlink(this.resolveDeletable(url, [MANAGED_PDF_DIR]));
return this.unlink(this.resolveInDirs(url, [MANAGED_PDF_DIR]));
}
private unlink(absolute: string | null): boolean {
@@ -79,19 +74,9 @@ export class AssetService {
}
}
/**
* Entfernt alle hochgeladenen Bilder, die in keiner der uebergebenen URLs
* mehr vorkommen. Die Liste muss ALLE Datensaetze abdecken, auch
* unveroeffentlichte - sonst verlieren die ihr Bild.
*/
sweepOrphanedImages(referencedUrls: string[]): string[] {
const keep = new Set<string>();
for (const url of referencedUrls) {
const absolute = this.resolveInPublic(url);
if (absolute) keep.add(absolute);
}
const removed: string[] = [];
/** Alle Bilddateien, die in den verwalteten Ordnern liegen, als URL-Pfade. */
listImageFiles(): string[] {
const found: string[] = [];
for (const dir of MANAGED_IMAGE_DIRS) {
const absoluteDir = path.join(this.publicDir, dir);
@@ -99,16 +84,11 @@ export class AssetService {
for (const name of fs.readdirSync(absoluteDir)) {
const absolute = path.join(absoluteDir, name);
if (!GENERATED_NAME.test(name)) continue;
if (keep.has(absolute)) continue;
if (!fs.statSync(absolute).isFile()) continue;
fs.unlinkSync(absolute);
removed.push('/' + dir + '/' + name);
found.push(`/${dir}/${name}`);
}
}
return removed;
return found;
}
}