Author SHA1 Message Date
Gallus-maintanance c0d6a1c5de Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-15 09:51:18 +00:00
Gallus-maintanance d678d80116 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 11:54:15 +00:00
Gallus-maintanance bce973679b Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 11:39:35 +00:00
Gallus-maintanance 45f705a104 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 11:20:26 +00:00
Gallus-maintanance c4f17d5f78 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 10:51:05 +00:00
Gallus-maintanance 247f6ea272 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-28 19:34:40 +00:00
Gallus-maintanance ebcfec4ec7 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-16 20:13:03 +00:00
Kenzo e22ccfeda3 Update package-lock.json with latest dependency versions.
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 19:46:21 +02:00
Kenzo 3729639596 Update package-lock.json with latest dependency versions.
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-13 19:39:16 +02:00
Kenzo 4887f599fd Update package-lock.json with latest dependency versions. 2026-08-13 19:27:16 +02:00
Gallus-maintanance 69db8de6d7 Update events 2026-08-13 13:23:33 +00:00
Gallus-maintanance 7bb5f339c0 Update events 2026-08-13 13:15:54 +00:00
Gallus-maintanance 5982a1bfd1 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 13:15:58 +00:00
KenzoandClaude Opus 5 f46a24ee4e fix(upload): Groessengrenzen realistisch setzen, PDFs eigener Wert
ci/woodpecker/push/woodpecker Pipeline was successful
Der Upload lief gegen einen einzigen globalen Wert von 5 MB, der fuer alle
Dateitypen galt. Eine Getraenkekarte liegt aber schnell bei 15-20 MB, das
Hochladen scheiterte deshalb zuverlaessig.

- MAX_FILE_SIZE (Bilder) von 5 auf 20 MB. Bilder werden ohnehin auf 1600px
  heruntergerechnet, die Grenze muss nur ein unbearbeitetes Handyfoto
  durchlassen - 5 MB reichten dafuer schon nicht.
- MAX_PDF_SIZE neu, 40 MB. PDFs werden unveraendert abgelegt.
- Beides ueber Umgebungsvariablen uebersteuerbar.

Die Registrierung von multipart nimmt den groesseren der beiden Werte als
Obergrenze, die Routen setzen ihn per request.file({ limits }) auf ihren
eigenen herunter. Die Fehlermeldung im PDF-Zweig nannte bisher die
Bildgrenze.

Geprueft am laufenden Container, auch mit --memory=512m wie auf Fly: 18-MB-
PDF und 18-MB-Foto gehen durch, 45 MB bzw. 25 MB werden mit 413 und
passender Meldung abgelehnt, kein OOM.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 14:42:56 +02:00
Gallus-maintanance 76c4d5c9bf Update Menu
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 12:13:25 +00:00
KenzoandClaude Opus 5 a6fc557fad fix(admin): Styles greifen wieder fuer die dynamisch erzeugten Listen
ci/woodpecker/push/woodpecker Pipeline was successful
Astro scoped <style>-Bloecke und schraenkt jede Regel auf
[data-astro-cid-...] ein. Dieses Attribut setzt es nur auf Elemente, die zur
Bauzeit im Template stehen. Die Karten der Event-, Gallery- und
Banner-Listen entstehen aber erst zur Laufzeit per innerHTML und tragen es
nicht - fuer sie hat kein einziger Selektor gegriffen.

Betroffen waren .card, .thumb, .muted, .row-buttons, .pill, .drag-handle und
.highlight-row. Sichtbar wurde es an den Vorschaubildern: sie liefen in
voller Aufloesung ueber die Spalten hinaus. Auch die urspruengliche Regel
max-width:100% hatte deshalb nie gewirkt, nicht erst die neue feste Box.

Behoben mit is:global. Die Seite ist ein eigenstaendiges Dokument mit
eigenem <html>, es kann also nichts anderes davon betroffen sein.

Der erklaerende Kommentar steht als {/* */} im Template und landet damit
nicht in der ausgelieferten Seite.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:55:15 +02:00
KenzoandClaude Opus 5 a2bdbf9035 feat(backend): Skript zur Umwandlung des Altbestands nach AVIF
Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert im
Originalformat im Repo - damals war sharp im Container kaputt, es gab also
weder Verkleinerung noch Formatwandlung. Das Skript schickt sie durch
dieselbe Verarbeitung wie einen frischen Upload.

  node dist/scripts/convert-images-to-avif.js           # nur anzeigen
  node dist/scripts/convert-images-to-avif.js --apply   # wirklich tun

Ohne --apply wird nichts geschrieben.

Ablauf: Workspace auf den Repo-Stand bringen, jedes referenzierte Bild
umwandeln und nach seinem Inhalt benennen (Event-Titel, Alt-Text, Zweck),
die Verweise in Events, Gallery und Textbereichen nachziehen, die
Vorgaenger wegraeumen und committen.

Weggeraeumt wird nur, was das CMS selbst angelegt hat. Ein handgepflegtes
event_karaoke.jpg bekommt zwar eine AVIF-Fassung und der Verweis zeigt
darauf, das Original bleibt aber liegen und wird im Bericht genannt.

saveImageBuffer aus upload.service.ts herausgeloest, damit Upload und
Skript nachweislich dieselbe Verarbeitung benutzen statt zweier Kopien.
replaceImageUrls ersetzt Pfade in den Textbereichen, ohne die restliche
JSON-Struktur anzufassen.

Durchgespielt an vier Bildern zu je ~656 KB: 2.56 MB -> 0.16 MB. Die
Vorschau schreibt nichts, ein zweiter Lauf findet nichts mehr, die
Highlights-Liste bleibt unversehrt und alle Bilder werden danach als
image/avif ausgeliefert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:46:27 +02:00
KenzoandClaude Opus 5 f9193eebfd feat(cms): AVIF und sprechende Dateinamen fuer Uploads
Uploads werden nach AVIF gewandelt statt nach WebP und heissen jetzt nach
dem Inhalt statt nach Zeitstempel und Zufall:
- Events uebernehmen den Event-Titel, die Gallery den Alt-Text, Bilder in
  den Textbereichen ihren Zweck, PDFs den urspruenglichen Dateinamen.
- Ohne solche Angabe wird der Dateiname vor dem Upload verwendet.
- Umlaute werden ausgeschrieben (Getraenkekarte, nicht Getrnkekarte),
  Sonderzeichen und Pfadanteile fallen weg.
- Gleiche Namen werden durchnummeriert, damit zwei Events namens
  "Karaoke" sich nicht gegenseitig ueberschreiben.

Besitz statt Namensmuster:
Die Loeschsicherheit haing bisher am Namensmuster <zeitstempel>-<zufall>.
Mit sprechenden Namen traegt der Name diese Information nicht mehr - ein
hochgeladenes karaoke-abend.avif ist von einem handgepflegten
event_karaoke.jpg nicht zu unterscheiden. Deshalb fuehrt das CMS jetzt in
managed_assets Buch darueber, welche Dateien es selbst angelegt hat, und
loescht ausschliesslich diese. Uploads von vor der Umstellung werden
weiterhin am alten Muster erkannt, damit sie aufraeumbar bleiben.

initDatabase() legte Tabellen nur an, wenn users noch fehlte. Auf einer
bestehenden Datenbank waere managed_assets damit nie entstanden. Der
Block laeuft jetzt bei jedem Start; alle Anweisungen sind IF NOT EXISTS.

Nebenbei repariert: die Formulare schickten den Alt-Text NACH der Datei.
Zu dem Zeitpunkt hat der Server ihn noch nicht geparst, in der Gallery
landete deshalb immer der Dateiname als Alt-Text. Textfelder gehen jetzt
vor der Datei raus.

Gemessen an einem 4032x3024-Bild: 0.90 MB rein, 36 KB AVIF bei 1600x1200
raus, 579 ms. Das Testbild ist synthetisch und komprimiert besser als ein
echtes Foto - die Verkleinerung auf 1600px greift aber immer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:39:42 +02:00
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
102 changed files with 4747 additions and 2885 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 install --package-lock-only
- npm audit --audit-level=moderate --json > audit-result.json 2>&1 || echo "Audit completed" - 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" - 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: when:
- branch: main - branch: main
event: push event: push
path:
include:
- 'package.json'
- 'package-lock.json'
- '.woodpecker.yml'
ignore_message: '[ALL]'
discord_notify_audit: discord_notify_audit:
image: alpine:latest image: alpine:latest
@@ -76,9 +84,41 @@ steps:
echo "No audit results found - listing workspace files:" echo "No audit results found - listing workspace files:"
ls -la ls -la
fi fi
# Gleicher Filter wie der Audit-Schritt, sonst meldet Discord bei jedem
# Inhalts-Commit "keine Ergebnisse gefunden"
when: when:
- branch: main - branch: main
event: push 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: deploy_frontend:
image: node:20 image: node:20
@@ -92,6 +132,19 @@ steps:
when: when:
- branch: main - branch: main
event: push 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: notify_success:
image: alpine:latest image: alpine:latest
+2 -5
View File
@@ -1,16 +1,13 @@
FROM node:20-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Fallback to npm install if no lockfile is present # Fallback to npm install if no lockfile is present
RUN npm ci || npm install RUN npm ci || npm install
COPY . . COPY . .
# Ensure CSS variables are present
RUN mkdir -p public/styles
RUN cp -r styles/* public/styles/ || true
RUN npm run build RUN npm run build
FROM node:20-alpine AS production FROM node:22-alpine AS production
WORKDIR /app WORKDIR /app
RUN npm install -g serve RUN npm install -g serve
+18 -29
View File
@@ -1,57 +1,46 @@
# Multi-stage build for Gallus CMS Backend # Multi-stage build for Gallus CMS Backend
# Stage 1: Builder # 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 FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Install build dependencies for native modules (better-sqlite3, sharp) # Nur fuer better-sqlite3 - fuer musl gibt es davon keine Fertigbauten.
RUN apk add --no-cache python3 make g++ vips-dev # 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 ./ COPY package*.json ./
# Use npm ci when lockfile exists, fallback to npm install for local/dev RUN npm ci
RUN npm ci || npm install
# Copy source
COPY . . COPY . .
# Build TypeScript
RUN npm run build 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 # Stage 2: Production
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool, vips for sharp) # git fuer simple-git, sqlite fuer die CLI. Keine Build-Werkzeuge mehr -
# Note: python3, make, g++ are needed for native module compilation # die blieben vorher als Layer im Image liegen, auch nach dem apk del.
RUN apk add --no-cache git sqlite vips vips-dev python3 make g++ RUN apk add --no-cache git sqlite
# Copy package files first
COPY --from=builder /app/package*.json ./ COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# 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/dist ./dist 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/migrate-production.js ./migrate-production.js
COPY --from=builder /app/data/images ./data/images COPY --from=builder /app/data/images ./data/images
# Create directories # Create directories and ensure proper permissions
RUN mkdir -p /app/workspace /app/data RUN mkdir -p /app/workspace /app/data && chown -R node:node /app
# Ensure proper permissions
RUN chown -R node:node /app
# Switch to non-root user # Switch to non-root user
USER node USER node
+1530 -151
View File
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -27,14 +27,16 @@ export function initDatabase() {
console.log('🔧 Initializing database...'); console.log('🔧 Initializing database...');
try { try {
// Check if users table exists (acts as a sentinel for initial setup) // Nur zur Protokollierung - angelegt wird immer, siehe unten
const tableCheck = sqlite const tableCheck = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get(); .get();
if (!tableCheck) { console.log(tableCheck ? '📝 Checking database schema...' : '📝 Creating database schema...');
console.log('📝 Creating database schema...');
// Laeuft bei JEDEM Start. Alle Anweisungen sind IF NOT EXISTS, und nur so
// bekommen bestehende Datenbanken spaeter ergaenzte Tabellen ueberhaupt.
{
sqlite.exec(` sqlite.exec(`
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
@@ -84,6 +86,11 @@ export function initDatabase() {
updated_at INTEGER DEFAULT (unixepoch()) updated_at INTEGER DEFAULT (unixepoch())
); );
CREATE TABLE IF NOT EXISTS managed_assets (
path TEXT PRIMARY KEY,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS publish_history ( CREATE TABLE IF NOT EXISTS publish_history (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id), user_id TEXT REFERENCES users(id),
@@ -93,9 +100,7 @@ export function initDatabase() {
); );
`); `);
console.log('✅ Database schema created successfully!'); console.log('✅ Database schema is up to date.');
} else {
console.log('✅ Database already initialized.');
} }
} catch (error) { } catch (error) {
console.error('❌ Error initializing database:', error); console.error('❌ Error initializing database:', error);
+6 -1
View File
@@ -28,7 +28,12 @@ export const env = {
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173', FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
// Upload // Upload
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10), // Bilder werden ohnehin auf 1600px heruntergerechnet, die Grenze muss nur
// gross genug fuer ein unbearbeitetes Handyfoto sein.
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || String(20 * 1024 * 1024), 10),
// PDFs werden unveraendert abgelegt. Eine Getraenkekarte mit Bildern liegt
// schnell bei 15-20 MB, deshalb ein eigener, groesserer Wert.
MAX_PDF_SIZE: parseInt(process.env.MAX_PDF_SIZE || String(40 * 1024 * 1024), 10),
}; };
// Validate required environment variables // Validate required environment variables
+12
View File
@@ -60,6 +60,18 @@ export const publishHistory = sqliteTable('publish_history', {
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`), publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
}); });
// Vom CMS selbst angelegte Dateien.
//
// Frueher wurde am Dateinamen erkannt, ob das CMS eine Datei loeschen darf.
// Seit die Namen aus Alt-Text bzw. Original-Dateiname abgeleitet werden,
// traegt der Name diese Information nicht mehr - ein hochgeladenes
// karaoke-abend.avif sieht aus wie ein handgepflegtes event_karaoke.jpg.
// Deshalb wird der Besitz hier festgehalten.
export const managedAssets = sqliteTable('managed_assets', {
path: text('path').primaryKey(), // z.B. /images/events/karaoke-abend.avif
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Banner table (for announcements like holidays, special info) // Banner table (for announcements like holidays, special info)
export const banners = sqliteTable('banners', { export const banners = sqliteTable('banners', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
+28 -1
View File
@@ -8,6 +8,7 @@ import { env, validateEnv } from './config/env.js';
import { db, initDatabase } from './config/database.js'; import { db, initDatabase } from './config/database.js';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import path from 'path'; import path from 'path';
import fs from 'fs';
// Import routes // Import routes
import authRoute from './routes/auth.js'; import authRoute from './routes/auth.js';
@@ -17,6 +18,7 @@ import contentRoute from './routes/content.js';
import settingsRoute from './routes/settings.js'; import settingsRoute from './routes/settings.js';
import publishRoute from './routes/publish.js'; import publishRoute from './routes/publish.js';
import bannersRoute from './routes/banners.js'; import bannersRoute from './routes/banners.js';
import pdfRoute from './routes/pdf.js';
// Validate environment variables // Validate environment variables
try { try {
@@ -71,20 +73,44 @@ fastify.register(jwt, {
}, },
}); });
// Der globale Wert ist die Obergrenze fuer alles. Die Routen setzen ihn per
// request.file({ limits }) auf ihren eigenen, engeren Wert herunter.
fastify.register(multipart, { fastify.register(multipart, {
limits: { limits: {
fileSize: env.MAX_FILE_SIZE, fileSize: Math.max(env.MAX_FILE_SIZE, env.MAX_PDF_SIZE),
}, },
}); });
// Serve static files (uploaded images, etc.) from persistent volume // Serve static files (uploaded images, etc.) from persistent volume
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); 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, { fastify.register(fastifyStatic, {
root: dataDir, root: dataDir,
prefix: '/static/', prefix: '/static/',
decorateReply: false 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 // Decorate fastify with authenticate method
fastify.decorate('authenticate', authenticate); fastify.decorate('authenticate', authenticate);
@@ -96,6 +122,7 @@ fastify.register(contentRoute, { prefix: '/api' });
fastify.register(settingsRoute, { prefix: '/api' }); fastify.register(settingsRoute, { prefix: '/api' });
fastify.register(publishRoute, { prefix: '/api' }); fastify.register(publishRoute, { prefix: '/api' });
fastify.register(bannersRoute, { prefix: '/api' }); fastify.register(bannersRoute, { prefix: '/api' });
fastify.register(pdfRoute, { prefix: '/api' });
// Health check // Health check
fastify.get('/health', async () => { fastify.get('/health', async () => {
+50
View File
@@ -3,6 +3,9 @@ import { z } from 'zod';
import { db } from '../config/database.js'; import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js'; import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { saveUploadedImage } from '../services/upload.service.js';
import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js';
import { env } from '../config/env.js';
// Fastify JSON schema for content section body // Fastify JSON schema for content section body
const contentBodyJsonSchema = { const contentBodyJsonSchema = {
@@ -78,6 +81,20 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
.returning(); .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 { return {
section: result.sectionName, section: result.sectionName,
content: result.contentJson, content: result.contentJson,
@@ -85,6 +102,39 @@ 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({ limits: { fileSize: env.MAX_FILE_SIZE } });
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 preferredName = (file.fields?.name?.value as string | undefined) || '';
const saved = await saveUploadedImage(file, 'content', {
preferredName,
log: fastify.log,
});
return reply.code(201).send({ imageUrl: saved.imageUrl });
} 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 // List all content sections
fastify.get('/content', { fastify.get('/content', {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
+41 -47
View File
@@ -2,8 +2,20 @@ import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js'; import { db } from '../config/database.js';
import { events } from '../db/schema.js'; import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import fs from 'fs'; import { dropImageIfUnused } from '../services/image-refs.service.js';
import path from 'path'; import { saveUploadedImage } from '../services/upload.service.js';
import { env } from '../config/env.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 // Fastify JSON schema for event body
const eventBodyJsonSchema = { const eventBodyJsonSchema = {
@@ -71,8 +83,17 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => { fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const data = request.body as any; 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(); 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' }); 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 }; return { event: row };
}); });
@@ -82,7 +103,7 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
}, async (request, reply) => { }, async (request, reply) => {
try { try {
// Expect a single file field named "file" // Expect a single file field named "file"
const file = await (request as any).file(); const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
if (!file) { if (!file) {
return reply.code(400).send({ error: 'No file uploaded' }); return reply.code(400).send({ error: 'No file uploaded' });
} }
@@ -92,52 +113,21 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' }); return reply.code(400).send({ error: 'Only image uploads are allowed' });
} }
// Prepare directories - use persistent volume for Fly.io // Der Titel kommt als Formularfeld VOR der Datei, sonst ist er hier
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); // noch nicht geparst
const uploadDir = path.join(dataDir, 'public', 'images', 'events'); const preferredName = (file.fields?.title?.value as string | undefined) || '';
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
// Read uploaded stream into buffer const saved = await saveUploadedImage(file, 'events', {
const chunks: Buffer[] = []; preferredName,
for await (const chunk of file.file) { log: fastify.log,
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'); fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' }); return reply.code(500).send({ error: 'Failed to upload image' });
} }
@@ -148,6 +138,10 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const [row] = await db.delete(events).where(eq(events.id, id)).returning(); const [row] = await db.delete(events).where(eq(events.id, id)).returning();
if (!row) return reply.code(404).send({ error: 'Event not found' }); 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' }; return { message: 'Event deleted successfully' };
}); });
+35 -48
View File
@@ -3,8 +3,20 @@ import { z } from 'zod';
import { db } from '../config/database.js'; import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js'; import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import fs from 'fs'; import { dropImageIfUnused } from '../services/image-refs.service.js';
import path from 'path'; import { saveUploadedImage } from '../services/upload.service.js';
import { env } from '../config/env.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 // Fastify JSON schema for gallery image body
const galleryBodyJsonSchema = { const galleryBodyJsonSchema = {
@@ -70,7 +82,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
}, async (request, reply) => { }, async (request, reply) => {
try { try {
// Expect a single file field named "file" // Expect a single file field named "file"
const file = await (request as any).file(); const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
if (!file) { if (!file) {
return reply.code(400).send({ error: 'No file uploaded' }); return reply.code(400).send({ error: 'No file uploaded' });
} }
@@ -84,60 +96,25 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' }); return reply.code(400).send({ error: 'Only image uploads are allowed' });
} }
// Prepare directories - use persistent volume for Fly.io const saved = await saveUploadedImage(file, 'gallery', {
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); preferredName: altText,
const uploadDir = path.join(dataDir, 'public', 'images', 'gallery'); log: fastify.log,
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}`;
// Store in DB (optional but useful) // Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({ const [row] = await db.insert(galleryImages).values({
imageUrl: publicUrl, imageUrl: saved.imageUrl,
altText: altText || filename, altText: altText || saved.filename,
displayOrder, displayOrder,
isPublished: true, isPublished: true,
}).returning(); }).returning();
return reply.code(201).send({ image: row }); 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'); fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' }); return reply.code(500).send({ error: 'Failed to upload image' });
} }
@@ -153,6 +130,8 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const data = request.body as any; const data = request.body as any;
const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
const [updated] = await db const [updated] = await db
.update(galleryImages) .update(galleryImages)
.set(data) .set(data)
@@ -163,6 +142,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(404).send({ error: 'Image not found' }); 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 }; return { image: updated };
}); });
@@ -181,6 +165,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(404).send({ error: 'Image not found' }); return reply.code(404).send({ error: 'Image not found' });
} }
// Zugehoerige Bilddatei mitnehmen
await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image');
return { message: 'Image deleted successfully' }; return { message: 'Image deleted successfully' };
}); });
+125
View File
@@ -0,0 +1,125 @@
import { FastifyPluginAsync } from 'fastify';
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 { isManagedAsset, forgetManagedAsset } from '../services/managed-assets.service.js';
import { saveUploadedPdf } from '../services/upload.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({ limits: { fileSize: env.MAX_PDF_SIZE } });
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_PDF_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 pdfUrl = await saveUploadedPdf(file, buffer);
// 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 && (await isManagedAsset(previousUrl))) {
try {
if (assets.deletePdf(previousUrl)) {
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
}
await forgetManagedAsset(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_PDF_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;
+16 -1
View File
@@ -2,6 +2,7 @@ import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { GitService } from '../services/git.service.js'; import { GitService } from '../services/git.service.js';
import { FileGeneratorService } from '../services/file-generator.service.js'; import { FileGeneratorService } from '../services/file-generator.service.js';
import { sweepOrphanedImages } from '../services/image-refs.service.js';
import { db } from '../config/database.js'; import { db } from '../config/database.js';
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js'; import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@@ -34,6 +35,13 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.log.info('Git repository initialized'); fastify.log.info('Git repository initialized');
// Verwaiste Uploads entfernen, bevor committet wird
const removedImages = await sweepOrphanedImages();
if (removedImages.length > 0) {
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
}
// Fetch all content from database // Fetch all content from database
const eventsData = await db const eventsData = await db
.select() .select()
@@ -78,16 +86,23 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.log.info(`Changes committed: ${commitHash}`); 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({ await db.insert(publishHistory).values({
userId, userId,
commitHash, commitHash,
commitMessage, commitMessage,
}); });
} catch (historyError) {
fastify.log.warn({ err: historyError }, 'Could not record publish history');
}
return { return {
success: true, success: true,
commitHash, commitHash,
removedImages: removedImages.length,
message: 'Changes published successfully', message: 'Changes published successfully',
}; };
@@ -0,0 +1,218 @@
/**
* Einmalige Umwandlung des Altbestands nach AVIF.
*
* Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert
* und im Originalformat im Repo - damals war sharp im Container kaputt, es
* gab also weder Verkleinerung noch Formatwandlung. Dieses Skript schickt
* sie durch dieselbe Verarbeitung wie einen frischen Upload, benennt sie
* nach ihrem Inhalt und zieht die Verweise in der Datenbank mit.
*
* Aufruf im Container:
* node dist/scripts/convert-images-to-avif.js # nur anzeigen
* node dist/scripts/convert-images-to-avif.js --apply # wirklich tun
*
* Ohne --apply wird nichts geschrieben.
*/
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { db, initDatabase } from '../config/database.js';
import { events, galleryImages, contentSections } from '../db/schema.js';
import { GitService } from '../services/git.service.js';
import { AssetService, MANAGED_IMAGE_DIRS } from '../services/asset.service.js';
import { saveImageBuffer, UploadSubdir } from '../services/upload.service.js';
import { replaceImageUrls, dropImageIfUnused } from '../services/image-refs.service.js';
import { env } from '../config/env.js';
const APPLY = process.argv.includes('--apply');
const assets = new AssetService();
interface Candidate {
url: string;
preferredName: string;
}
/** /images/events/foo.jpg -> events */
function subdirOf(url: string): UploadSubdir | null {
const match = /^\/images\/(events|gallery|content)\//.exec(url);
return match ? (match[1] as UploadSubdir) : null;
}
function absolutePathOf(url: string): string {
return path.join(env.GIT_WORKSPACE_DIR, 'public', url.replace(/^\/+/, ''));
}
/** Sprechender Name fuer ein Bild aus einem Textbereich. */
function contentImageName(section: string, key: string, content: any): string {
if (section === 'welcome' && key === 'imageUrl') return 'willkommen';
if (section === 'drinks' && key === 'monthlySpecialImage') {
return content?.monthlySpecialName || 'monats-hit';
}
const whiskey = /^whiskeyImage(\d)$/.exec(key);
if (whiskey) return `whiskey-${whiskey[1]}`;
return `${section}-bild`;
}
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 : {};
}
async function collectCandidates(): Promise<Candidate[]> {
const byUrl = new Map<string, string>();
const remember = (url: any, name: string) => {
if (typeof url !== 'string' || !url.startsWith('/images/')) return;
if (!byUrl.has(url)) byUrl.set(url, name);
};
for (const row of (await db.select().from(events)) as any[]) {
remember(row.imageUrl, row.title);
}
for (const row of (await db.select().from(galleryImages)) as any[]) {
remember(row.imageUrl, row.altText);
}
for (const row of (await db.select().from(contentSections)) as any[]) {
const content = asObject(row.contentJson);
for (const [key, value] of Object.entries(content)) {
remember(value, contentImageName(row.sectionName, key, content));
}
}
return [...byUrl.entries()].map(([url, preferredName]) => ({ url, preferredName }));
}
async function main() {
console.log(APPLY ? '=== Umwandlung nach AVIF ===' : '=== Vorschau (nichts wird geschrieben) ===\n');
initDatabase();
// Workspace auf den Stand des Repos bringen
const git = new GitService();
await git.initialize();
console.log(`Workspace: ${env.GIT_WORKSPACE_DIR}\n`);
const candidates = await collectCandidates();
const mapping = new Map<string, string>();
let bytesBefore = 0;
let bytesAfter = 0;
let skipped = 0;
let missing = 0;
for (const { url, preferredName } of candidates) {
const subdir = subdirOf(url);
if (!subdir) {
console.log(` uebersprungen (ausserhalb der Upload-Ordner): ${url}`);
skipped++;
continue;
}
if (url.toLowerCase().endsWith('.avif')) {
skipped++;
continue;
}
const source = absolutePathOf(url);
if (!assets.resolveInDirs(url, MANAGED_IMAGE_DIRS) || !fs.existsSync(source)) {
console.log(` FEHLT auf der Platte, Verweis bleibt: ${url}`);
missing++;
continue;
}
const input = fs.readFileSync(source);
bytesBefore += input.length;
if (!APPLY) {
console.log(` ${url} (${(input.length / 1024).toFixed(0)} KB) -> benannt nach "${preferredName}"`);
continue;
}
const saved = await saveImageBuffer(input, subdir, { preferredName });
const outSize = fs.statSync(absolutePathOf(saved.imageUrl)).size;
bytesAfter += outSize;
mapping.set(url, saved.imageUrl);
console.log(
` ${url} ${(input.length / 1024).toFixed(0)} KB -> ${saved.imageUrl} ${(outSize / 1024).toFixed(0)} KB`
);
}
if (!APPLY) {
console.log(`\n${candidates.length - skipped - missing} Bild(er) wuerden umgewandelt.`);
console.log(`Gesamtgroesse aktuell: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB`);
console.log('\nZum Ausfuehren: node dist/scripts/convert-images-to-avif.js --apply');
return;
}
if (mapping.size === 0) {
console.log('\nNichts umzuwandeln.');
return;
}
// Verweise in der Datenbank nachziehen
for (const row of (await db.select().from(events)) as any[]) {
const next = mapping.get(row.imageUrl);
if (next) await db.update(events).set({ imageUrl: next }).where(eq(events.id, row.id));
}
for (const row of (await db.select().from(galleryImages)) as any[]) {
const next = mapping.get(row.imageUrl);
if (next) await db.update(galleryImages).set({ imageUrl: next }).where(eq(galleryImages.id, row.id));
}
for (const row of (await db.select().from(contentSections)) as any[]) {
const updated = replaceImageUrls(asObject(row.contentJson), mapping);
await db
.update(contentSections)
.set({ contentJson: updated, updatedAt: new Date() })
.where(eq(contentSections.sectionName, row.sectionName));
}
console.log('\nVerweise in der Datenbank aktualisiert.');
// Vorgaenger wegraeumen - dropImageIfUnused loescht nur, was das CMS
// selbst angelegt hat. Handgepflegte Dateien wie event_karaoke.jpg
// bleiben liegen, obwohl jetzt eine AVIF-Fassung existiert.
let removed = 0;
let kept = 0;
for (const oldUrl of mapping.keys()) {
if (await dropImageIfUnused(oldUrl)) {
removed++;
} else if (fs.existsSync(absolutePathOf(oldUrl))) {
console.log(` Original behalten (nicht vom CMS angelegt): ${oldUrl}`);
kept++;
}
}
console.log(`\n${mapping.size} Bild(er) umgewandelt, ${removed} Original(e) entfernt, ${kept} behalten.`);
console.log(
`Groesse: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB -> ${(bytesAfter / 1024 / 1024).toFixed(2)} MB` +
` (${(100 - (bytesAfter / bytesBefore) * 100).toFixed(1)} % gespart)`
);
const hash = await git.commitAndPush('Bestandsbilder nach AVIF umgewandelt');
console.log(`\nCommit: ${hash}`);
console.log('Fertig. Woodpecker baut die Seite jetzt neu.');
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error('\nFehlgeschlagen:', err);
process.exit(1);
});
+94
View File
@@ -0,0 +1,94 @@
import fs from 'fs';
import path from 'path';
import { env } from '../config/env.js';
/**
* Kennt die Verzeichnisse, in die das CMS schreibt, und sorgt dafuer, dass
* kein Pfad ausserhalb davon angefasst wird.
*
* Ob eine konkrete Datei geloescht werden DARF, entscheidet diese Klasse
* bewusst nicht - das steht in managed-assets.service.ts.
*/
// Unterordner unterhalb von public/
export const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
export const MANAGED_PDF_DIR = 'pdf';
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;
}
/** Absoluter Pfad, sofern die Datei in einem der erlaubten Ordner liegt. */
resolveInDirs(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('/');
return dirs.includes(dir) ? absolute : null;
}
/** Existiert die Datei bereits? Fuer die Namensvergabe. */
exists(url: string, dirs: string[]): boolean {
const absolute = this.resolveInDirs(url, dirs);
return absolute ? fs.existsSync(absolute) : false;
}
/** Loescht eine Bilddatei. Die Besitzfrage muss vorher geklaert sein. */
deleteImage(url: string): boolean {
return this.unlink(this.resolveInDirs(url, MANAGED_IMAGE_DIRS));
}
/** Loescht ein PDF. Die Besitzfrage muss vorher geklaert sein. */
deletePdf(url: string): boolean {
return this.unlink(this.resolveInDirs(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;
}
}
/** Alle Bilddateien, die in den verwalteten Ordnern liegen, als URL-Pfade. */
listImageFiles(): string[] {
const found: 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 (!fs.statSync(absolute).isFile()) continue;
found.push(`/${dir}/${name}`);
}
}
return found;
}
}
+38 -17
View File
@@ -27,6 +27,27 @@ export class FileGeneratorService {
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${'); 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 { generateIndexAstro(events: Event[], images: GalleryImage[]): string {
const eventsCode = events.map(e => `\t{ const eventsCode = events.map(e => `\t{
\t\timage: "${e.imageUrl}", \t\timage: "${e.imageUrl}",
@@ -86,9 +107,9 @@ const { id } = Astro.props;
\t\t<div class="hero-content"> \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\t<a href="#" class="button">Aktuelles ↓</a>
\t\t</div> \t\t</div>
@@ -105,7 +126,7 @@ const { id } = Astro.props;
generateWelcomeComponent(content: ContentSection): string { generateWelcomeComponent(content: ContentSection): string {
const highlightsList = (content.highlights || []).map((h: any) => 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'); ).join('\n\n');
return `--- return `---
@@ -119,11 +140,11 @@ const { id } = Astro.props;
\t<div class="welcome-text"> \t<div class="welcome-text">
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2> \t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2> \t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
\t\t<p> \t\t<p>
\t\t\t${content.introText || ''} \t\t\t${this.text(content.introText)}
\t\t</p> \t\t</p>
\t\t<p><b>Unsere Highlights:</b></p> \t\t<p><b>Unsere Highlights:</b></p>
@@ -133,14 +154,14 @@ ${highlightsList}
\t\t</ul> \t\t</ul>
\t\t<p> \t\t<p>
\t\t\t${content.closingText || ''} \t\t\t${this.text(content.closingText)}
\t\t</p> \t\t</p>
\t</div> \t</div>
\t<div class="welcome-image"> \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> \t</div>
</section> </section>
@@ -157,36 +178,36 @@ const { id } = Astro.props;
<h2 class="title">Drinks</h2> <h2 class="title">Drinks</h2>
<p class="note"> <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> </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> <h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka"> <div class="mate-vodka">
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}"> <div class="circle" title="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" /> <img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span> <span class="circle-label"></span>
</div> </div>
<div>${content.monthlySpecialName || 'Mate Vodka'}</div> <div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
</div> </div>
<p class="note"> <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> </p>
<div class="circle-row"> <div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1"> <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> <span class="circle-label"></span>
</div> </div>
<div class="circle whiskey-circle" title="Whiskey 2"> <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> <span class="circle-label"></span>
</div> </div>
<div class="circle whiskey-circle" title="Whiskey 3"> <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> <span class="circle-label"></span>
</div> </div>
</div> </div>
+22 -11
View File
@@ -4,6 +4,13 @@ import { existsSync } from 'fs';
import path from 'path'; import path from 'path';
import { env } from '../config/env.js'; 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 { export class GitService {
private git: SimpleGit; private git: SimpleGit;
private workspaceDir: string; private workspaceDir: string;
@@ -51,15 +58,17 @@ export class GitService {
if (!usable) { if (!usable) {
console.log('Cloning repository...'); 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 // verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
const imagesDir = path.join(this.workspaceDir, 'public', 'images'); const backupRoot = path.join(this.parentDir, '.workspace-upload-backup');
const backupDir = path.join(this.parentDir, '.workspace-images-backup'); await rm(backupRoot, { recursive: true, force: true });
const hasImages = existsSync(imagesDir);
await rm(backupDir, { recursive: true, force: true }); const saved: string[] = [];
if (hasImages) { for (const relative of UPLOAD_DIRS) {
await cp(imagesDir, backupDir, { recursive: true }); 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 }); await rm(this.workspaceDir, { recursive: true, force: true });
@@ -70,15 +79,16 @@ export class GitService {
await this.git.clone(authenticatedUrl, this.workspaceDir); await this.git.clone(authenticatedUrl, this.workspaceDir);
this.git = simpleGit(this.workspaceDir); this.git = simpleGit(this.workspaceDir);
if (hasImages) { for (const relative of saved) {
// force: false -> was schon im Repo liegt, bleibt unangetastet // 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, recursive: true,
force: false, force: false,
errorOnExist: false, errorOnExist: false,
}); });
await rm(backupDir, { recursive: true, force: true });
} }
await rm(backupRoot, { recursive: true, force: true });
} }
// Configure git user // Configure git user
@@ -117,6 +127,7 @@ export class GitService {
const git = simpleGit(this.workspaceDir); const git = simpleGit(this.workspaceDir);
await git.reset(['--hard', 'HEAD']); await git.reset(['--hard', 'HEAD']);
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg // 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]);
} }
} }
+128
View File
@@ -0,0 +1,128 @@
import { db } from '../config/database.js';
import { events, galleryImages, contentSections } from '../db/schema.js';
import { AssetService } from './asset.service.js';
import { isManagedAsset, forgetManagedAsset } from './managed-assets.service.js';
const assets = new AssetService();
/**
* Loescht eine Bilddatei, sofern das CMS sie selbst angelegt hat und kein
* Datensatz sie mehr benutzt. Muss NACH dem Loeschen bzw. Aktualisieren der
* Zeile aufgerufen werden.
*/
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
if (!url) return false;
if (!(await isManagedAsset(url))) return false;
const referenced = await collectReferencedImageUrls();
if (referenced.has(url)) return false;
const deleted = assets.deleteImage(url);
await forgetManagedAsset(url);
return deleted;
}
/**
* Entfernt alle vom CMS angelegten Bilder, die nirgends mehr referenziert
* werden. Die Referenzliste deckt bewusst ALLE Zeilen ab, auch
* unveroeffentlichte - sonst verlieren die ihr Bild.
*/
export async function sweepOrphanedImages(): Promise<string[]> {
const referenced = await collectReferencedImageUrls();
const removed: string[] = [];
for (const url of assets.listImageFiles()) {
if (referenced.has(url)) continue;
if (!(await isManagedAsset(url))) continue;
if (assets.deleteImage(url)) removed.push(url);
await forgetManagedAsset(url);
}
return removed;
}
/**
* 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;
}
/**
* Ersetzt Bildpfade in einem beliebigen Content-JSON anhand einer Zuordnung
* alt -> neu. Die Struktur bleibt dabei unveraendert.
*/
export function replaceImageUrls(value: any, mapping: Map<string, string>): any {
if (typeof value === 'string') {
return mapping.get(value) ?? value;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceImageUrls(entry, mapping));
}
if (value && typeof value === 'object') {
const out: Record<string, any> = {};
for (const [key, entry] of Object.entries(value)) {
out[key] = replaceImageUrls(entry, mapping);
}
return out;
}
return value;
}
/** 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);
}
}
@@ -0,0 +1,54 @@
import { eq } from 'drizzle-orm';
import { db } from '../config/database.js';
import { managedAssets } from '../db/schema.js';
/**
* Fuehrt Buch darueber, welche Dateien das CMS selbst angelegt hat.
* Nur diese darf es spaeter wieder loeschen.
*/
// Altbestand: vor der Umstellung auf sprechende Namen hiessen Uploads
// <base36-zeitstempel>-<6 zeichen>.<ext>. Diese Dateien stehen nicht in der
// Tabelle, sollen aber weiterhin aufgeraeumt werden koennen. Handgepflegte
// Assets wie event_karaoke.jpg oder Gallery1.webp passen nicht auf das Muster.
const LEGACY_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
function basename(urlPath: string): string {
return urlPath.split('/').pop() || '';
}
/** Merkt sich eine neu angelegte Datei. */
export async function registerManagedAsset(urlPath: string): Promise<void> {
await db.insert(managedAssets).values({ path: urlPath }).onConflictDoNothing();
}
/** Vergisst eine Datei wieder (nach dem Loeschen). */
export async function forgetManagedAsset(urlPath: string): Promise<void> {
await db.delete(managedAssets).where(eq(managedAssets.path, urlPath));
}
/** Darf das CMS diese Datei loeschen? */
export async function isManagedAsset(urlPath: string): Promise<boolean> {
if (!urlPath) return false;
const [row] = await db
.select()
.from(managedAssets)
.where(eq(managedAssets.path, urlPath))
.limit(1);
if (row) return true;
return LEGACY_NAME.test(basename(urlPath));
}
/** Alle vom CMS angelegten Pfade aus der Tabelle. */
export async function listManagedAssets(): Promise<string[]> {
const rows = (await db.select().from(managedAssets)) as any[];
return rows.map((row) => row.path as string);
}
/** Trifft der Altbestands-Namensstil zu? */
export function hasLegacyName(urlPath: string): boolean {
return LEGACY_NAME.test(basename(urlPath));
}
+161
View File
@@ -0,0 +1,161 @@
import fs from 'fs';
import path from 'path';
import { env } from '../config/env.js';
import { AssetService, MANAGED_IMAGE_DIRS, MANAGED_PDF_DIR } from './asset.service.js';
import { registerManagedAsset } from './managed-assets.service.js';
export type UploadSubdir = 'events' | 'gallery' | 'content';
export interface SavedImage {
filename: string;
imageUrl: string;
}
const assets = new AssetService();
/**
* Macht aus "Karaoke-Abend im Gallus Pub!" -> "karaoke-abend-im-gallus-pub".
* Umlaute werden ausgeschrieben, nicht entfernt, damit aus "Getränke" nicht
* "getrnke" wird.
*/
export function slugify(value: string): string {
const slug = String(value || '')
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60)
.replace(/-+$/, '');
return slug;
}
/** Dateiname ohne Endung, wie er vor dem Upload hiess. */
function originalBaseName(file: any): string {
const name = (file?.filename as string | undefined) || '';
return name.replace(/\.[^.]+$/, '');
}
/**
* Sucht einen freien Namen. Gibt es <basis>.avif schon, wird -2, -3, ...
* angehaengt, damit zwei Events namens "Karaoke" sich nicht ueberschreiben.
*/
function findFreeName(base: string, ext: string, urlDir: string, dirs: string[]): string {
const safeBase = base || `datei-${Date.now().toString(36)}`;
for (let attempt = 1; attempt <= 500; attempt++) {
const candidate = attempt === 1 ? `${safeBase}${ext}` : `${safeBase}-${attempt}${ext}`;
if (!assets.exists(`${urlDir}/${candidate}`, dirs)) return candidate;
}
// Sollte nie eintreten - lieber ein haesslicher Name als eine Endlosschleife
return `${safeBase}-${Date.now().toString(36)}${ext}`;
}
/**
* Nimmt einen Multipart-Upload entgegen, rechnet ihn auf 1600px herunter,
* wandelt nach AVIF und legt ihn unter public/images/<subdir> ab.
*
* Der Name kommt aus preferredName (Event-Titel bzw. Alt-Text) und faellt
* sonst auf den urspruenglichen Dateinamen zurueck.
*/
export async function saveUploadedImage(
file: any,
subdir: UploadSubdir,
options: { preferredName?: string; log?: { warn: (obj: any, msg: string) => void } } = {}
): Promise<SavedImage> {
const { preferredName, log } = options;
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;
}
return saveImageBuffer(inputBuffer, subdir, {
preferredName: preferredName || originalBaseName(file),
fallbackExtension: '.' + ((file.mimetype || '').split('/')[1] || 'bin')
.replace(/[^a-z0-9]/gi, '').toLowerCase(),
log,
});
}
/**
* Kern der Bildverarbeitung: verkleinern, nach AVIF wandeln, unter einem
* sprechenden Namen ablegen und als CMS-eigene Datei vermerken.
*
* Wird sowohl vom Upload als auch vom Umwandlungsskript fuer den Altbestand
* benutzt, damit beide Wege identisch arbeiten.
*/
export async function saveImageBuffer(
inputBuffer: Buffer,
subdir: UploadSubdir,
options: {
preferredName?: string;
fallbackExtension?: string;
log?: { warn: (obj: any, msg: string) => void };
} = {}
): Promise<SavedImage> {
const { preferredName, fallbackExtension = '.bin', log } = options;
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
fs.mkdirSync(uploadDir, { recursive: true });
let outBuffer: Buffer;
let outExt = '.avif';
try {
// Sharp erst laden wenn wirklich gebraucht
const sharp = (await import('sharp')).default;
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.avif({ quality: 55 })
.toBuffer();
} catch (err) {
log?.warn({ err }, 'Sharp processing failed, using original image');
outBuffer = inputBuffer;
outExt = fallbackExtension;
}
const urlDir = `/images/${subdir}`;
const filename = findFreeName(slugify(preferredName || ''), outExt, urlDir, MANAGED_IMAGE_DIRS);
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
const imageUrl = `${urlDir}/${filename}`;
await registerManagedAsset(imageUrl);
return { filename, imageUrl };
}
/**
* Legt ein hochgeladenes PDF unter public/pdf ab, benannt nach dem
* urspruenglichen Dateinamen.
*/
export async function saveUploadedPdf(file: any, buffer: Buffer, preferredName?: string): Promise<string> {
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
fs.mkdirSync(uploadDir, { recursive: true });
const base = slugify(preferredName || '') || slugify(originalBaseName(file)) || 'dokument';
const filename = findFreeName(base, '.pdf', '/pdf', [MANAGED_PDF_DIR]);
fs.writeFileSync(path.join(uploadDir, filename), buffer);
const pdfUrl = `/pdf/${filename}`;
await registerManagedAsset(pdfUrl);
return pdfUrl;
}
+1704 -2530
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,6 +9,6 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"astro": "^5.12.0" "astro": "^7.2.1"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.
+8 -8
View File
@@ -7,19 +7,19 @@ const { id } = Astro.props;
<h2 class="title">Drinks</h2> <h2 class="title">Drinks</h2>
<p class="note"> <p class="note">
Ob frisch gezapftes Pint, edler Whisky oder ein gemütliches Glas Wein bei uns genießt du das Leben in entspannter Atmosphäre. Auch Cocktails dürfen nicht fehlen: Vieles kreieren wir selbst. Sláinte! 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> </p>
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a> <a href="/pdf/gallus-getraenkekarte-2026.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<h3 class="monats-hit">Monats Hit</h3> <h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka"> <div class="mate-vodka">
<div class="circle" title="Mate Vodka"> <div class="circle" title="mit oder ohne Schuss ;)">
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" /> <img src="/images/content/mit-oder-ohne-schuss.avif" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span> <span class="circle-label"></span>
</div> </div>
<div>Mate Vodka</div> <div>mit oder ohne Schuss ;)</div>
</div> </div>
<p class="note"> <p class="note">
@@ -28,15 +28,15 @@ const { id } = Astro.props;
<div class="circle-row"> <div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1"> <div class="circle whiskey-circle" title="Whiskey 1">
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" /> <img src="/images/content/whiskey-1.avif" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span> <span class="circle-label"></span>
</div> </div>
<div class="circle whiskey-circle" title="Whiskey 2"> <div class="circle whiskey-circle" title="Whiskey 2">
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" /> <img src="/images/content/whiskey-2.avif" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span> <span class="circle-label"></span>
</div> </div>
<div class="circle whiskey-circle" title="Whiskey 3"> <div class="circle whiskey-circle" title="Whiskey 3">
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" /> <img src="/images/content/whiskey-3.avif" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span> <span class="circle-label"></span>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More