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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user