Author SHA1 Message Date
KenzoandClaude Opus 5 1a7a4f8a02 perf(ci): Builds beschleunigen und kaputtes sharp reparieren
ci/woodpecker/push/woodpecker Pipeline was successful
Nur bauen was sich geaendert hat:
- deploy_backend laeuft nur bei Aenderungen unter backend/, deploy_frontend
  nur bei src/, public/ und den Frontend-Konfigdateien. Die haeufigsten
  Commits sind Inhaltsaenderungen aus dem CMS und fassen nur src/ und
  public/ an - die brauchen kein Backend-Deployment mehr.
- Der Abhaengigkeits-Audit laeuft nur noch, wenn sich package.json oder
  package-lock.json aendern. Sonst kam bei jedem Inhalts-Commit derselbe
  Bericht nach Discord.
- "[ALL]" in der Commit-Message erzwingt weiterhin den vollen Durchlauf.

Build-Kontext:
- Es gab kein .dockerignore im Root. Bei jedem Frontend-Deploy wanderte
  das komplette Repo zum Fly-Remote-Builder, inklusive 50 MB Git-Historie
  und des gesamten Backends. 98 MB -> 47 MB.
- Zwei tote Zeilen im Frontend-Dockerfile entfernt: ein Root-styles/ gibt
  es nicht und /styles/ wird als URL nirgends verwendet.

Backend-Image:
- npm ci lief in beiden Stages, better-sqlite3 wurde also doppelt
  uebersetzt. Jetzt einmal im Builder, danach npm prune --omit=dev und die
  fertigen node_modules wandern weiter.
- Die Build-Werkzeuge sind raus aus der Laufzeit-Stage. Das apk del vorher
  hat sie nur unsichtbar gemacht, die Layer blieben im Image.
- Ohne Cache lokal: 1:31 -> 0:29. Image: 845 MB -> 312 MB.

sharp war die ganze Zeit kaputt:
- backend/package-lock.json enthielt als einziges Plattform-Binary
  @img/sharp-win32-x64, das Lockfile stammt von einer Windows-Maschine.
  npm ci installiert strikt nach Lockfile, auf Alpine kam damit gar kein
  sharp-Binary an. Der Upload fiel jedes Mal auf den Fallback zurueck:
  keine Verkleinerung auf 1600px, keine WebP-Wandlung, das Originalbild
  landete unveraendert im Repo. Genau deshalb sind die Bilder so gross -
  in der Historie liegen entsprechend .jpeg statt .webp.
- Lockfile mit allen Plattformvarianten neu aufgeloest. Keine einzige
  bestehende Paketversion aendert sich dabei; dazugekommen sind die
  Binaries, weggefallen sind 12 Postgres-Pakete, die drizzle-kit optional
  mitzieht und die hier niemand benutzt.
- vips aus dem Image entfernt, sharp bringt seit 0.33 sein eigenes libvips
  mit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:13:52 +02:00
KenzoandClaude Opus 5 60246f3941 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]>
2026-08-11 11:42:59 +02:00
Gallus-maintanance 2af70c238b Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 09:36:48 +00:00
Gallus-maintanance 30f4b9d8c5 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 08:47:41 +00:00
KenzoandClaude Opus 5 390b016cc2 fix(backend): Bilder unter /images/ ausliefern
ci/woodpecker/push/woodpecker Pipeline was successful
Die Bild-URLs in der DB sind /images/events/... bzw. /images/gallery/... -
das ist der Pfad auf der publizierten Astro-Seite. Das Backend lieferte die
Dateien aber nur unter /static/ mit Root auf dem Workspace aus, die Datei lag
also unter /static/public/images/... Ein Aufruf von /images/... traf auf gar
keine Route, kam als JSON-404 zurueck und wurde vom Browser als
OpaqueResponseBlocking verworfen. Dadurch waren im Adminbereich saemtliche
Event- und Gallery-Bilder kaputt.

Zusaetzlicher statischer Mount /images/ -> <workspace>/public/images. Die DB
bleibt unveraendert und die URLs stimmen weiterhin fuer die publizierte Seite.
/static/ bleibt zusaetzlich bestehen.

Beide Verzeichnisse werden vorab angelegt - @fastify/static verweigert sonst
die Registrierung, wenn der Workspace noch nicht geklont ist.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 10:22:40 +02:00
KenzoandClaude Opus 5 e9d7ce262d ci: Backend per Woodpecker deployen
Die Pipeline hatte nur einen Deploy-Step fuer das Frontend (App gallus-pub).
Die Backend-App gallus-cms-backend wurde von der CI nie ausgerollt, sondern
haing am letzten manuellen fly deploy. Ein Push auf main hat damit zwar die
Admin-Seite aktualisiert, aber nie die API dahinter.

Step laeuft vor deploy_frontend und nutzt denselben FLY_API_TOKEN.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 10:22:40 +02:00
Kenzo fdd3793ee1 Merge pull request 'fix(backend): Publish repariert - spawn git ENOENT und kaputter Image-Build' (#5) from fix/publish-git-enoent into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #5
2026-08-11 09:51:59 +02:00
19 changed files with 2545 additions and 321 deletions
+29
View File
@@ -0,0 +1,29 @@
# Ohne diese Datei wandert das komplette Repo zum Fly-Remote-Builder,
# inklusive der ~50 MB Git-Historie und des gesamten Backends. Der
# Frontend-Build braucht davon nichts.
.git
.gitignore
node_modules
dist
.astro
# Das Backend wird als eigene Fly-App aus backend/ heraus gebaut
backend
# Nicht vom Frontend-Build verwendet
.woodpecker.yml
Dockerfile.caddy
docker-compose.yml
pnpm-lock.yaml
pnpm-workspace.yaml
MIGRATION_README.md
.vscode
.idea
.DS_Store
.env
.env.*
*.log
+53
View File
@@ -5,9 +5,17 @@ steps:
- npm install --package-lock-only
- npm audit --audit-level=moderate --json > audit-result.json 2>&1 || echo "Audit completed"
- npm audit --audit-level=moderate > audit-output.txt 2>&1 || echo "Audit completed"
# Nur wenn sich Abhaengigkeiten geaendert haben - bei reinen
# Inhaltsaenderungen kaeme sonst jedes Mal derselbe Bericht
when:
- branch: main
event: push
path:
include:
- 'package.json'
- 'package-lock.json'
- '.woodpecker.yml'
ignore_message: '[ALL]'
discord_notify_audit:
image: alpine:latest
@@ -76,9 +84,41 @@ steps:
echo "No audit results found - listing workspace files:"
ls -la
fi
# Gleicher Filter wie der Audit-Schritt, sonst meldet Discord bei jedem
# Inhalts-Commit "keine Ergebnisse gefunden"
when:
- branch: main
event: push
path:
include:
- 'package.json'
- 'package-lock.json'
- '.woodpecker.yml'
ignore_message: '[ALL]'
# Backend zuerst - die Admin-Seite braucht die API.
# Laeuft nur, wenn sich am Backend etwas geaendert hat. Die haeufigsten
# Commits sind Inhaltsaenderungen aus dem CMS und fassen nur src/ und
# public/ an - die brauchen kein Backend-Deployment.
deploy_backend:
image: node:20
environment:
FLY_API_TOKEN:
from_secret: FLY_API_TOKEN
commands:
- curl -L https://fly.io/install.sh | sh
- export PATH="$HOME/.fly/bin:$PATH"
# aus backend/ heraus, damit Build-Kontext und Dockerfile stimmen
- cd backend && flyctl deploy --app gallus-cms-backend --remote-only
when:
- branch: main
event: push
path:
include:
- 'backend/**'
- '.woodpecker.yml'
# "[ALL]" in der Commit-Message erzwingt den vollen Durchlauf
ignore_message: '[ALL]'
deploy_frontend:
image: node:20
@@ -92,6 +132,19 @@ steps:
when:
- branch: main
event: push
path:
include:
- 'src/**'
- 'public/**'
- 'package.json'
- 'package-lock.json'
- 'astro.config.mjs'
- 'tsconfig.json'
- 'Dockerfile'
- '.dockerignore'
- 'fly.toml'
- '.woodpecker.yml'
ignore_message: '[ALL]'
notify_success:
image: alpine:latest
-3
View File
@@ -5,9 +5,6 @@ COPY package*.json ./
# Fallback to npm install if no lockfile is present
RUN npm ci || npm install
COPY . .
# Ensure CSS variables are present
RUN mkdir -p public/styles
RUN cp -r styles/* public/styles/ || true
RUN npm run build
FROM node:20-alpine AS production
+18 -29
View File
@@ -1,57 +1,46 @@
# Multi-stage build for Gallus CMS Backend
# Stage 1: Builder
# Hier werden die nativen Module EINMAL uebersetzt. Vorher lief npm ci in
# beiden Stages, better-sqlite3 wurde also doppelt kompiliert.
FROM node:20-alpine AS builder
WORKDIR /app
# Install build dependencies for native modules (better-sqlite3, sharp)
RUN apk add --no-cache python3 make g++ vips-dev
# Nur fuer better-sqlite3 - fuer musl gibt es davon keine Fertigbauten.
# sharp ab 0.33 bringt eigene Binaries samt libvips mit, vips-dev wird
# dadurch nicht mehr gebraucht.
RUN apk add --no-cache python3 make g++
# Install dependencies
# Eigener Layer: bleibt im Cache, solange sich das Lockfile nicht aendert
COPY package*.json ./
# Use npm ci when lockfile exists, fallback to npm install for local/dev
RUN npm ci || npm install
RUN npm ci
# Copy source
COPY . .
# Build TypeScript
RUN npm run build
# Dev-Abhaengigkeiten entfernen. Was uebrig bleibt, ist fertig uebersetzt
# und wandert unveraendert ins Laufzeit-Image.
RUN npm prune --omit=dev
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool, vips for sharp)
# Note: python3, make, g++ are needed for native module compilation
RUN apk add --no-cache git sqlite vips vips-dev python3 make g++
# git fuer simple-git, sqlite fuer die CLI. Keine Build-Werkzeuge mehr -
# die blieben vorher als Layer im Image liegen, auch nach dem apk del.
RUN apk add --no-cache git sqlite
# Copy package files first
COPY --from=builder /app/package*.json ./
# Install all production dependencies and rebuild sharp for linuxmusl-x64
RUN npm ci --omit=dev || npm install --production && \
npm rebuild sharp
# Clean up build dependencies after installation to reduce image size
RUN apk del python3 make g++ vips-dev
# Copy built files from builder
# Hinweis: Drizzle-Migrationen werden nicht kopiert - das Schema wird zur
# Laufzeit von initDatabase() angelegt, src/db/migrations existiert nicht.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Copy migration script and migrated images
COPY --from=builder /app/migrate-production.js ./migrate-production.js
COPY --from=builder /app/data/images ./data/images
# Create directories
RUN mkdir -p /app/workspace /app/data
# Ensure proper permissions
RUN chown -R node:node /app
# Create directories and ensure proper permissions
RUN mkdir -p /app/workspace /app/data && chown -R node:node /app
# Switch to non-root user
USER node
+1530 -151
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -8,6 +8,7 @@ import { env, validateEnv } from './config/env.js';
import { db, initDatabase } from './config/database.js';
import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'fs';
// Import routes
import authRoute from './routes/auth.js';
@@ -17,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 {
@@ -79,12 +81,34 @@ fastify.register(multipart, {
// Serve static files (uploaded images, etc.) from persistent volume
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
// Muss existieren, sonst verweigert @fastify/static die Registrierung
fs.mkdirSync(dataDir, { recursive: true });
fastify.register(fastifyStatic, {
root: dataDir,
prefix: '/static/',
decorateReply: false
});
// Uploads liegen unter <workspace>/public/images. In der DB stehen sie als
// /images/... - das ist die URL auf der publizierten Astro-Seite. Damit die
// Admin-Oberflaeche dieselben Pfade verwenden kann, hier ebenso ausliefern.
const imagesDir = path.join(dataDir, 'public', 'images');
fs.mkdirSync(imagesDir, { recursive: true });
fastify.register(fastifyStatic, {
root: imagesDir,
prefix: '/images/',
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);
@@ -96,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 () => {
+44
View File
@@ -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],
+32 -46
View File
@@ -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' };
});
+30 -47
View File
@@ -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' };
});
+132
View File
@@ -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<string, { section: string; field: string }> = {
drinks: { section: 'drinks', field: 'pdfUrl' },
};
/** contentJson kommt je nach Treiber als Objekt oder als String zurueck. */
function asObject(value: any): Record<string, any> {
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;
+21 -1
View File
@@ -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
// 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',
};
+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;
}
}
+38 -17
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 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<div class="hero-content">
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
\t\t\t<h1>${this.text(content.heading, 'Dein Irish Pub')}</h1>
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
\t\t\t<p>${this.text(content.subheading, 'Im Herzen von St.Gallen')}</p>
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
\t\t</div>
@@ -105,7 +126,7 @@ const { id } = Astro.props;
generateWelcomeComponent(content: ContentSection): string {
const highlightsList = (content.highlights || []).map((h: any) =>
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
`\t\t\t<li>\n\t\t\t\t<b>${this.escapeHtml(h?.title)}:</b> ${this.escapeHtml(h?.description)}\n\t\t\t</li>`
).join('\n\n');
return `---
@@ -119,11 +140,11 @@ const { id } = Astro.props;
\t<div class="welcome-text">
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
\t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
\t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
\t\t<p>
\t\t\t${content.introText || ''}
\t\t\t${this.text(content.introText)}
\t\t</p>
\t\t<p><b>Unsere Highlights:</b></p>
@@ -133,14 +154,14 @@ ${highlightsList}
\t\t</ul>
\t\t<p>
\t\t\t${content.closingText || ''}
\t\t\t${this.text(content.closingText)}
\t\t</p>
\t</div>
\t<div class="welcome-image">
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
\t\t<img src="${this.text(content.imageUrl, '/images/Welcome.png')}" alt="Welcome background image" />
\t</div>
</section>
@@ -157,36 +178,36 @@ const { id } = Astro.props;
<h2 class="title">Drinks</h2>
<p class="note">
${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.')}
</p>
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<a href="${this.text(content.pdfUrl, '/pdf/Getraenke_Gallus_2025.pdf')}" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka">
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
<div class="circle" title="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
<img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
<div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
</div>
<p class="note">
${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.')}
</p>
<div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
<img src="${this.text(content.whiskeyImage1, '/images/Whiskey1.png')}" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
<img src="${this.text(content.whiskeyImage2, '/images/Whiskey2.png')}" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
<img src="${this.text(content.whiskeyImage3, '/images/Whiskey3.png')}" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
+22 -11
View File
@@ -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]);
}
}
@@ -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<boolean> {
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<Set<string>> {
const urls = new Set<string>();
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<string> {
const out = new Set<string>();
collectFromJson(value, out);
return out;
}
function collectFromJson(value: any, out: Set<string>): 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);
}
}
+67
View File
@@ -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/<subdir> im Git-Workspace ab.
*
* Der Dateiname folgt dem Muster <zeitstempel>-<zufall>.<ext>. 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<SavedImage> {
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}` };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+300 -11
View File
@@ -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';
</div>
</section>
<section id="sec-content" style="display:none">
<h2>Texte bearbeiten</h2>
<div class="muted">Änderungen werden erst mit „Publish“ auf die Website übernommen.</div>
<div class="stack">
<div class="card">
<h3>Startseite oben (Hero)</h3>
<div class="field-row">
<label>Überschrift<input id="hero-heading" placeholder="Dein Irish Pub" /></label>
<label>Unterzeile<input id="hero-subheading" placeholder="Im Herzen von St.Gallen" /></label>
</div>
<button id="btn-save-hero">Hero speichern</button>
<span id="hero-msg" class="muted"></span>
</div>
<div class="card">
<h3>Willkommen</h3>
<div class="field-row">
<label>Überschrift Zeile 1<input id="wel-heading1" placeholder="Herzlich willkommen im" /></label>
<label>Überschrift Zeile 2<input id="wel-heading2" placeholder="Gallus Pub!" /></label>
</div>
<label>Einleitungstext<textarea id="wel-intro" rows="4"></textarea></label>
<label style="margin-top:1rem"><strong>Highlights</strong></label>
<div id="wel-highlights"></div>
<button id="btn-add-highlight" type="button">Highlight hinzufügen</button>
<label>Schlusstext<textarea id="wel-closing" rows="3"></textarea></label>
<label>Bild ersetzen<input id="wel-file" type="file" accept="image/*" /></label>
<img id="wel-preview" class="thumb-sm" alt="Aktuelles Willkommen-Bild" style="display:none" />
<button id="btn-save-welcome">Willkommen speichern</button>
<span id="wel-msg" class="muted"></span>
</div>
<div class="card">
<h3>Drinks</h3>
<label>Einleitungstext<textarea id="dr-intro" rows="3"></textarea></label>
<div class="field-row">
<label>Monats-Hit Name<input id="dr-special-name" placeholder="Mate Vodka" /></label>
<label>Monats-Hit Bild ersetzen<input id="dr-special-file" type="file" accept="image/*" /></label>
</div>
<img id="dr-special-preview" class="thumb-sm" alt="Aktuelles Monats-Hit-Bild" style="display:none" />
<label>Whiskey-Text<textarea id="dr-whiskey" rows="3"></textarea></label>
<div class="field-row">
<label>Whiskey-Bild 1<input id="dr-whiskey-file1" type="file" accept="image/*" />
<img id="dr-whiskey-preview1" class="thumb-sm" alt="Whiskey 1" style="display:none" /></label>
<label>Whiskey-Bild 2<input id="dr-whiskey-file2" type="file" accept="image/*" />
<img id="dr-whiskey-preview2" class="thumb-sm" alt="Whiskey 2" style="display:none" /></label>
<label>Whiskey-Bild 3<input id="dr-whiskey-file3" type="file" accept="image/*" />
<img id="dr-whiskey-preview3" class="thumb-sm" alt="Whiskey 3" style="display:none" /></label>
</div>
<label style="margin-top:1rem">Getränkekarte (PDF) ersetzen<input id="dr-pdf-file" type="file" accept="application/pdf" /></label>
<div id="dr-pdf-current" class="muted"></div>
<button id="btn-save-drinks">Drinks speichern</button>
<span id="dr-msg" class="muted"></span>
</div>
</div>
</section>
<section id="sec-publish" style="display:none">
<h2>Veröffentlichen</h2>
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
@@ -120,6 +200,12 @@ const title = 'Admin';
return ct.includes('application/json') ? res.json() : res.text();
};
// Inhalte landen per innerHTML in der Seite - ohne Maskierung zerlegt
// ein Anführungszeichen im Titel die Darstellung
const esc = (v) => String(v ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
async function refreshAuth() {
try {
const me = await api('/api/auth/me');
@@ -128,11 +214,13 @@ const title = 'Admin';
document.getElementById('sec-events').style.display = '';
document.getElementById('sec-gallery').style.display = '';
document.getElementById('sec-banner').style.display = '';
document.getElementById('sec-content').style.display = '';
document.getElementById('sec-publish').style.display = '';
// Direkt Events laden und auf Sektion fokussieren
await loadEvents();
await loadGallery();
await loadBanners();
await loadContent();
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
const el = document.getElementById('auth-status');
@@ -141,6 +229,7 @@ const title = 'Admin';
document.getElementById('sec-events').style.display = 'none';
document.getElementById('sec-gallery').style.display = 'none';
document.getElementById('sec-banner').style.display = 'none';
document.getElementById('sec-content').style.display = 'none';
document.getElementById('sec-publish').style.display = 'none';
}
}
@@ -218,12 +307,12 @@ const title = 'Admin';
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
card.innerHTML = `
<div class="row" style="justify-content:space-between;align-items:center">
<div><strong>${ev.title}</strong></div>
<div><strong>${esc(ev.title)}</strong></div>
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
</div>
<div class="muted">${ev.date}</div>
<div>${ev.description || ''}</div>
${ev.imageUrl ? `<img src="${API_BASE}${ev.imageUrl}" alt="${ev.title}" class="thumb" style="margin-top:0.5rem;" />` : '<div class="muted">Kein Bild</div>'}
<div class="muted">${esc(ev.date)}</div>
<div>${esc(ev.description || '')}</div>
${ev.imageUrl ? `<img src="${API_BASE}${esc(ev.imageUrl)}" alt="${esc(ev.title)}" class="thumb" />` : '<div class="muted">Kein Bild</div>'}
<div class="row-buttons">
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
</div>`;
@@ -306,7 +395,10 @@ const title = 'Admin';
s.textContent = 'Veröffentliche...';
try {
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
s.textContent = res?.message || 'Veröffentlicht';
const cleaned = res?.removedImages
? ` (${res.removedImages} ungenutzte${res.removedImages === 1 ? 's Bild' : ' Bilder'} entfernt)`
: '';
s.textContent = (res?.message || 'Veröffentlicht') + cleaned;
} catch(e){ s.textContent = 'Fehler: '+e.message }
});
@@ -348,8 +440,8 @@ const title = 'Admin';
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" class="thumb" />
<div class="muted">${img.altText || ''}</div>
<img src="${API_BASE}${esc(img.imageUrl)}" alt="${esc(img.altText)}" class="thumb" />
<div class="muted">${esc(img.altText || '')}</div>
<div class="row-buttons">
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
</div>`;
@@ -402,9 +494,9 @@ const title = 'Admin';
card.className = 'card';
const statusText = banner.isActive ? '✓ Aktiv' : '✗ Inaktiv';
card.innerHTML = `
<div><strong>${banner.text.substring(0, 60)}${banner.text.length > 60 ? '...' : ''}</strong></div>
<div class="muted">Von: ${banner.startDate}</div>
<div class="muted">Bis: ${banner.endDate}</div>
<div><strong>${esc(banner.text.substring(0, 60))}${banner.text.length > 60 ? '...' : ''}</strong></div>
<div class="muted">Von: ${esc(banner.startDate)}</div>
<div class="muted">Bis: ${esc(banner.endDate)}</div>
<div class="pill">${statusText}</div>
<div class="row-buttons">
<button data-id="${banner.id}" class="btn-toggle-banner">${banner.isActive ? 'Deaktivieren' : 'Aktivieren'}</button>
@@ -475,6 +567,203 @@ const title = 'Admin';
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
// ========== Texte (Content-Sections) ==========
// Der geladene Stand wird gemerkt, damit beim Speichern keine Felder
// verloren gehen, die die Oberfläche gar nicht anzeigt (z.B. pdfUrl).
let contentState = { hero: {}, welcome: {}, drinks: {} };
const byId = (id) => document.getElementById(id);
function setPreview(imgId, url) {
const img = byId(imgId);
if (url) {
img.src = API_BASE + url;
img.style.display = '';
} else {
img.removeAttribute('src');
img.style.display = 'none';
}
}
function renderHighlights(highlights) {
const box = byId('wel-highlights');
box.innerHTML = '';
(highlights || []).forEach((h, idx) => {
const row = document.createElement('div');
row.className = 'highlight-row';
row.innerHTML = `
<label>Titel<input class="hl-title" value="${esc(h?.title)}" /></label>
<label>Beschreibung<input class="hl-desc" value="${esc(h?.description)}" /></label>
<button type="button" class="hl-remove" data-idx="${idx}">Entfernen</button>`;
box.appendChild(row);
});
box.querySelectorAll('.hl-remove').forEach(btn => {
btn.addEventListener('click', () => {
btn.closest('.highlight-row').remove();
});
});
}
function readHighlights() {
return Array.from(byId('wel-highlights').querySelectorAll('.highlight-row'))
.map(row => ({
title: row.querySelector('.hl-title').value.trim(),
description: row.querySelector('.hl-desc').value.trim(),
}))
.filter(h => h.title || h.description);
}
async function loadContent() {
try {
const data = await api('/api/content');
const map = {};
(data.sections || []).forEach(s => {
let c = s.content;
if (typeof c === 'string') { try { c = JSON.parse(c); } catch { c = {}; } }
map[s.section] = c || {};
});
contentState = {
hero: map.hero || {},
welcome: map.welcome || {},
drinks: map.drinks || {},
};
byId('hero-heading').value = contentState.hero.heading || '';
byId('hero-subheading').value = contentState.hero.subheading || '';
byId('wel-heading1').value = contentState.welcome.heading1 || '';
byId('wel-heading2').value = contentState.welcome.heading2 || '';
byId('wel-intro').value = contentState.welcome.introText || '';
byId('wel-closing').value = contentState.welcome.closingText || '';
renderHighlights(contentState.welcome.highlights);
setPreview('wel-preview', contentState.welcome.imageUrl);
byId('dr-intro').value = contentState.drinks.introText || '';
byId('dr-special-name').value = contentState.drinks.monthlySpecialName || '';
byId('dr-whiskey').value = contentState.drinks.whiskeyText || '';
setPreview('dr-special-preview', contentState.drinks.monthlySpecialImage);
setPreview('dr-whiskey-preview1', contentState.drinks.whiskeyImage1);
setPreview('dr-whiskey-preview2', contentState.drinks.whiskeyImage2);
setPreview('dr-whiskey-preview3', contentState.drinks.whiskeyImage3);
const pdf = contentState.drinks.pdfUrl;
byId('dr-pdf-current').innerHTML = pdf
? `Aktuell: <a href="${API_BASE}${esc(pdf)}" target="_blank" rel="noopener noreferrer">${esc(pdf.split('/').pop())}</a>`
: 'Noch keine eigene Getränkekarte hochgeladen (es gilt die im Repo hinterlegte).';
} catch (e) {
console.error(e);
}
}
async function uploadContentImage(file) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(API_BASE + '/api/content/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
/** Lädt die Datei aus einem Input hoch, sofern eine gewählt wurde. */
async function maybeUpload(inputId, current) {
const file = byId(inputId).files[0];
if (!file) return current;
const up = await uploadContentImage(file);
byId(inputId).value = '';
return up?.imageUrl || current;
}
async function saveSection(name, content, msgId) {
const msg = byId(msgId);
msg.textContent = 'Speichere...';
msg.className = 'muted';
try {
await api(`/api/content/${name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentJson: content }),
});
msg.textContent = 'Gespeichert. Mit „Publish“ übernehmen.';
msg.className = 'ok';
await loadContent();
} catch (e) {
msg.textContent = 'Fehler: ' + e.message;
msg.className = 'err';
}
}
byId('btn-add-highlight').addEventListener('click', () => {
const current = readHighlights();
current.push({ title: '', description: '' });
renderHighlights(current);
});
byId('btn-save-hero').addEventListener('click', () => {
saveSection('hero', {
...contentState.hero,
heading: byId('hero-heading').value.trim(),
subheading: byId('hero-subheading').value.trim(),
}, 'hero-msg');
});
byId('btn-save-welcome').addEventListener('click', async () => {
const msg = byId('wel-msg');
msg.textContent = 'Lade Bild hoch...';
msg.className = 'muted';
try {
const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl);
await saveSection('welcome', {
...contentState.welcome,
heading1: byId('wel-heading1').value.trim(),
heading2: byId('wel-heading2').value.trim(),
introText: byId('wel-intro').value.trim(),
closingText: byId('wel-closing').value.trim(),
highlights: readHighlights(),
...(imageUrl ? { imageUrl } : {}),
}, 'wel-msg');
} catch (e) {
msg.textContent = 'Fehler: ' + e.message;
msg.className = 'err';
}
});
byId('btn-save-drinks').addEventListener('click', async () => {
const msg = byId('dr-msg');
msg.textContent = 'Lade Dateien hoch...';
msg.className = 'muted';
try {
const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage);
const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1);
const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2);
const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3);
// PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt
const pdfFile = byId('dr-pdf-file').files[0];
if (pdfFile) {
const fd = new FormData();
fd.append('file', pdfFile);
const res = await fetch(API_BASE + '/api/pdf/drinks', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
const up = await res.json();
contentState.drinks.pdfUrl = up?.pdfUrl || contentState.drinks.pdfUrl;
byId('dr-pdf-file').value = '';
}
await saveSection('drinks', {
...contentState.drinks,
introText: byId('dr-intro').value.trim(),
monthlySpecialName: byId('dr-special-name').value.trim(),
whiskeyText: byId('dr-whiskey').value.trim(),
...(special ? { monthlySpecialImage: special } : {}),
...(w1 ? { whiskeyImage1: w1 } : {}),
...(w2 ? { whiskeyImage2: w2 } : {}),
...(w3 ? { whiskeyImage3: w3 } : {}),
}, 'dr-msg');
} catch (e) {
msg.textContent = 'Fehler: ' + e.message;
msg.className = 'err';
}
});
refreshAuth();
</script>
</body>
+8
View File
@@ -27,6 +27,14 @@ const events = [
Plätze sind begrenzt! Jetzt reservieren unter 🍀WA 077 232 27 70
`,
},
{
image: "/images/events/msogsj4l-bmlcxc.jpeg",
title: "SG Fest",
date: "2026-08-14",
description: `
Das Stadt-Fest steht vor der Tür! Wir haben OFFEN ab 17:00Uhr mit Karaoke! Wir freuen uns auf euch!
`,
},
{
image: "/images/events/mr82c7eh-yp87hy.jpeg",
title: "Richie Live",