feat(cms): Aufraeumen von Uploads, Texte-Bearbeitung und PDF-Slot

Bilder wurden nie geloescht - weder beim Loeschen eines Datensatzes noch
beim Austauschen. Dazu waren die Textbereiche zwar im Backend vorhanden,
aber ohne Oberflaeche, und die Getraenkekarte hing hartkodiert im Markup.

Aufraeumen:
- asset.service.ts loescht ausschliesslich Dateien, die das CMS selbst
  angelegt hat (Muster <zeitstempel>-<zufall>.<ext>) und nur innerhalb der
  Upload-Ordner. Handgepflegte Assets wie event_karaoke.jpg oder Welcome.png
  bleiben unangetastet, auch wenn sie nirgends referenziert sind.
- image-refs.service.ts sammelt alle benutzten Bild-URLs, inklusive der
  Pfade aus den Textbereichen. Geloescht wird nur, was wirklich niemand
  mehr benutzt - ein von zwei Events geteiltes Bild bleibt liegen.
- Events und Gallery raeumen beim Loeschen und beim Bildwechsel mit auf.
- Der Publish entfernt zusaetzlich Verwaiste. Die Referenzliste deckt
  bewusst alle Zeilen ab, auch unveroeffentlichte, sonst verlieren die ihr
  Bild. Die Loeschungen werden mitcommittet, das Repo schrumpft also.

Texte:
- Neuer Adminbereich fuer Hero, Willkommen und Drinks ueber die schon
  vorhandenen /api/content-Endpunkte, samt Highlights-Liste und Bildern.
- Der Generator maskiert Texte jetzt. Ohne das haette ein "<" oder "&" in
  einem Feld gereicht, um den Astro-Build und damit den Deploy zu killen.

PDF:
- POST /api/pdf/drinks nimmt die Getraenkekarte entgegen, hinterlegt die
  URL in der drinks-Section und raeumt den Vorgaenger weg. Der Generator
  verlinkt sie, statt den Pfad fest im Markup zu haben.

Ausserdem:
- Vorschaubilder im Admin auf eine feste Box gezwungen. Uploads sind bis
  1600px breit und haben das Layout je nach Seitenverhaeltnis zerrissen.
- git.service.ts sichert public/pdf beim Neu-Clone mit weg, nicht nur
  public/images.
- Der Publish scheitert nicht mehr, wenn nur das Audit-Log nicht
  geschrieben werden kann - der Push ist da laengst durch.
- Upload-Logik lag dreifach kopiert vor, jetzt in upload.service.ts. Der
  Helfer prueft auch auf abgeschnittene Dateien; bisher landete bei zu
  grossen Uploads ein kaputtes Bild auf der Platte.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-08-11 11:42:59 +02:00
co-authored by Claude Opus 5
parent 2af70c238b
commit 60246f3941
12 changed files with 893 additions and 138 deletions
+114
View File
@@ -0,0 +1,114 @@
import fs from 'fs';
import path from 'path';
import { env } from '../config/env.js';
/**
* Verwaltet die Dateien, die das CMS in den Git-Workspace schreibt.
*
* 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.
*/
// 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;
export class AssetService {
private publicDir: string;
constructor() {
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
}
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb */
private resolveInPublic(url: string): string | null {
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
const relative = url.replace(/^\/+/, '').split('?')[0].split('#')[0];
const absolute = path.resolve(this.publicDir, relative);
// Traversal-Schutz: muss unterhalb von public/ bleiben
if (absolute !== this.publicDir && !absolute.startsWith(this.publicDir + path.sep)) {
return null;
}
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 {
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;
}
/** Loescht ein hochgeladenes Bild. Gibt zurueck, ob wirklich etwas weg ist. */
deleteImage(url: string): boolean {
return this.unlink(this.resolveDeletable(url, MANAGED_IMAGE_DIRS));
}
/** Loescht ein hochgeladenes PDF. */
deletePdf(url: string): boolean {
return this.unlink(this.resolveDeletable(url, [MANAGED_PDF_DIR]));
}
private unlink(absolute: string | null): boolean {
if (!absolute) return false;
try {
fs.unlinkSync(absolute);
return true;
} catch (err: any) {
if (err?.code === 'ENOENT') return false;
throw err;
}
}
/**
* 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[] = [];
for (const dir of MANAGED_IMAGE_DIRS) {
const absoluteDir = path.join(this.publicDir, dir);
if (!fs.existsSync(absoluteDir)) continue;
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);
}
}
return removed;
}
}