From 60246f3941d3d9053ec36a3e8e7b301ecb2be881 Mon Sep 17 00:00:00 2001 From: Kenzo Date: Tue, 11 Aug 2026 11:41:30 +0200 Subject: [PATCH] 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 -.) 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) --- backend/src/index.ts | 11 + backend/src/routes/content.ts | 44 +++ backend/src/routes/events.ts | 78 ++--- backend/src/routes/gallery.ts | 77 ++--- backend/src/routes/pdf.ts | 132 ++++++++ backend/src/routes/publish.ts | 32 +- backend/src/services/asset.service.ts | 114 +++++++ .../src/services/file-generator.service.ts | 55 +++- backend/src/services/git.service.ts | 33 +- backend/src/services/image-refs.service.ts | 77 +++++ backend/src/services/upload.service.ts | 67 ++++ src/pages/admin.astro | 311 +++++++++++++++++- 12 files changed, 893 insertions(+), 138 deletions(-) create mode 100644 backend/src/routes/pdf.ts create mode 100644 backend/src/services/asset.service.ts create mode 100644 backend/src/services/image-refs.service.ts create mode 100644 backend/src/services/upload.service.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 6d5d4c4..a251732 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,7 @@ import contentRoute from './routes/content.js'; import settingsRoute from './routes/settings.js'; import publishRoute from './routes/publish.js'; import bannersRoute from './routes/banners.js'; +import pdfRoute from './routes/pdf.js'; // Validate environment variables try { @@ -99,6 +100,15 @@ fastify.register(fastifyStatic, { decorateReply: false }); +// Dasselbe fuer die hochgeladenen PDFs (Getraenkekarte) +const pdfDir = path.join(dataDir, 'public', 'pdf'); +fs.mkdirSync(pdfDir, { recursive: true }); +fastify.register(fastifyStatic, { + root: pdfDir, + prefix: '/pdf/', + decorateReply: false +}); + // Decorate fastify with authenticate method fastify.decorate('authenticate', authenticate); @@ -110,6 +120,7 @@ fastify.register(contentRoute, { prefix: '/api' }); fastify.register(settingsRoute, { prefix: '/api' }); fastify.register(publishRoute, { prefix: '/api' }); fastify.register(bannersRoute, { prefix: '/api' }); +fastify.register(pdfRoute, { prefix: '/api' }); // Health check fastify.get('/health', async () => { diff --git a/backend/src/routes/content.ts b/backend/src/routes/content.ts index 47c2081..38a292f 100644 --- a/backend/src/routes/content.ts +++ b/backend/src/routes/content.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { db } from '../config/database.js'; import { contentSections } from '../db/schema.js'; import { eq } from 'drizzle-orm'; +import { saveUploadedImage } from '../services/upload.service.js'; +import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js'; // Fastify JSON schema for content section body const contentBodyJsonSchema = { @@ -78,6 +80,20 @@ const contentRoute: FastifyPluginAsync = async (fastify) => { .returning(); } + // Bilder, die durch die Aenderung herausgefallen sind, wegraeumen + const before = extractImageUrls((existing as any)?.contentJson); + const after = extractImageUrls(result.contentJson); + for (const url of before) { + if (after.has(url)) continue; + try { + if (await dropImageIfUnused(url)) { + fastify.log.info(`Removed unused content image ${url}`); + } + } catch (err) { + fastify.log.warn({ err }, 'Could not remove unused content image'); + } + } + return { section: result.sectionName, content: result.contentJson, @@ -85,6 +101,34 @@ const contentRoute: FastifyPluginAsync = async (fastify) => { }; }); + // Bild fuer einen Textbereich hochladen (Welcome-Bild, Monatshit, Whiskey) + fastify.post('/content/upload', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + try { + const file = await (request as any).file(); + if (!file) { + return reply.code(400).send({ error: 'No file uploaded' }); + } + + const mime = file.mimetype as string | undefined; + if (!mime || !mime.startsWith('image/')) { + return reply.code(400).send({ error: 'Only image uploads are allowed' }); + } + + const saved = await saveUploadedImage(file, 'content', fastify.log); + + return reply.code(201).send({ imageUrl: saved.imageUrl }); + + } catch (err: any) { + if (err?.statusCode === 413) { + return reply.code(413).send({ error: err.message }); + } + fastify.log.error({ err }, 'Upload failed'); + return reply.code(500).send({ error: 'Failed to upload image' }); + } + }); + // List all content sections fastify.get('/content', { preHandler: [fastify.authenticate], diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 6cdb352..881078e 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -2,8 +2,19 @@ import { FastifyPluginAsync } from 'fastify'; import { db } from '../config/database.js'; import { events } from '../db/schema.js'; import { eq } from 'drizzle-orm'; -import fs from 'fs'; -import path from 'path'; +import { dropImageIfUnused } from '../services/image-refs.service.js'; +import { saveUploadedImage } from '../services/upload.service.js'; + +/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */ +async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) { + try { + if (await dropImageIfUnused(url)) { + fastify.log.info(`Removed ${reason} ${url}`); + } + } catch (err) { + fastify.log.warn({ err }, `Could not remove ${reason}`); + } +} // Fastify JSON schema for event body const eventBodyJsonSchema = { @@ -71,8 +82,17 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => { const { id } = request.params as { id: string }; const data = request.body as any; + + const [previous] = await db.select().from(events).where(eq(events.id, id)).limit(1); + const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning(); if (!row) return reply.code(404).send({ error: 'Event not found' }); + + // Ausgetauschtes Bild wegraeumen, sonst bleibt es fuer immer liegen + if (previous && previous.imageUrl !== row.imageUrl) { + await dropUnusedImage(fastify, previous.imageUrl, 'replaced event image'); + } + return { event: row }; }); @@ -92,52 +112,14 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'Only image uploads are allowed' }); } - // Prepare directories - use persistent volume for Fly.io - const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); - const uploadDir = path.join(dataDir, 'public', 'images', 'events'); - if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + const saved = await saveUploadedImage(file, 'events', fastify.log); - // Read uploaded stream into buffer - const chunks: Buffer[] = []; - for await (const chunk of file.file) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return reply.code(201).send({ imageUrl: saved.imageUrl }); + + } catch (err: any) { + if (err?.statusCode === 413) { + return reply.code(413).send({ error: err.message }); } - const inputBuffer = Buffer.concat(chunks); - - // Generate filename - const stamp = Date.now().toString(36); - const rand = Math.random().toString(36).slice(2, 8); - const baseName = `${stamp}-${rand}`; - - // Try to convert to webp and limit size; fallback to original - let outBuffer: Buffer | null = null; - let outExt = '.webp'; - try { - // Lazy load sharp only when needed - const sharp = (await import('sharp')).default; - outBuffer = await sharp(inputBuffer) - .rotate() - .resize({ width: 1600, withoutEnlargement: true }) - .webp({ quality: 82 }) - .toBuffer(); - } catch (err) { - fastify.log.warn({ err }, 'Sharp processing failed, using original image'); - outBuffer = inputBuffer; - // naive extension from mimetype - const extFromMime = mime.split('/')[1] || 'bin'; - outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase(); - } - - const filename = baseName + outExt; - const destPath = path.join(uploadDir, filename); - fs.writeFileSync(destPath, outBuffer); - - // Public URL (served via /static) - const publicUrl = `/images/events/${filename}`; - - return reply.code(201).send({ imageUrl: publicUrl }); - - } catch (err) { fastify.log.error({ err }, 'Upload failed'); return reply.code(500).send({ error: 'Failed to upload image' }); } @@ -148,6 +130,10 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { const { id } = request.params as { id: string }; const [row] = await db.delete(events).where(eq(events.id, id)).returning(); if (!row) return reply.code(404).send({ error: 'Event not found' }); + + // Zugehoerige Bilddatei mitnehmen + await dropUnusedImage(fastify, row.imageUrl, 'event image'); + return { message: 'Event deleted successfully' }; }); diff --git a/backend/src/routes/gallery.ts b/backend/src/routes/gallery.ts index e6695af..97fd179 100644 --- a/backend/src/routes/gallery.ts +++ b/backend/src/routes/gallery.ts @@ -3,8 +3,19 @@ import { z } from 'zod'; import { db } from '../config/database.js'; import { galleryImages } from '../db/schema.js'; import { eq } from 'drizzle-orm'; -import fs from 'fs'; -import path from 'path'; +import { dropImageIfUnused } from '../services/image-refs.service.js'; +import { saveUploadedImage } from '../services/upload.service.js'; + +/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */ +async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) { + try { + if (await dropImageIfUnused(url)) { + fastify.log.info(`Removed ${reason} ${url}`); + } + } catch (err) { + fastify.log.warn({ err }, `Could not remove ${reason}`); + } +} // Fastify JSON schema for gallery image body const galleryBodyJsonSchema = { @@ -84,60 +95,22 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'Only image uploads are allowed' }); } - // Prepare directories - use persistent volume for Fly.io - const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); - const uploadDir = path.join(dataDir, 'public', 'images', 'gallery'); - if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); - - // Read uploaded stream into buffer - const chunks: Buffer[] = []; - for await (const chunk of file.file) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const inputBuffer = Buffer.concat(chunks); - - // Generate filename - const stamp = Date.now().toString(36); - const rand = Math.random().toString(36).slice(2, 8); - const baseName = `${stamp}-${rand}`; - - // Try to convert to webp and limit size; fallback to original - let outBuffer: Buffer | null = null; - let outExt = '.webp'; - try { - // Lazy load sharp only when needed - const sharp = (await import('sharp')).default; - outBuffer = await sharp(inputBuffer) - .rotate() - .resize({ width: 1600, withoutEnlargement: true }) - .webp({ quality: 82 }) - .toBuffer(); - } catch (err) { - fastify.log.warn({ err }, 'Sharp processing failed, using original image'); - outBuffer = inputBuffer; - // naive extension from mimetype - const extFromMime = mime.split('/')[1] || 'bin'; - outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase(); - } - - const filename = baseName + outExt; - const destPath = path.join(uploadDir, filename); - fs.writeFileSync(destPath, outBuffer); - - // Public URL (served via /static) - const publicUrl = `/images/gallery/${filename}`; + const saved = await saveUploadedImage(file, 'gallery', fastify.log); // Store in DB (optional but useful) const [row] = await db.insert(galleryImages).values({ - imageUrl: publicUrl, - altText: altText || filename, + imageUrl: saved.imageUrl, + altText: altText || saved.filename, displayOrder, isPublished: true, }).returning(); return reply.code(201).send({ image: row }); - } catch (err) { + } catch (err: any) { + if (err?.statusCode === 413) { + return reply.code(413).send({ error: err.message }); + } fastify.log.error({ err }, 'Upload failed'); return reply.code(500).send({ error: 'Failed to upload image' }); } @@ -153,6 +126,8 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { const { id } = request.params as { id: string }; const data = request.body as any; + const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1); + const [updated] = await db .update(galleryImages) .set(data) @@ -163,6 +138,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { return reply.code(404).send({ error: 'Image not found' }); } + // Ausgetauschte Datei wegraeumen + if (previous && previous.imageUrl !== updated.imageUrl) { + await dropUnusedImage(fastify, previous.imageUrl, 'replaced gallery image'); + } + return { image: updated }; }); @@ -181,6 +161,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { return reply.code(404).send({ error: 'Image not found' }); } + // Zugehoerige Bilddatei mitnehmen + await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image'); + return { message: 'Image deleted successfully' }; }); diff --git a/backend/src/routes/pdf.ts b/backend/src/routes/pdf.ts new file mode 100644 index 0000000..1ee4e21 --- /dev/null +++ b/backend/src/routes/pdf.ts @@ -0,0 +1,132 @@ +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 { env } from '../config/env.js'; + +const assets = new AssetService(); + +/** + * Feste PDF-Plaetze. Die URL landet jeweils in einer Content-Section, damit + * der Generator sie beim Publish in die Astro-Komponente schreiben kann. + */ +const PDF_SLOTS: Record = { + drinks: { section: 'drinks', field: 'pdfUrl' }, +}; + +/** contentJson kommt je nach Treiber als Objekt oder als String zurueck. */ +function asObject(value: any): Record { + if (!value) return {}; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } + return typeof value === 'object' ? value : {}; +} + +const pdfRoute: FastifyPluginAsync = async (fastify) => { + + // PDF fuer einen festen Platz hochladen und verlinken + fastify.post('/pdf/:slot', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { slot } = request.params as { slot: string }; + const target = PDF_SLOTS[slot]; + + if (!target) { + return reply.code(404).send({ error: `Unknown PDF slot "${slot}"` }); + } + + try { + const file = await (request as any).file(); + if (!file) { + return reply.code(400).send({ error: 'No file uploaded' }); + } + + const mime = file.mimetype as string | undefined; + const originalName = (file.filename as string | undefined) || ''; + if (mime !== 'application/pdf' && !originalName.toLowerCase().endsWith('.pdf')) { + return reply.code(400).send({ error: 'Only PDF uploads are allowed' }); + } + + const chunks: Buffer[] = []; + for await (const chunk of file.file) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const buffer = Buffer.concat(chunks); + + if ((file.file as any)?.truncated) { + const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024); + return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` }); + } + + // Inhalt gegenpruefen, damit nicht irgendetwas mit .pdf-Endung landet + if (buffer.subarray(0, 5).toString('latin1') !== '%PDF-') { + 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}`; + + // URL in der Content-Section hinterlegen + const [existing] = await db + .select() + .from(contentSections) + .where(eq(contentSections.sectionName, target.section)) + .limit(1); + + const previousContent = asObject((existing as any)?.contentJson); + const previousUrl = previousContent[target.field]; + const content = { ...previousContent, [target.field]: pdfUrl }; + + if (existing) { + await db + .update(contentSections) + .set({ contentJson: content, updatedAt: new Date() }) + .where(eq(contentSections.sectionName, target.section)); + } else { + await db + .insert(contentSections) + .values({ sectionName: target.section, contentJson: content }); + } + + // Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs, + // die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen + if (previousUrl && previousUrl !== pdfUrl) { + try { + if (assets.deletePdf(previousUrl)) { + fastify.log.info(`Removed replaced PDF ${previousUrl}`); + } + } catch (err) { + fastify.log.warn({ err }, 'Could not remove replaced PDF'); + } + } + + return reply.code(201).send({ pdfUrl, section: target.section }); + + } catch (err: any) { + if (err?.code === 'FST_REQ_FILE_TOO_LARGE') { + const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024); + return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` }); + } + fastify.log.error({ err }, 'PDF upload failed'); + return reply.code(500).send({ error: 'Failed to upload PDF' }); + } + }); +}; + +export default pdfRoute; diff --git a/backend/src/routes/publish.ts b/backend/src/routes/publish.ts index 81dc49b..0c7d145 100644 --- a/backend/src/routes/publish.ts +++ b/backend/src/routes/publish.ts @@ -2,6 +2,8 @@ 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 { db } from '../config/database.js'; import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js'; import { eq } from 'drizzle-orm'; @@ -34,6 +36,17 @@ 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]); + + if (removedImages.length > 0) { + fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`); + } + // Fetch all content from database const eventsData = await db .select() @@ -78,16 +91,23 @@ const publishRoute: FastifyPluginAsync = async (fastify) => { fastify.log.info(`Changes committed: ${commitHash}`); - // Record in history - await db.insert(publishHistory).values({ - userId, - commitHash, - commitMessage, - }); + // Record in history. Der Push ist an dieser Stelle bereits durch - + // ein Fehler im Protokoll darf die Veroeffentlichung nicht als + // gescheitert melden und den Workspace zuruecksetzen. + try { + await db.insert(publishHistory).values({ + userId, + commitHash, + commitMessage, + }); + } catch (historyError) { + fastify.log.warn({ err: historyError }, 'Could not record publish history'); + } return { success: true, commitHash, + removedImages: removedImages.length, message: 'Changes published successfully', }; diff --git a/backend/src/services/asset.service.ts b/backend/src/services/asset.service.ts new file mode 100644 index 0000000..60ef2fa --- /dev/null +++ b/backend/src/services/asset.service.ts @@ -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 -<6 zufaellige zeichen>. + * (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(); + 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; + } +} diff --git a/backend/src/services/file-generator.service.ts b/backend/src/services/file-generator.service.ts index cac423c..74cd814 100644 --- a/backend/src/services/file-generator.service.ts +++ b/backend/src/services/file-generator.service.ts @@ -27,6 +27,27 @@ export class FileGeneratorService { return str.replace(/`/g, '\\`').replace(/\${/g, '\\${'); } + /** + * Texte aus dem Adminbereich landen direkt im Astro-Markup. Ohne Maskierung + * reicht ein "<" oder "&" in einem Feld, damit der Build der Seite scheitert + * und der Deploy stehen bleibt. + */ + escapeHtml(value: any): string { + if (value === undefined || value === null) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** Wie escapeHtml, aber mit Rueckfallwert wenn nichts gesetzt ist. */ + text(value: any, fallback = ''): string { + const raw = value === undefined || value === null || value === '' ? fallback : value; + return this.escapeHtml(raw); + } + generateIndexAstro(events: Event[], images: GalleryImage[]): string { const eventsCode = events.map(e => `\t{ \t\timage: "${e.imageUrl}", @@ -86,9 +107,9 @@ const { id } = Astro.props; \t\t
-\t\t\t

${content.heading || 'Dein Irish Pub'}

+\t\t\t

${this.text(content.heading, 'Dein Irish Pub')}

-\t\t\t

${content.subheading || 'Im Herzen von St.Gallen'}

+\t\t\t

${this.text(content.subheading, 'Im Herzen von St.Gallen')}

\t\t\tAktuelles ↓ \t\t
@@ -105,7 +126,7 @@ const { id } = Astro.props; generateWelcomeComponent(content: ContentSection): string { const highlightsList = (content.highlights || []).map((h: any) => - `\t\t\t
  • \n\t\t\t\t${h.title}: ${h.description}\n\t\t\t
  • ` + `\t\t\t
  • \n\t\t\t\t${this.escapeHtml(h?.title)}: ${this.escapeHtml(h?.description)}\n\t\t\t
  • ` ).join('\n\n'); return `--- @@ -119,11 +140,11 @@ const { id } = Astro.props; \t
    -\t\t

    ${content.heading1 || 'Herzlich willkommen im'}

    -\t\t

    ${content.heading2 || 'Gallus Pub!'}

    +\t\t

    ${this.text(content.heading1, 'Herzlich willkommen im')}

    +\t\t

    ${this.text(content.heading2, 'Gallus Pub!')}

    \t\t

    -\t\t\t${content.introText || ''} +\t\t\t${this.text(content.introText)} \t\t

    \t\t

    Unsere Highlights:

    @@ -133,14 +154,14 @@ ${highlightsList} \t\t \t\t

    -\t\t\t${content.closingText || ''} +\t\t\t${this.text(content.closingText)} \t\t

    \t
    \t
    -\t\tWelcome background image +\t\tWelcome background image \t
    @@ -157,36 +178,36 @@ const { id } = Astro.props;

    Drinks

    - ${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'} + ${this.text(content.introText, 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.')}

    - Getränkekarte + Getränkekarte

    Monats Hit

    -
    - Monats Hit +
    + Monats Hit
    -
    ${content.monthlySpecialName || 'Mate Vodka'}
    +
    ${this.text(content.monthlySpecialName, 'Mate Vodka')}

    - ${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'} + ${this.text(content.whiskeyText, 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.')}

    - Whiskey 1 + Whiskey 1
    - Whiskey 2 + Whiskey 2
    - Whiskey 3 + Whiskey 3
    diff --git a/backend/src/services/git.service.ts b/backend/src/services/git.service.ts index 2909c15..a3d8bca 100644 --- a/backend/src/services/git.service.ts +++ b/backend/src/services/git.service.ts @@ -4,6 +4,13 @@ import { existsSync } from 'fs'; import path from 'path'; import { env } from '../config/env.js'; +// Verzeichnisse, in die das CMS hochlaedt. Die muessen einen Neu-Clone des +// Workspace ueberleben, sonst sind Bilder und Getraenkekarte weg. +const UPLOAD_DIRS = [ + path.join('public', 'images'), + path.join('public', 'pdf'), +]; + export class GitService { private git: SimpleGit; private workspaceDir: string; @@ -51,15 +58,17 @@ export class GitService { if (!usable) { console.log('Cloning repository...'); - // Hochgeladene Bilder liegen im Workspace und wuerden beim Loeschen + // Hochgeladene Dateien liegen im Workspace und wuerden beim Loeschen // verschwinden - vorher wegsichern, nach dem Clone zurueckspielen - const imagesDir = path.join(this.workspaceDir, 'public', 'images'); - const backupDir = path.join(this.parentDir, '.workspace-images-backup'); - const hasImages = existsSync(imagesDir); + const backupRoot = path.join(this.parentDir, '.workspace-upload-backup'); + await rm(backupRoot, { recursive: true, force: true }); - await rm(backupDir, { recursive: true, force: true }); - if (hasImages) { - await cp(imagesDir, backupDir, { recursive: true }); + const saved: string[] = []; + for (const relative of UPLOAD_DIRS) { + const source = path.join(this.workspaceDir, relative); + if (!existsSync(source)) continue; + await cp(source, path.join(backupRoot, relative), { recursive: true }); + saved.push(relative); } await rm(this.workspaceDir, { recursive: true, force: true }); @@ -70,15 +79,16 @@ export class GitService { await this.git.clone(authenticatedUrl, this.workspaceDir); this.git = simpleGit(this.workspaceDir); - if (hasImages) { + for (const relative of saved) { // force: false -> was schon im Repo liegt, bleibt unangetastet - await cp(backupDir, imagesDir, { + await cp(path.join(backupRoot, relative), path.join(this.workspaceDir, relative), { recursive: true, force: false, errorOnExist: false, }); - await rm(backupDir, { recursive: true, force: true }); } + + await rm(backupRoot, { recursive: true, force: true }); } // Configure git user @@ -117,6 +127,7 @@ export class GitService { const git = simpleGit(this.workspaceDir); await git.reset(['--hard', 'HEAD']); // Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg - await git.clean('f', ['-d', '-e', 'public/images']); + const excludes = UPLOAD_DIRS.flatMap((dir) => ['-e', dir.split(path.sep).join('/')]); + await git.clean('f', ['-d', ...excludes]); } } diff --git a/backend/src/services/image-refs.service.ts b/backend/src/services/image-refs.service.ts new file mode 100644 index 0000000..a4c9b9a --- /dev/null +++ b/backend/src/services/image-refs.service.ts @@ -0,0 +1,77 @@ +import { db } from '../config/database.js'; +import { events, galleryImages, contentSections } from '../db/schema.js'; +import { AssetService } from './asset.service.js'; + +const assets = new AssetService(); + +/** + * Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird. + * Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden. + */ +export async function dropImageIfUnused(url: string | null | undefined): Promise { + if (!url) return false; + const referenced = await collectReferencedImageUrls(); + if (referenced.has(url)) return false; + return assets.deleteImage(url); +} + +/** + * Sammelt jede Bild-URL, die irgendwo in der Datenbank vorkommt. + * + * Bewusst ueber ALLE Zeilen, nicht nur die veroeffentlichten - sonst wuerde + * ein unveroeffentlichtes Event sein Bild verlieren, sobald jemand publisht. + * + * Die Content-Sections enthalten beliebiges JSON (Welcome-Bild, Monatshit, + * Whiskey-Bilder), deshalb wird es rekursiv nach Bildpfaden durchsucht. + */ +export async function collectReferencedImageUrls(): Promise> { + const urls = new Set(); + + for (const row of (await db.select().from(events)) as any[]) { + if (row.imageUrl) urls.add(row.imageUrl); + } + + for (const row of (await db.select().from(galleryImages)) as any[]) { + if (row.imageUrl) urls.add(row.imageUrl); + } + + for (const row of (await db.select().from(contentSections)) as any[]) { + collectFromJson(row.contentJson, urls); + } + + return urls; +} + +/** Alle Bildpfade aus einem beliebigen Content-JSON. */ +export function extractImageUrls(value: any): Set { + const out = new Set(); + collectFromJson(value, out); + return out; +} + +function collectFromJson(value: any, out: Set): void { + if (typeof value === 'string') { + if (value.startsWith('/images/')) { + out.add(value); + return; + } + // Je nach Treiber kommt das JSON als String zurueck + if (value.startsWith('{') || value.startsWith('[')) { + try { + collectFromJson(JSON.parse(value), out); + } catch { + // kein JSON - ignorieren + } + } + return; + } + + if (Array.isArray(value)) { + for (const entry of value) collectFromJson(entry, out); + return; + } + + if (value && typeof value === 'object') { + for (const entry of Object.values(value)) collectFromJson(entry, out); + } +} diff --git a/backend/src/services/upload.service.ts b/backend/src/services/upload.service.ts new file mode 100644 index 0000000..5e404e3 --- /dev/null +++ b/backend/src/services/upload.service.ts @@ -0,0 +1,67 @@ +import fs from 'fs'; +import path from 'path'; +import { env } from '../config/env.js'; + +export type UploadSubdir = 'events' | 'gallery' | 'content'; + +export interface SavedImage { + filename: string; + imageUrl: string; +} + +/** + * Nimmt einen Multipart-Upload entgegen, rechnet ihn nach WebP herunter und + * legt ihn unter public/images/ im Git-Workspace ab. + * + * Der Dateiname folgt dem Muster -.. Daran erkennt + * der AssetService spaeter, dass er die Datei wieder loeschen darf. + */ +export async function saveUploadedImage( + file: any, + subdir: UploadSubdir, + log?: { warn: (obj: any, msg: string) => void } +): Promise { + const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir); + fs.mkdirSync(uploadDir, { recursive: true }); + + const chunks: Buffer[] = []; + for await (const chunk of file.file) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const inputBuffer = Buffer.concat(chunks); + + // Ohne diese Pruefung landet bei zu grossen Dateien ein abgeschnittenes, + // kaputtes Bild auf der Platte + if (file.file?.truncated) { + const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024); + const error: any = new Error(`Image too large. Maximum is ${limit} MB`); + error.statusCode = 413; + throw error; + } + + const stamp = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 8); + + let outBuffer: Buffer; + let outExt = '.webp'; + + try { + // Sharp erst laden wenn wirklich gebraucht + const sharp = (await import('sharp')).default; + outBuffer = await sharp(inputBuffer) + .rotate() + .resize({ width: 1600, withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer(); + } catch (err) { + log?.warn({ err }, 'Sharp processing failed, using original image'); + outBuffer = inputBuffer; + const extFromMime = (file.mimetype || '').split('/')[1] || 'bin'; + outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase(); + } + + const filename = `${stamp}-${rand}${outExt}`; + fs.writeFileSync(path.join(uploadDir, filename), outBuffer); + + return { filename, imageUrl: `/images/${subdir}/${filename}` }; +} diff --git a/src/pages/admin.astro b/src/pages/admin.astro index c0dada0..d06c233 100644 --- a/src/pages/admin.astro +++ b/src/pages/admin.astro @@ -22,7 +22,21 @@ const title = 'Admin'; .card { border: 1px solid #eee; padding: 0.75rem; border-radius: 6px; } label { display:block; margin-top: 0.5rem; } input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; } - img.thumb { max-width: 100%; height: auto; display: block; } + /* Feste Box: Uploads sind bis 1600px breit und wuerden die Liste sonst + je nach Seitenverhaeltnis beliebig hoch ziehen. */ + img.thumb { width: 100%; height: 160px; object-fit: cover; display: block; + border-radius: 4px; background: #f2f2f2; margin-top: 0.5rem; } + img.thumb-sm { width: 110px; height: 110px; object-fit: cover; display: block; + border-radius: 4px; background: #f2f2f2; border: 1px solid #e5e5e5; + margin-top: 0.25rem; } + .field-row { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; } + .field-row > label { flex: 1 1 260px; } + .highlight-row { display: grid; grid-template-columns: 1fr 2fr auto; gap: .5rem; + align-items: end; margin-top: .5rem; } + @media (max-width: 700px){ .highlight-row { grid-template-columns: 1fr; } } + .stack > .card { margin-bottom: 1rem; } + .ok { color: #17692b; } + .err { color: #a11; } .toolbar { display:flex; gap:.5rem; align-items:center; margin:.5rem 0; } .pill { font-size:.85rem; padding:.25rem .5rem; border:1px solid #ddd; border-radius:999px; background:#f7f7f7; } .dragging { opacity:.5; } @@ -102,6 +116,72 @@ const title = 'Admin';
    + +