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:
@@ -27,14 +27,16 @@ export function initDatabase() {
|
|||||||
console.log('🔧 Initializing database...');
|
console.log('🔧 Initializing database...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if users table exists (acts as a sentinel for initial setup)
|
// Nur zur Protokollierung - angelegt wird immer, siehe unten
|
||||||
const tableCheck = sqlite
|
const tableCheck = sqlite
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!tableCheck) {
|
console.log(tableCheck ? '📝 Checking database schema...' : '📝 Creating database schema...');
|
||||||
console.log('📝 Creating database schema...');
|
|
||||||
|
|
||||||
|
// Laeuft bei JEDEM Start. Alle Anweisungen sind IF NOT EXISTS, und nur so
|
||||||
|
// bekommen bestehende Datenbanken spaeter ergaenzte Tabellen ueberhaupt.
|
||||||
|
{
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
@@ -84,6 +86,11 @@ export function initDatabase() {
|
|||||||
updated_at INTEGER DEFAULT (unixepoch())
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS managed_assets (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS publish_history (
|
CREATE TABLE IF NOT EXISTS publish_history (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT REFERENCES users(id),
|
user_id TEXT REFERENCES users(id),
|
||||||
@@ -93,9 +100,7 @@ export function initDatabase() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('✅ Database schema created successfully!');
|
console.log('✅ Database schema is up to date.');
|
||||||
} else {
|
|
||||||
console.log('✅ Database already initialized.');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error initializing database:', error);
|
console.error('❌ Error initializing database:', error);
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ export const publishHistory = sqliteTable('publish_history', {
|
|||||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vom CMS selbst angelegte Dateien.
|
||||||
|
//
|
||||||
|
// Frueher wurde am Dateinamen erkannt, ob das CMS eine Datei loeschen darf.
|
||||||
|
// Seit die Namen aus Alt-Text bzw. Original-Dateiname abgeleitet werden,
|
||||||
|
// traegt der Name diese Information nicht mehr - ein hochgeladenes
|
||||||
|
// karaoke-abend.avif sieht aus wie ein handgepflegtes event_karaoke.jpg.
|
||||||
|
// Deshalb wird der Besitz hier festgehalten.
|
||||||
|
export const managedAssets = sqliteTable('managed_assets', {
|
||||||
|
path: text('path').primaryKey(), // z.B. /images/events/karaoke-abend.avif
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
// Banner table (for announcements like holidays, special info)
|
// Banner table (for announcements like holidays, special info)
|
||||||
export const banners = sqliteTable('banners', {
|
export const banners = sqliteTable('banners', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
|||||||
@@ -116,7 +116,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
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 });
|
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,14 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
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 });
|
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
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)
|
// Store in DB (optional but useful)
|
||||||
const [row] = await db.insert(galleryImages).values({
|
const [row] = await db.insert(galleryImages).values({
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { contentSections } from '../db/schema.js';
|
import { contentSections } from '../db/schema.js';
|
||||||
import { AssetService } from '../services/asset.service.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';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
const assets = new AssetService();
|
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' });
|
return reply.code(400).send({ error: 'File is not a valid PDF' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
|
const pdfUrl = await saveUploadedPdf(file, buffer);
|
||||||
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}`;
|
|
||||||
|
|
||||||
// URL in der Content-Section hinterlegen
|
// URL in der Content-Section hinterlegen
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -106,11 +98,12 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
|
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
|
||||||
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
|
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
|
||||||
if (previousUrl && previousUrl !== pdfUrl) {
|
if (previousUrl && previousUrl !== pdfUrl && (await isManagedAsset(previousUrl))) {
|
||||||
try {
|
try {
|
||||||
if (assets.deletePdf(previousUrl)) {
|
if (assets.deletePdf(previousUrl)) {
|
||||||
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
|
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
|
||||||
}
|
}
|
||||||
|
await forgetManagedAsset(previousUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.warn({ err }, 'Could not remove replaced PDF');
|
fastify.log.warn({ err }, 'Could not remove replaced PDF');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { FastifyPluginAsync } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { GitService } from '../services/git.service.js';
|
import { GitService } from '../services/git.service.js';
|
||||||
import { FileGeneratorService } from '../services/file-generator.service.js';
|
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||||
import { AssetService } from '../services/asset.service.js';
|
import { sweepOrphanedImages } from '../services/image-refs.service.js';
|
||||||
import { collectReferencedImageUrls } from '../services/image-refs.service.js';
|
|
||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -36,12 +35,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
fastify.log.info('Git repository initialized');
|
fastify.log.info('Git repository initialized');
|
||||||
|
|
||||||
// Verwaiste Uploads entfernen, bevor committet wird. Die Referenzliste
|
// Verwaiste Uploads entfernen, bevor committet wird
|
||||||
// deckt bewusst alle Zeilen ab, auch unveroeffentlichte, und zusaetzlich
|
const removedImages = await sweepOrphanedImages();
|
||||||
// die Bildpfade aus den Textbereichen.
|
|
||||||
const assetService = new AssetService();
|
|
||||||
const referencedImages = await collectReferencedImageUrls();
|
|
||||||
const removedImages = assetService.sweepOrphanedImages([...referencedImages]);
|
|
||||||
|
|
||||||
if (removedImages.length > 0) {
|
if (removedImages.length > 0) {
|
||||||
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
|
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
|
||||||
|
|||||||
@@ -3,21 +3,16 @@ import path from 'path';
|
|||||||
import { env } from '../config/env.js';
|
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.
|
* Ob eine konkrete Datei geloescht werden DARF, entscheidet diese Klasse
|
||||||
* Uploads bekommen den Namen <base36-zeitstempel>-<6 zufaellige zeichen>.<ext>
|
* bewusst nicht - das steht in managed-assets.service.ts.
|
||||||
* (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
|
// Unterordner unterhalb von public/
|
||||||
const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
|
export const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
|
||||||
const MANAGED_PDF_DIR = 'pdf';
|
export 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 {
|
export class AssetService {
|
||||||
private publicDir: string;
|
private publicDir: string;
|
||||||
@@ -26,7 +21,7 @@ export class AssetService {
|
|||||||
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
|
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 {
|
private resolveInPublic(url: string): string | null {
|
||||||
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
|
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
|
||||||
|
|
||||||
@@ -41,31 +36,31 @@ export class AssetService {
|
|||||||
return absolute;
|
return absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Absoluter Pfad, sofern die Datei in einem der erlaubten Ordner liegt. */
|
||||||
* Pfad einer Datei, die das CMS loeschen darf.
|
resolveInDirs(url: string, dirs: string[]): string | null {
|
||||||
* Liefert null fuer fremde Pfade und fuer handgepflegte Dateien.
|
|
||||||
*/
|
|
||||||
resolveDeletable(url: string, dirs: string[]): string | null {
|
|
||||||
const absolute = this.resolveInPublic(url);
|
const absolute = this.resolveInPublic(url);
|
||||||
if (!absolute) return null;
|
if (!absolute) return null;
|
||||||
|
|
||||||
const relative = path.relative(this.publicDir, absolute);
|
const relative = path.relative(this.publicDir, absolute);
|
||||||
const dir = path.dirname(relative).split(path.sep).join('/');
|
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 dirs.includes(dir) ? absolute : null;
|
||||||
|
|
||||||
return absolute;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
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 {
|
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 {
|
private unlink(absolute: string | null): boolean {
|
||||||
@@ -79,19 +74,9 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Alle Bilddateien, die in den verwalteten Ordnern liegen, als URL-Pfade. */
|
||||||
* Entfernt alle hochgeladenen Bilder, die in keiner der uebergebenen URLs
|
listImageFiles(): string[] {
|
||||||
* mehr vorkommen. Die Liste muss ALLE Datensaetze abdecken, auch
|
const found: string[] = [];
|
||||||
* 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) {
|
for (const dir of MANAGED_IMAGE_DIRS) {
|
||||||
const absoluteDir = path.join(this.publicDir, dir);
|
const absoluteDir = path.join(this.publicDir, dir);
|
||||||
@@ -99,16 +84,11 @@ export class AssetService {
|
|||||||
|
|
||||||
for (const name of fs.readdirSync(absoluteDir)) {
|
for (const name of fs.readdirSync(absoluteDir)) {
|
||||||
const absolute = path.join(absoluteDir, name);
|
const absolute = path.join(absoluteDir, name);
|
||||||
|
|
||||||
if (!GENERATED_NAME.test(name)) continue;
|
|
||||||
if (keep.has(absolute)) continue;
|
|
||||||
if (!fs.statSync(absolute).isFile()) continue;
|
if (!fs.statSync(absolute).isFile()) continue;
|
||||||
|
found.push(`/${dir}/${name}`);
|
||||||
fs.unlinkSync(absolute);
|
|
||||||
removed.push('/' + dir + '/' + name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed;
|
return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,45 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events, galleryImages, contentSections } from '../db/schema.js';
|
import { events, galleryImages, contentSections } from '../db/schema.js';
|
||||||
import { AssetService } from './asset.service.js';
|
import { AssetService } from './asset.service.js';
|
||||||
|
import { isManagedAsset, forgetManagedAsset } from './managed-assets.service.js';
|
||||||
|
|
||||||
const assets = new AssetService();
|
const assets = new AssetService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird.
|
* Loescht eine Bilddatei, sofern das CMS sie selbst angelegt hat und kein
|
||||||
* Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden.
|
* Datensatz sie mehr benutzt. Muss NACH dem Loeschen bzw. Aktualisieren der
|
||||||
|
* Zeile aufgerufen werden.
|
||||||
*/
|
*/
|
||||||
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
|
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
|
if (!(await isManagedAsset(url))) return false;
|
||||||
|
|
||||||
const referenced = await collectReferencedImageUrls();
|
const referenced = await collectReferencedImageUrls();
|
||||||
if (referenced.has(url)) return false;
|
if (referenced.has(url)) return false;
|
||||||
return assets.deleteImage(url);
|
|
||||||
|
const deleted = assets.deleteImage(url);
|
||||||
|
await forgetManagedAsset(url);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt alle vom CMS angelegten Bilder, die nirgends mehr referenziert
|
||||||
|
* werden. Die Referenzliste deckt bewusst ALLE Zeilen ab, auch
|
||||||
|
* unveroeffentlichte - sonst verlieren die ihr Bild.
|
||||||
|
*/
|
||||||
|
export async function sweepOrphanedImages(): Promise<string[]> {
|
||||||
|
const referenced = await collectReferencedImageUrls();
|
||||||
|
const removed: string[] = [];
|
||||||
|
|
||||||
|
for (const url of assets.listImageFiles()) {
|
||||||
|
if (referenced.has(url)) continue;
|
||||||
|
if (!(await isManagedAsset(url))) continue;
|
||||||
|
|
||||||
|
if (assets.deleteImage(url)) removed.push(url);
|
||||||
|
await forgetManagedAsset(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { managedAssets } from '../db/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt Buch darueber, welche Dateien das CMS selbst angelegt hat.
|
||||||
|
* Nur diese darf es spaeter wieder loeschen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Altbestand: vor der Umstellung auf sprechende Namen hiessen Uploads
|
||||||
|
// <base36-zeitstempel>-<6 zeichen>.<ext>. Diese Dateien stehen nicht in der
|
||||||
|
// Tabelle, sollen aber weiterhin aufgeraeumt werden koennen. Handgepflegte
|
||||||
|
// Assets wie event_karaoke.jpg oder Gallery1.webp passen nicht auf das Muster.
|
||||||
|
const LEGACY_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
|
||||||
|
|
||||||
|
function basename(urlPath: string): string {
|
||||||
|
return urlPath.split('/').pop() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merkt sich eine neu angelegte Datei. */
|
||||||
|
export async function registerManagedAsset(urlPath: string): Promise<void> {
|
||||||
|
await db.insert(managedAssets).values({ path: urlPath }).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergisst eine Datei wieder (nach dem Loeschen). */
|
||||||
|
export async function forgetManagedAsset(urlPath: string): Promise<void> {
|
||||||
|
await db.delete(managedAssets).where(eq(managedAssets.path, urlPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf das CMS diese Datei loeschen? */
|
||||||
|
export async function isManagedAsset(urlPath: string): Promise<boolean> {
|
||||||
|
if (!urlPath) return false;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(managedAssets)
|
||||||
|
.where(eq(managedAssets.path, urlPath))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (row) return true;
|
||||||
|
|
||||||
|
return LEGACY_NAME.test(basename(urlPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle vom CMS angelegten Pfade aus der Tabelle. */
|
||||||
|
export async function listManagedAssets(): Promise<string[]> {
|
||||||
|
const rows = (await db.select().from(managedAssets)) as any[];
|
||||||
|
return rows.map((row) => row.path as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trifft der Altbestands-Namensstil zu? */
|
||||||
|
export function hasLegacyName(urlPath: string): boolean {
|
||||||
|
return LEGACY_NAME.test(basename(urlPath));
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
import { AssetService, MANAGED_IMAGE_DIRS, MANAGED_PDF_DIR } from './asset.service.js';
|
||||||
|
import { registerManagedAsset } from './managed-assets.service.js';
|
||||||
|
|
||||||
export type UploadSubdir = 'events' | 'gallery' | 'content';
|
export type UploadSubdir = 'events' | 'gallery' | 'content';
|
||||||
|
|
||||||
@@ -9,18 +11,64 @@ export interface SavedImage {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assets = new AssetService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nimmt einen Multipart-Upload entgegen, rechnet ihn nach WebP herunter und
|
* Macht aus "Karaoke-Abend im Gallus Pub!" -> "karaoke-abend-im-gallus-pub".
|
||||||
* legt ihn unter public/images/<subdir> im Git-Workspace ab.
|
* Umlaute werden ausgeschrieben, nicht entfernt, damit aus "Getränke" nicht
|
||||||
|
* "getrnke" wird.
|
||||||
|
*/
|
||||||
|
export function slugify(value: string): string {
|
||||||
|
const slug = String(value || '')
|
||||||
|
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
||||||
|
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 60)
|
||||||
|
.replace(/-+$/, '');
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname ohne Endung, wie er vor dem Upload hiess. */
|
||||||
|
function originalBaseName(file: any): string {
|
||||||
|
const name = (file?.filename as string | undefined) || '';
|
||||||
|
return name.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht einen freien Namen. Gibt es <basis>.avif schon, wird -2, -3, ...
|
||||||
|
* angehaengt, damit zwei Events namens "Karaoke" sich nicht ueberschreiben.
|
||||||
|
*/
|
||||||
|
function findFreeName(base: string, ext: string, urlDir: string, dirs: string[]): string {
|
||||||
|
const safeBase = base || `datei-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 500; attempt++) {
|
||||||
|
const candidate = attempt === 1 ? `${safeBase}${ext}` : `${safeBase}-${attempt}${ext}`;
|
||||||
|
if (!assets.exists(`${urlDir}/${candidate}`, dirs)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sollte nie eintreten - lieber ein haesslicher Name als eine Endlosschleife
|
||||||
|
return `${safeBase}-${Date.now().toString(36)}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt einen Multipart-Upload entgegen, rechnet ihn auf 1600px herunter,
|
||||||
|
* wandelt nach AVIF und legt ihn unter public/images/<subdir> ab.
|
||||||
*
|
*
|
||||||
* Der Dateiname folgt dem Muster <zeitstempel>-<zufall>.<ext>. Daran erkennt
|
* Der Name kommt aus preferredName (Event-Titel bzw. Alt-Text) und faellt
|
||||||
* der AssetService spaeter, dass er die Datei wieder loeschen darf.
|
* sonst auf den urspruenglichen Dateinamen zurueck.
|
||||||
*/
|
*/
|
||||||
export async function saveUploadedImage(
|
export async function saveUploadedImage(
|
||||||
file: any,
|
file: any,
|
||||||
subdir: UploadSubdir,
|
subdir: UploadSubdir,
|
||||||
log?: { warn: (obj: any, msg: string) => void }
|
options: { preferredName?: string; log?: { warn: (obj: any, msg: string) => void } } = {}
|
||||||
): Promise<SavedImage> {
|
): Promise<SavedImage> {
|
||||||
|
const { preferredName, log } = options;
|
||||||
|
|
||||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
@@ -39,11 +87,8 @@ export async function saveUploadedImage(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stamp = Date.now().toString(36);
|
|
||||||
const rand = Math.random().toString(36).slice(2, 8);
|
|
||||||
|
|
||||||
let outBuffer: Buffer;
|
let outBuffer: Buffer;
|
||||||
let outExt = '.webp';
|
let outExt = '.avif';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Sharp erst laden wenn wirklich gebraucht
|
// Sharp erst laden wenn wirklich gebraucht
|
||||||
@@ -51,7 +96,7 @@ export async function saveUploadedImage(
|
|||||||
outBuffer = await sharp(inputBuffer)
|
outBuffer = await sharp(inputBuffer)
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
.webp({ quality: 82 })
|
.avif({ quality: 55 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.warn({ err }, 'Sharp processing failed, using original image');
|
log?.warn({ err }, 'Sharp processing failed, using original image');
|
||||||
@@ -60,8 +105,33 @@ export async function saveUploadedImage(
|
|||||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = `${stamp}-${rand}${outExt}`;
|
const base = slugify(preferredName || '') || slugify(originalBaseName(file));
|
||||||
|
const urlDir = `/images/${subdir}`;
|
||||||
|
const filename = findFreeName(base, outExt, urlDir, MANAGED_IMAGE_DIRS);
|
||||||
|
|
||||||
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
|
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
|
||||||
|
|
||||||
return { filename, imageUrl: `/images/${subdir}/${filename}` };
|
const imageUrl = `${urlDir}/${filename}`;
|
||||||
|
await registerManagedAsset(imageUrl);
|
||||||
|
|
||||||
|
return { filename, imageUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt ein hochgeladenes PDF unter public/pdf ab, benannt nach dem
|
||||||
|
* urspruenglichen Dateinamen.
|
||||||
|
*/
|
||||||
|
export async function saveUploadedPdf(file: any, buffer: Buffer, preferredName?: string): Promise<string> {
|
||||||
|
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
const base = slugify(preferredName || '') || slugify(originalBaseName(file)) || 'dokument';
|
||||||
|
const filename = findFreeName(base, '.pdf', '/pdf', [MANAGED_PDF_DIR]);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
||||||
|
|
||||||
|
const pdfUrl = `/pdf/${filename}`;
|
||||||
|
await registerManagedAsset(pdfUrl);
|
||||||
|
|
||||||
|
return pdfUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -254,8 +254,11 @@ const title = 'Admin';
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ========== Events & Publish ==========
|
// ========== Events & Publish ==========
|
||||||
async function uploadEventImage(file) {
|
// Wichtig: Textfelder IMMER vor der Datei anhaengen. Der Server liest
|
||||||
|
// sie beim Empfang der Datei aus; was danach kommt, ist zu spaet.
|
||||||
|
async function uploadEventImage(file, title) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
fd.append('title', title || '');
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
|
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
@@ -264,9 +267,9 @@ const title = 'Admin';
|
|||||||
|
|
||||||
async function uploadGalleryImage(file, altText) {
|
async function uploadGalleryImage(file, altText) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
|
||||||
if (altText) fd.append('altText', altText);
|
if (altText) fd.append('altText', altText);
|
||||||
fd.append('displayOrder', '0');
|
fd.append('displayOrder', '0');
|
||||||
|
fd.append('file', file);
|
||||||
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
|
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -372,7 +375,8 @@ const title = 'Admin';
|
|||||||
try {
|
try {
|
||||||
let imageUrl = '';
|
let imageUrl = '';
|
||||||
if (file) {
|
if (file) {
|
||||||
const up = await uploadEventImage(file);
|
// Titel mitgeben, damit die Datei danach benannt wird
|
||||||
|
const up = await uploadEventImage(file, title);
|
||||||
imageUrl = up?.imageUrl || '';
|
imageUrl = up?.imageUrl || '';
|
||||||
}
|
}
|
||||||
msg.textContent = 'Lege Event an...';
|
msg.textContent = 'Lege Event an...';
|
||||||
@@ -655,8 +659,9 @@ const title = 'Admin';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadContentImage(file) {
|
async function uploadContentImage(file, name) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
fd.append('name', name || '');
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
const res = await fetch(API_BASE + '/api/content/upload', { method: 'POST', body: fd, credentials: 'include' });
|
const res = await fetch(API_BASE + '/api/content/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
@@ -664,10 +669,10 @@ const title = 'Admin';
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt die Datei aus einem Input hoch, sofern eine gewählt wurde. */
|
/** Lädt die Datei aus einem Input hoch, sofern eine gewählt wurde. */
|
||||||
async function maybeUpload(inputId, current) {
|
async function maybeUpload(inputId, current, name) {
|
||||||
const file = byId(inputId).files[0];
|
const file = byId(inputId).files[0];
|
||||||
if (!file) return current;
|
if (!file) return current;
|
||||||
const up = await uploadContentImage(file);
|
const up = await uploadContentImage(file, name);
|
||||||
byId(inputId).value = '';
|
byId(inputId).value = '';
|
||||||
return up?.imageUrl || current;
|
return up?.imageUrl || current;
|
||||||
}
|
}
|
||||||
@@ -710,7 +715,7 @@ const title = 'Admin';
|
|||||||
msg.textContent = 'Lade Bild hoch...';
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
msg.className = 'muted';
|
msg.className = 'muted';
|
||||||
try {
|
try {
|
||||||
const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl);
|
const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl, 'willkommen');
|
||||||
await saveSection('welcome', {
|
await saveSection('welcome', {
|
||||||
...contentState.welcome,
|
...contentState.welcome,
|
||||||
heading1: byId('wel-heading1').value.trim(),
|
heading1: byId('wel-heading1').value.trim(),
|
||||||
@@ -731,10 +736,11 @@ const title = 'Admin';
|
|||||||
msg.textContent = 'Lade Dateien hoch...';
|
msg.textContent = 'Lade Dateien hoch...';
|
||||||
msg.className = 'muted';
|
msg.className = 'muted';
|
||||||
try {
|
try {
|
||||||
const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage);
|
const specialName = byId('dr-special-name').value.trim();
|
||||||
const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1);
|
const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage, specialName || 'monats-hit');
|
||||||
const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2);
|
const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1, 'whiskey-1');
|
||||||
const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3);
|
const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2, 'whiskey-2');
|
||||||
|
const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3, 'whiskey-3');
|
||||||
|
|
||||||
// PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt
|
// PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt
|
||||||
const pdfFile = byId('dr-pdf-file').files[0];
|
const pdfFile = byId('dr-pdf-file').files[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user