1 Commits
Author SHA1 Message Date
Kenzo 61ad50b367 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 17:14:23 +01:00
90 changed files with 2950 additions and 9607 deletions
-29
View File
@@ -1,29 +0,0 @@
# 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
-1
View File
@@ -22,4 +22,3 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
/ai/
+7 -220
View File
@@ -1,106 +1,5 @@
steps:
audit_dependencies:
image: node:20
commands:
- 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
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
if [ -f audit-result.json ]; then
TOTAL=$(jq -r '.metadata.vulnerabilities.total // 0' audit-result.json 2>/dev/null || echo "0")
CRITICAL=$(jq -r '.metadata.vulnerabilities.critical // 0' audit-result.json 2>/dev/null || echo "0")
HIGH=$(jq -r '.metadata.vulnerabilities.high // 0' audit-result.json 2>/dev/null || echo "0")
MODERATE=$(jq -r '.metadata.vulnerabilities.moderate // 0' audit-result.json 2>/dev/null || echo "0")
LOW=$(jq -r '.metadata.vulnerabilities.low // 0' audit-result.json 2>/dev/null || echo "0")
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ] || [ "$MODERATE" -gt 0 ]; then
COLOR=16744448
STATUS="⚠️ Vulnerabilities Found"
else
COLOR=3066993
STATUS="✅ No Vulnerabilities"
fi
if [ -f audit-output.txt ]; then
VULNS=$(head -50 audit-output.txt | tail -40 || echo "No details")
else
VULNS="No audit output available"
fi
printf '%s' "$VULNS" > /tmp/vulns.txt
PAYLOAD=$(jq -n \
--arg title "🔒 Security Audit - Build #${CI_BUILD_NUMBER}" \
--arg status "$STATUS" \
--arg total "$TOTAL" \
--arg critical "$CRITICAL" \
--arg high "$HIGH" \
--arg moderate "$MODERATE" \
--arg low "$LOW" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--rawfile details /tmp/vulns.txt \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
--argjson color "$COLOR" \
'{
embeds: [{
title: $title,
description: $status,
color: $color,
fields: [
{ name: "Total", value: $total, inline: true },
{ name: "Critical", value: $critical, inline: true },
{ name: "High", value: $high, inline: true },
{ name: "Moderate", value: $moderate, inline: true },
{ name: "Low", value: $low, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Details", value: ("```\n" + ($details[:800]) + (if ($details | length) > 800 then "\n... (truncated)" else "" end) + "\n```"), inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
else
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:
deploy:
image: node:20
environment:
FLY_API_TOKEN:
@@ -108,122 +7,10 @@ steps:
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
- flyctl deploy --config fly.toml --app gallus-pub
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
environment:
FLY_API_TOKEN:
from_secret: FLY_API_TOKEN
commands:
- curl -L https://fly.io/install.sh | sh
- export PATH="$HOME/.fly/bin:$PATH"
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
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
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
--arg title "✅ Build #${CI_BUILD_NUMBER} - Success" \
--arg repo "${CI_REPO}" \
--arg branch "${CI_COMMIT_BRANCH}" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--arg author "${CI_COMMIT_AUTHOR}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'. as $message | {
embeds: [{
title: $title,
description: "Build und Deployment erfolgreich abgeschlossen!",
color: 3066993,
fields: [
{ name: "Repository", value: $repo, inline: true },
{ name: "Branch", value: $branch, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Author", value: $author, inline: true },
{ name: "Commit Message", value: $message, inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
when:
- branch: main
event: push
status: success
notify_failure:
image: alpine:latest
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
--arg title "❌ Build #${CI_BUILD_NUMBER} - Failure" \
--arg repo "${CI_REPO}" \
--arg branch "${CI_COMMIT_BRANCH}" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--arg author "${CI_COMMIT_AUTHOR}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'. as $message | {
embeds: [{
title: $title,
description: "Build oder Deployment ist fehlgeschlagen!",
color: 15158332,
fields: [
{ name: "Repository", value: $repo, inline: true },
{ name: "Branch", value: $branch, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Author", value: $author, inline: true },
{ name: "Commit Message", value: $message, inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
when:
- branch: main
event: push
status: failure
branch:
- main
event:
- push
+7 -6
View File
@@ -1,21 +1,22 @@
FROM node:22-alpine AS build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
# Fallback to npm install if no lockfile is present
RUN npm ci || npm install
RUN npm ci
COPY . .
# Ensure CSS variables are present
RUN mkdir -p public/styles
RUN cp -r styles/* public/styles/ || true
RUN npm run build
FROM node:22-alpine AS production
FROM node:20-alpine AS production
WORKDIR /app
RUN npm install -g serve
COPY --from=build /app/dist ./dist
EXPOSE 3000
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
CMD ["serve", "-l", "3000", "dist"]
CMD ["serve", "-s", "dist", "-l", "3000"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1
-9
View File
@@ -1,9 +0,0 @@
FROM caddy:2-alpine
# Embed Caddyfile directly to avoid host path issues on Windows
RUN mkdir -p /etc/caddy \
&& printf ":80\nlog\nroute {\n handle /api/* {\n reverse_proxy backend:8080\n }\n handle {\n reverse_proxy frontend:3000\n }\n}\n" > /etc/caddy/Caddyfile
EXPOSE 80
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
-142
View File
@@ -1,142 +0,0 @@
# Migration der alten Events und Gallery-Bilder
## ✅ Was wurde migriert?
### Events (7 Stück):
- Karaoke (wiederkehrend)
- Pub Quiz (wiederkehrend)
- Schlager Hüttenzauber Karaoke
- Adventskalender
- Santa Karaoke-Party
- Weihnachtsferien
- Neujahrs-Apero
### Gallery-Bilder (9 Stück):
- Gallery1.webp bis Gallery9.webp
## 📁 Wo liegen die Bilder?
Alle Bilder wurden konvertiert und liegen jetzt in:
- **Events:** `backend/data/images/events/`
- **Gallery:** `backend/data/images/gallery/`
Die Bilder wurden automatisch:
- Von PNG/JPG/JPEG zu WebP konvertiert
- Auf max. 1600px Breite skaliert
- Mit 85% Qualität optimiert
## 🚀 Deployment-Schritte
### 1. Lokale Vorbereitung (bereits erledigt ✓)
- ✓ Migrations-Script erstellt
- ✓ Bilder konvertiert und in `backend/data/images/` kopiert
- ✓ Public API-Endpunkte erstellt (`/api/events/public`, `/api/gallery/public`)
- ✓ Frontend aktualisiert, um Events und Gallery dynamisch zu laden
### 2. Auf Fly.io deployen
Alle Änderungen committen und pushen:
```bash
git add .
git commit -m "feat: Migrate old events and gallery images to CMS"
git push origin main
```
Woodpecker CI wird automatisch beide Services deployen.
### 3. Nach dem ersten Deploy - Datenbank initialisieren
**Wichtig:** Die Bilder sind bereits im Repository in `backend/data/images/`, aber die Datenbank muss noch mit den Event- und Gallery-Einträgen befüllt werden.
#### Via fly ssh (Empfohlen):
```bash
# In das Backend einloggen
fly ssh console -a gallus-cms-backend
# Prüfen ob Bilder da sind
ls -la /app/data/images/events/
ls -la /app/data/images/gallery/
# Migrations-Script ausführen
cd /app
npm run migrate:old-data
```
#### Alternative: Manuell via Admin-Panel
1. Gehe zu https://gallus-pub.ch/admin
2. Melde dich an
3. Für jedes Event:
- Klicke auf "Neues Event"
- Gib Titel, Datum und Beschreibung ein
- Statt Bild hochzuladen, trage manuell die imageUrl ein:
- z.B. `/images/events/event_karaoke.webp`
- Speichere das Event
## 🔍 Verifikation
Nach dem Deployment prüfen:
1. **Frontend:** https://gallus-pub.ch/
- Events sollten angezeigt werden
- Gallery sollte Bilder zeigen
2. **Admin:** https://gallus-pub.ch/admin
- Events können bearbeitet werden
- Neue Events können hinzugefügt werden
3. **Backend Health:** https://cms.gallus-pub.ch/health
- Status sollte "ok" sein
## 📝 Event-Daten für manuelles Einfügen
Falls du die Events manuell via Admin-Panel einfügen möchtest:
### Karaoke
- **Titel:** Karaoke
- **Datum:** 2025-12-31
- **Beschreibung:** Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_karaoke.webp`
### Pub Quiz
- **Titel:** Pub Quiz
- **Datum:** 2025-12-31
- **Beschreibung:** Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
- **Bild-URL:** `/images/events/event_pub-quiz.webp`
### Schlager Hüttenzauber Karaoke
- **Titel:** Schlager Hüttenzauber Karaoke
- **Datum:** 2025-11-27
- **Beschreibung:** Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_schlager-karaoke.webp`
### Adventskalender
- **Titel:** Adventskalender
- **Datum:** 2025-12-20
- **Beschreibung:** Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
- **Bild-URL:** `/images/events/event_advents-kalender.webp`
### Santa Karaoke-Party
- **Titel:** Santa Karaoke-Party
- **Datum:** 2025-12-06
- **Beschreibung:** 🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻
- **Bild-URL:** `/images/events/event_santa_karaoke.webp`
### Weihnachtsferien
- **Titel:** Weihnachtsferien
- **Datum:** 2025-12-21
- **Beschreibung:** Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
- **Bild-URL:** `/images/events/event_ferien.webp`
### Neujahrs-Apero
- **Titel:** Neujahrs-Apero
- **Datum:** 2026-01-02
- **Beschreibung:** 18:00-20:00 Uhr
- **Bild-URL:** `/images/events/event_neujahrs-apero.webp`
## ⚠️ Wichtige Hinweise
1. **Bilder sind im Volume persistent:** Alle Bilder in `/app/data/` bleiben bei Restarts erhalten
2. **Datenbank ist persistent:** Die SQLite-DB in `/app/data/gallus_cms.db` bleibt erhalten
3. **Alte Bilder in `public/images/`:** Die alten Original-Bilder bleiben im Frontend-Repository, werden aber nicht mehr verwendet
-1
View File
@@ -45,4 +45,3 @@ All commands are run from the root of the project, from a terminal:
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
# Test commit to trigger Woodpecker
-34
View File
@@ -1,34 +0,0 @@
# Local development environment for Gallus CMS Backend
# Database
DB_CLIENT=sqlite
DATABASE_URL=
DATABASE_PATH=./data/gallus_cms.db
# Gitea OAuth
GITEA_URL=https://git.bookageek.ch
GITEA_CLIENT_ID=bcddfe24-3099-41bc-bb7a-6c6d80bd9048
GITEA_CLIENT_SECRET=gto_me7hsswrsdq2ygey65edlc6qa2xxr3i5nrq7q4jvjwx654ytrh7q
# Frontend proxy callback in local dev
GITEA_REDIRECT_URI=http://localhost:4321/api/auth/callback
GITEA_ALLOWED_USERS=Gallus-maintanance
# Git repository for content versioning
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub
GIT_TOKEN=1482ae7bcdbd7610bf0cfd468b6757722d16a2a2
GIT_USER_NAME=Gallus-maintanance
GIT_USER_EMAIL=[email protected]
GIT_WORKSPACE_DIR=./data/workspace
# JWT & Session secrets (use strong random strings in real deployments)
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
# Server & CORS
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:4321
CORS_ORIGIN=http://localhost:4321
# Upload limits
MAX_FILE_SIZE=5242880
+4 -6
View File
@@ -4,9 +4,7 @@ dist
*.log
.DS_Store
/tmp
/data/*.db
/data/*.db-wal
/data/*.db-shm
/data/workspace
# Allow images to be committed
!/data/images
/data
*.db
*.db-wal
*.db-shm
+1 -1
View File
@@ -1,6 +1,6 @@
# Deployment Guide
## Prerequisite
## Prerequisites
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
2. Fly.io account: `flyctl auth login`
+27 -21
View File
@@ -1,46 +1,52 @@
# 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
# 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.
# Install build dependencies for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# Eigener Layer: bleibt im Cache, solange sich das Lockfile nicht aendert
# Install dependencies
COPY package*.json ./
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
# 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.
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
RUN apk add --no-cache git sqlite
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/migrate-production.js ./migrate-production.js
COPY --from=builder /app/data/images ./data/images
# Install build dependencies for better-sqlite3 (needed for npm ci)
RUN apk add --no-cache python3 make g++
# Create directories and ensure proper permissions
RUN mkdir -p /app/workspace /app/data && chown -R node:node /app
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --production
# Remove build dependencies after install
RUN apk del python3 make g++
# Copy built files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
# Create directories
RUN mkdir -p /app/workspace /app/data
# Ensure proper permissions
RUN chown -R node:node /app
# Switch to non-root user
USER node
@@ -57,5 +63,5 @@ ENV DATABASE_PATH=/app/data/gallus_cms.db
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Run DB migrations if present, then start application
CMD ["/bin/sh", "-lc", "mkdir -p /app/data/images/events /app/data/images/gallery && [ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
# Start application
CMD ["node", "dist/index.js"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

-6
View File
@@ -3,8 +3,6 @@ app = "gallus-cms-backend"
primary_region = "ams"
[build]
# Ensure Fly uses the Dockerfile in this backend directory
dockerfile = "Dockerfile"
[env]
PORT = "8080"
@@ -12,10 +10,6 @@ primary_region = "ams"
GITEA_URL = "https://git.bookageek.ch"
DATABASE_PATH = "/app/data/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
# Cross-site frontend and OAuth
FRONTEND_URL = "https://gallus-pub.ch"
CORS_ORIGIN = "https://gallus-pub.ch,https://www.gallus-pub.ch"
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
[http_service]
internal_port = 8080
-183
View File
@@ -1,183 +0,0 @@
// Production migration script - can be run directly with node
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Database schema
const events = sqliteTable('events', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
date: text('date').notNull(),
description: text('description').notNull(),
imageUrl: text('image_url').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
const galleryImages = sqliteTable('gallery_images', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
imageUrl: text('image_url').notNull(),
altText: text('alt_text').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Old events data
const oldEvents = [
{
title: "Karaoke",
date: "2025-12-31",
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
imageUrl: "/images/events/event_karaoke.webp",
displayOrder: 0,
},
{
title: "Pub Quiz",
date: "2025-12-31",
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
imageUrl: "/images/events/event_schlager-karaoke.webp",
displayOrder: 2,
},
{
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
imageUrl: "/images/events/event_advents-kalender.webp",
displayOrder: 3,
},
{
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
imageUrl: "/images/events/event_santa_karaoke.webp",
displayOrder: 4,
},
{
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
imageUrl: "/images/events/event_ferien.webp",
displayOrder: 5,
},
{
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
imageUrl: "/images/events/event_neujahrs-apero.webp",
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ imageUrl: "/images/gallery/Gallery7.webp", alt: "Gallery 7", order: 0 },
{ imageUrl: "/images/gallery/Gallery8.webp", alt: "Gallery 8", order: 1 },
{ imageUrl: "/images/gallery/Gallery9.webp", alt: "Gallery 9", order: 2 },
{ imageUrl: "/images/gallery/Gallery6.webp", alt: "Gallery 6", order: 3 },
{ imageUrl: "/images/gallery/Gallery1.webp", alt: "Gallery 1", order: 4 },
{ imageUrl: "/images/gallery/Gallery2.webp", alt: "Gallery 2", order: 5 },
{ imageUrl: "/images/gallery/Gallery3.webp", alt: "Gallery 3", order: 6 },
{ imageUrl: "/images/gallery/Gallery4.webp", alt: "Gallery 4", order: 7 },
{ imageUrl: "/images/gallery/Gallery5.webp", alt: "Gallery 5", order: 8 },
];
async function main() {
console.log('=== Production Migration Script ===\n');
const dbPath = process.env.DATABASE_PATH || '/app/data/gallus_cms.db';
console.log('Database path:', dbPath);
// Check if database exists
if (!fs.existsSync(dbPath)) {
console.error('ERROR: Database not found at:', dbPath);
console.error('Please ensure the backend has been started at least once to create the database.');
process.exit(1);
}
// Check if images exist
const dataDir = process.env.GIT_WORKSPACE_DIR || '/app/data';
const eventsDir = path.join(dataDir, 'images', 'events');
const galleryDir = path.join(dataDir, 'images', 'gallery');
console.log('Events images directory:', eventsDir);
console.log('Gallery images directory:', galleryDir);
if (!fs.existsSync(eventsDir)) {
console.error('ERROR: Events images directory not found:', eventsDir);
process.exit(1);
}
if (!fs.existsSync(galleryDir)) {
console.error('ERROR: Gallery images directory not found:', galleryDir);
process.exit(1);
}
// List available images
console.log('\nAvailable event images:', fs.readdirSync(eventsDir));
console.log('Available gallery images:', fs.readdirSync(galleryDir));
// Connect to database
const sqlite = new Database(dbPath);
const db = drizzle(sqlite);
console.log('\n=== Migrating Events ===\n');
for (const event of oldEvents) {
try {
const [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: event.imageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error.message);
}
}
console.log('\n=== Migrating Gallery Images ===\n');
for (const img of oldGalleryImages) {
try {
const [newImage] = await db.insert(galleryImages).values({
imageUrl: img.imageUrl,
altText: img.alt,
displayOrder: img.order,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error.message);
}
}
sqlite.close();
console.log('\n✓ Migration completed successfully!');
console.log('\nYou can verify the migration by visiting:');
console.log('- Frontend: https://gallus-pub.ch/');
console.log('- Admin: https://gallus-pub.ch/admin');
}
main().catch(error => {
console.error('\n✗ Migration failed:', error);
process.exit(1);
});
-4083
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -9,15 +9,14 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.0",
"@fastify/static": "^6.12.0",
"@fastify/multipart": "^8.1.0",
"@fastify/session": "^10.8.0",
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.33.0",
-94
View File
@@ -2,108 +2,14 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from '../db/schema.js';
import { env } from './env.js';
import fs from 'fs';
import path from 'path';
if (!env.DATABASE_PATH) {
throw new Error('DATABASE_PATH environment variable is not set');
}
// Ensure directory exists BEFORE opening the database file
const dbDir = path.dirname(env.DATABASE_PATH);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const sqlite = new Database(env.DATABASE_PATH);
// Enable WAL mode for better concurrent access
sqlite.pragma('journal_mode = WAL');
export const db = drizzle(sqlite, { schema });
// Auto-create tables if they don't exist
export function initDatabase() {
console.log('🔧 Initializing database...');
try {
// Nur zur Protokollierung - angelegt wird immer, siehe unten
const tableCheck = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get();
console.log(tableCheck ? '📝 Checking database schema...' : '📝 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(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
gitea_id TEXT UNIQUE NOT NULL,
gitea_username TEXT NOT NULL,
gitea_email TEXT,
display_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (unixepoch()),
last_login INTEGER
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS gallery_images (
id TEXT PRIMARY KEY,
image_url TEXT NOT NULL,
alt_text TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS content_sections (
id TEXT PRIMARY KEY,
section_name TEXT UNIQUE NOT NULL,
content_json TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
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 (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
commit_hash TEXT,
commit_message TEXT,
published_at INTEGER DEFAULT (unixepoch())
);
`);
console.log('✅ Database schema is up to date.');
}
} catch (error) {
console.error('❌ Error initializing database:', error);
throw error;
}
}
+1 -6
View File
@@ -28,12 +28,7 @@ export const env = {
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
// Upload
// 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),
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
};
// Validate required environment variables
+1 -23
View File
@@ -1,6 +1,7 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// Users table - stores Gitea user info for audit and access control
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
giteaId: text('gitea_id').notNull().unique(),
@@ -59,26 +60,3 @@ export const publishHistory = sqliteTable('publish_history', {
commitMessage: text('commit_message'),
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)
export const banners = sqliteTable('banners', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
text: text('text').notNull(),
startDate: text('start_date').notNull(), // ISO date string
endDate: text('end_date').notNull(), // ISO date string
isActive: integer('is_active', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
+12 -64
View File
@@ -3,12 +3,9 @@ import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import multipart from '@fastify/multipart';
import cookie from '@fastify/cookie';
import session from '@fastify/session';
import { authenticate } from './middleware/auth.middleware.js';
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,8 +14,6 @@ import galleryRoute from './routes/gallery.js';
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 {
@@ -42,75 +37,32 @@ const fastify = Fastify({
});
// Register plugins
// Support multiple origins for CORS
const allowedOrigins = env.CORS_ORIGIN.split(',').map(o => o.trim());
fastify.register(cors, {
origin: (origin, cb) => {
// Allow requests with no origin (like mobile apps or curl)
if (!origin) {
return cb(null, true);
}
// Check if origin is in allowed list
const isAllowed = allowedOrigins.includes(origin);
if (isAllowed) {
return cb(null, true);
} else {
return cb(null, false);
}
},
origin: env.CORS_ORIGIN,
credentials: true,
});
fastify.register(cookie);
fastify.register(session, {
secret: env.SESSION_SECRET,
cookie: {
secure: env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 600000, // 10 minutes (only needed for OAuth flow)
},
});
fastify.register(jwt, {
secret: env.JWT_SECRET,
cookie: {
cookieName: 'token',
signed: false,
},
});
// 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, {
limits: {
fileSize: Math.max(env.MAX_FILE_SIZE, env.MAX_PDF_SIZE),
fileSize: env.MAX_FILE_SIZE,
},
});
// 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);
@@ -121,8 +73,6 @@ fastify.register(galleryRoute, { prefix: '/api' });
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 () => {
@@ -155,8 +105,6 @@ fastify.setErrorHandler((error, request, reply) => {
// Start server
const start = async () => {
try {
// Initialize database before starting server
initDatabase();
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
console.log(`🚀 Server listening on port ${env.PORT}`);
console.log(`📝 Environment: ${env.NODE_ENV}`);
+19 -42
View File
@@ -6,14 +6,10 @@ import { eq } from 'drizzle-orm';
import { GiteaService } from '../services/gitea.service.js';
import { env } from '../config/env.js';
const callbackQueryJsonSchema = {
type: 'object',
required: ['code', 'state'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
},
} as const;
const callbackSchema = z.object({
code: z.string(),
state: z.string(),
});
const authRoute: FastifyPluginAsync = async (fastify) => {
const giteaService = new GiteaService();
@@ -26,14 +22,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
// Generate CSRF state token
const state = giteaService.generateState();
// Store state in a short-lived cookie
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
sameSite: 'none',
secure: true,
maxAge: 10 * 60, // 10 minutes
});
// Store state in session
request.session.set('oauth_state', state);
// Generate authorization URL
const authUrl = giteaService.getAuthorizationUrl(state);
@@ -48,20 +38,20 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
*/
fastify.get('/auth/callback', {
schema: {
querystring: callbackQueryJsonSchema,
querystring: callbackSchema,
},
}, async (request, reply) => {
try {
const { code, state } = request.query as { code: string; state: string };
const { code, state } = request.query as z.infer<typeof callbackSchema>;
// Verify CSRF state from cookie
const expectedState = request.cookies?.oauth_state as string | undefined;
// Verify CSRF state
const expectedState = request.session.get('oauth_state');
if (!expectedState || state !== expectedState) {
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state cookie
reply.clearCookie('oauth_state', { path: '/' });
// Clear state from session
request.session.delete('oauth_state');
// Exchange code for access token
const tokenResponse = await giteaService.exchangeCodeForToken(code);
@@ -113,28 +103,18 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
{
id: user.id,
giteaId: user.giteaId,
username: user.giteaUsername || '',
role: user.role ?? 'admin',
username: user.giteaUsername,
role: user.role,
},
{ expiresIn: '24h' }
);
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
reply.setCookie('token', token, {
path: '/',
httpOnly: true,
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
maxAge: 60 * 60 * 24, // 24h
});
// Redirect to admin dashboard
// Redirect to frontend with token
const frontendUrl = env.FRONTEND_URL;
return reply.redirect(`${frontendUrl}/admin`);
return reply.redirect(`${frontendUrl}/auth/callback?token=${token}`);
} catch (error) {
fastify.log.error({ err: error }, 'OAuth callback error');
fastify.log.error('OAuth callback error:', error);
return reply.code(500).send({ error: 'Authentication failed' });
}
});
@@ -159,14 +139,12 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
}
return {
user: {
id: user.id,
giteaUsername: user.giteaUsername,
giteaEmail: user.giteaEmail,
username: user.giteaUsername,
email: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
},
};
});
@@ -179,7 +157,6 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
}, async (request, reply) => {
// For JWT, logout is primarily client-side (delete token)
// You could maintain a token blacklist in Redis for production
reply.clearCookie('token', { path: '/' });
return { message: 'Logged out successfully' };
});
};
-152
View File
@@ -1,152 +0,0 @@
import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js';
import { banners } from '../db/schema.js';
import { eq, and, lte, gte, desc } from 'drizzle-orm';
const bannerBodyJsonSchema = {
type: 'object',
required: ['text', 'startDate', 'endDate'],
properties: {
text: { type: 'string' },
startDate: { type: 'string' },
endDate: { type: 'string' },
isActive: { type: 'boolean' },
},
} as const;
const bannersRoute: FastifyPluginAsync = async (fastify) => {
// Get active banner (public endpoint)
fastify.get('/banners/active', async (request, reply) => {
// Use local date to avoid timezone issues
const now = new Date();
const today = new Date(now.getTime() - (now.getTimezoneOffset() * 60000))
.toISOString()
.split('T')[0]; // YYYY-MM-DD
const [activeBanner] = await db
.select()
.from(banners)
.where(
and(
eq(banners.isActive, true),
lte(banners.startDate, today),
gte(banners.endDate, today)
)
)
.orderBy(desc(banners.createdAt))
.limit(1);
if (!activeBanner) {
return { banner: null };
}
return {
banner: {
id: activeBanner.id,
text: activeBanner.text,
startDate: activeBanner.startDate,
endDate: activeBanner.endDate,
},
};
});
// Get all banners (admin only)
fastify.get('/banners', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const allBanners = await db.select().from(banners);
return {
banners: allBanners.map((b: any) => ({
id: b.id,
text: b.text,
startDate: b.startDate,
endDate: b.endDate,
isActive: b.isActive,
createdAt: b.createdAt,
updatedAt: b.updatedAt,
})),
};
});
// Create banner (admin only)
fastify.post('/banners', {
schema: {
body: bannerBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { text, startDate, endDate, isActive = true } = request.body as any;
const [newBanner] = await db
.insert(banners)
.values({
text,
startDate,
endDate,
isActive,
})
.returning();
return {
banner: {
id: newBanner.id,
text: newBanner.text,
startDate: newBanner.startDate,
endDate: newBanner.endDate,
isActive: newBanner.isActive,
},
};
});
// Update banner (admin only)
fastify.put('/banners/:id', {
schema: {
body: bannerBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const { text, startDate, endDate, isActive } = request.body as any;
const [updated] = await db
.update(banners)
.set({
text,
startDate,
endDate,
isActive,
updatedAt: new Date(),
})
.where(eq(banners.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Banner not found' });
}
return {
banner: {
id: updated.id,
text: updated.text,
startDate: updated.startDate,
endDate: updated.endDate,
isActive: updated.isActive,
},
};
});
// Delete banner (admin only)
fastify.delete('/banners/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
await db.delete(banners).where(eq(banners.id, id));
return { success: true };
});
};
export default bannersRoute;
+6 -61
View File
@@ -3,18 +3,10 @@ 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';
import { env } from '../config/env.js';
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
type: 'object',
required: ['contentJson'],
properties: {
contentJson: {}, // allow any JSON
},
} as const;
const contentSectionSchema = z.object({
contentJson: z.record(z.any()),
});
const contentRoute: FastifyPluginAsync = async (fastify) => {
@@ -44,12 +36,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
// Update content section
fastify.put('/content/:section', {
schema: {
body: contentBodyJsonSchema,
body: contentSectionSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const { contentJson } = request.body as any;
const { contentJson } = request.body as z.infer<typeof contentSectionSchema>;
// Check if section exists
const [existing] = await db
@@ -81,20 +73,6 @@ 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,
@@ -102,39 +80,6 @@ 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
fastify.get('/content', {
preHandler: [fastify.authenticate],
@@ -142,7 +87,7 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
const sections = await db.select().from(contentSections);
return {
sections: (sections as any[]).map((s: any) => ({
sections: sections.map(s => ({
section: s.sectionName,
content: s.contentJson,
updatedAt: s.updatedAt,
+84 -121
View File
@@ -1,158 +1,121 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { dropImageIfUnused } from '../services/image-refs.service.js';
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
const eventBodyJsonSchema = {
type: 'object',
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
properties: {
title: { type: 'string', minLength: 1, maxLength: 200 },
date: { type: 'string', minLength: 1, maxLength: 100 },
description: { type: 'string', minLength: 1 },
imageUrl: { type: 'string', minLength: 1 },
displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' },
},
} as const;
const reorderBodyJsonSchema = {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
} as const;
const eventsRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published events (no auth required)
fastify.get('/events/public', async () => {
const all = await db.select().from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
return { events: all };
const eventSchema = z.object({
title: z.string().min(1).max(200),
date: z.string().min(1).max(100),
description: z.string().min(1),
imageUrl: z.string().url(),
displayOrder: z.number().int().min(0),
isPublished: z.boolean().optional().default(true),
});
// List all events (by displayOrder) - admin only
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
const all = await db.select().from(events).orderBy(events.displayOrder);
return { events: all };
const eventsRoute: FastifyPluginAsync = async (fastify) => {
// List all events
fastify.get('/events', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const allEvents = await db.select().from(events).orderBy(events.displayOrder);
return { events: allEvents };
});
// Get single event
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.get('/events/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
return { event: rows[0] };
const event = await db.select().from(events).where(eq(events.id, id)).limit(1);
if (event.length === 0) {
return reply.code(404).send({ error: 'Event not found' });
}
return { event: event[0] };
});
// Create event
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const data = request.body as any;
const [row] = await db.insert(events).values(data).returning();
return reply.code(201).send({ event: row });
fastify.post('/events', {
schema: {
body: eventSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as z.infer<typeof eventSchema>;
const [newEvent] = await db.insert(events).values(data).returning();
return reply.code(201).send({ event: newEvent });
});
// Update event
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 };
});
// Upload event image file (multipart)
fastify.post('/events/upload', {
fastify.put('/events/:id', {
schema: {
body: eventSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
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 { id } = request.params as { id: string };
const data = request.body as z.infer<typeof eventSchema>;
const [updated] = await db
.update(events)
.set({ ...data, updatedAt: new Date() })
.where(eq(events.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Event not found' });
}
const mime = file.mimetype as string | undefined;
if (!mime || !mime.startsWith('image/')) {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
// Der Titel kommt als Formularfeld VOR der Datei, sonst ist er hier
// noch nicht geparst
const preferredName = (file.fields?.title?.value as string | undefined) || '';
const saved = await saveUploadedImage(file, 'events', {
preferredName,
log: fastify.log,
});
return reply.code(201).send({ imageUrl: saved.imageUrl });
} 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' });
}
return { event: updated };
});
// Delete event
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.delete('/events/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
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');
const [deleted] = await db
.delete(events)
.where(eq(events.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Event not found' });
}
return { message: 'Event deleted successfully' };
});
// Reorder events (synchronous transaction for better-sqlite3)
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
// Reorder events
fastify.put('/events/reorder', {
schema: {
body: z.object({
orders: z.array(z.object({
id: z.string().uuid(),
displayOrder: z.number().int().min(0),
})),
}),
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
db.transaction((tx: any) => {
// Update all in transaction
await db.transaction(async (tx) => {
for (const { id, displayOrder } of orders) {
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
await tx
.update(events)
.set({ displayOrder })
.where(eq(events.id, id));
}
});
return { message: 'Events reordered successfully' };
});
};
+23 -112
View File
@@ -3,44 +3,17 @@ import { z } from 'zod';
import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { dropImageIfUnused } from '../services/image-refs.service.js';
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
const galleryBodyJsonSchema = {
type: 'object',
required: ['imageUrl', 'altText', 'displayOrder'],
properties: {
imageUrl: { type: 'string', minLength: 1 },
altText: { type: 'string', minLength: 1, maxLength: 200 },
displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' },
},
} as const;
const galleryImageSchema = z.object({
imageUrl: z.string().url(),
altText: z.string().min(1).max(200),
displayOrder: z.number().int().min(0),
isPublished: z.boolean().optional().default(true),
});
const galleryRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published gallery images (no auth required)
fastify.get('/gallery/public', async () => {
const images = await db.select().from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
return { images };
});
// List all gallery images - admin only
// List all gallery images
fastify.get('/gallery', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@@ -65,72 +38,26 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
// Create gallery image
fastify.post('/gallery', {
schema: {
body: galleryBodyJsonSchema,
body: galleryImageSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as any;
const data = request.body as z.infer<typeof galleryImageSchema>;
const [newImage] = await db.insert(galleryImages).values(data).returning();
return reply.code(201).send({ image: newImage });
});
// Upload image file (multipart)
fastify.post('/gallery/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
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 altText = (file.fields?.altText?.value as string | undefined) || '';
const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0';
const displayOrder = Number.parseInt(displayOrderRaw) || 0;
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, 'gallery', {
preferredName: altText,
log: fastify.log,
});
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({
imageUrl: saved.imageUrl,
altText: altText || saved.filename,
displayOrder,
isPublished: true,
}).returning();
return reply.code(201).send({ image: row });
} 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' });
}
});
// Update gallery image
fastify.put('/gallery/:id', {
schema: {
body: galleryBodyJsonSchema,
body: galleryImageSchema,
},
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(galleryImages).where(eq(galleryImages.id, id)).limit(1);
const data = request.body as z.infer<typeof galleryImageSchema>;
const [updated] = await db
.update(galleryImages)
@@ -142,11 +69,6 @@ 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 };
});
@@ -165,41 +87,30 @@ 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' };
});
// Reorder gallery images
fastify.put('/gallery/reorder', {
schema: {
body: {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
},
body: z.object({
orders: z.array(z.object({
id: z.string().uuid(),
displayOrder: z.number().int().min(0),
})),
}),
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
// Update all in synchronous transaction (better-sqlite3 requirement)
db.transaction((tx: any) => {
// Update all in transaction
await db.transaction(async (tx) => {
for (const { id, displayOrder } of orders) {
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
await tx
.update(galleryImages)
.set({ displayOrder })
.where(eq(galleryImages.id, id));
}
});
-125
View File
@@ -1,125 +0,0 @@
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;
+12 -32
View File
@@ -2,29 +2,23 @@ import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { GitService } from '../services/git.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 { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for publish body
const publishBodyJsonSchema = {
type: 'object',
required: ['commitMessage'],
properties: {
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
},
} as const;
const publishSchema = z.object({
commitMessage: z.string().min(1).max(200),
});
const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.post('/publish', {
schema: {
body: publishBodyJsonSchema,
body: publishSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const { commitMessage } = request.body as any;
const { commitMessage } = request.body as z.infer<typeof publishSchema>;
const userId = request.user.id;
fastify.log.info('Starting publish process...');
@@ -35,13 +29,6 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
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
const eventsData = await db
.select()
@@ -56,8 +43,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
.orderBy(galleryImages.displayOrder);
const sectionsData = await db.select().from(contentSections);
const sectionsMap = new Map<string, any>(
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
const sectionsMap = new Map(
sectionsData.map(s => [s.sectionName, s.contentJson as any])
);
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
@@ -66,13 +53,13 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
const fileGenerator = new FileGeneratorService();
await fileGenerator.writeFiles(
gitService.getWorkspacePath(''),
(eventsData as any[]).map((e: any) => ({
eventsData.map(e => ({
title: e.title,
date: e.date,
description: e.description,
imageUrl: e.imageUrl,
})),
(galleryData as any[]).map((g: any) => ({
galleryData.map(g => ({
imageUrl: g.imageUrl,
altText: g.altText,
})),
@@ -86,35 +73,28 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.log.info(`Changes committed: ${commitHash}`);
// 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 {
// Record in history
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',
};
} catch (error) {
fastify.log.error({ err: error }, 'Publish error');
fastify.log.error('Publish error:', error);
// Attempt to reset git state on error
try {
const gitService = new GitService();
await gitService.reset();
} catch (resetError) {
fastify.log.error({ err: resetError }, 'Failed to reset git state');
fastify.log.error('Failed to reset git state:', resetError);
}
return reply.code(500).send({
+5 -10
View File
@@ -4,14 +4,9 @@ import { db } from '../config/database.js';
import { siteSettings } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for settings body
const settingBodyJsonSchema = {
type: 'object',
required: ['value'],
properties: {
value: { type: 'string' },
},
} as const;
const settingSchema = z.object({
value: z.string(),
});
const settingsRoute: FastifyPluginAsync = async (fastify) => {
@@ -55,12 +50,12 @@ const settingsRoute: FastifyPluginAsync = async (fastify) => {
// Update setting
fastify.put('/settings/:key', {
schema: {
body: settingBodyJsonSchema,
body: settingSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const { value } = request.body as any;
const { value } = request.body as z.infer<typeof settingSchema>;
// Check if setting exists
const [existing] = await db
@@ -1,218 +0,0 @@
/**
* 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);
});
-190
View File
@@ -1,190 +0,0 @@
import { db } from '../config/database.js';
import { events, galleryImages } from '../db/schema.js';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Old events data
const oldEvents = [
{
image: "/images/events/event_karaoke.jpg",
title: "Karaoke",
date: "2025-12-31", // Set as ongoing event
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 0,
},
{
image: "/images/events/event_pub-quiz.jpg",
title: "Pub Quiz",
date: "2025-12-31", // Set as ongoing event
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
Auch Einzelpersonen sind herzlich willkommen! <br>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
image: "/images/events/event_schlager-karaoke.jpeg",
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 2,
},
{
image: "/images/events/event_advents-kalender.jpeg",
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
displayOrder: 3,
},
{
image: "/images/events/event_santa_karaoke.jpeg",
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
displayOrder: 4,
},
{
image: "/images/events/event_ferien.jpeg",
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
displayOrder: 5,
},
{
image: "/images/events/event_neujahrs-apero.jpeg",
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ src: "/images/gallery/Gallery7.png", alt: "Gallery 7" },
{ src: "/images/gallery/Gallery8.png", alt: "Gallery 8" },
{ src: "/images/gallery/Gallery9.png", alt: "Gallery 9" },
{ src: "/images/gallery/Gallery6.png", alt: "Gallery 6" },
{ src: "/images/gallery/Gallery1.png", alt: "Gallery 1" },
{ src: "/images/gallery/Gallery2.png", alt: "Gallery 2" },
{ src: "/images/gallery/Gallery3.png", alt: "Gallery 3" },
{ src: "/images/gallery/Gallery4.png", alt: "Gallery 4" },
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
];
async function copyAndConvertImage(
sourcePath: string,
destDir: string,
filename: string
): Promise<string> {
const projectRoot = path.join(process.cwd(), '..');
const fullSourcePath = path.join(projectRoot, 'public', sourcePath);
// Ensure destination directory exists
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const ext = path.extname(filename);
const baseName = path.basename(filename, ext);
const webpFilename = `${baseName}.webp`;
const destPath = path.join(destDir, webpFilename);
console.log(`Processing: ${fullSourcePath} -> ${destPath}`);
// Check if source exists
if (!fs.existsSync(fullSourcePath)) {
console.error(`Source file not found: ${fullSourcePath}`);
throw new Error(`Source file not found: ${fullSourcePath}`);
}
// Convert to webp and copy
await sharp(fullSourcePath)
.rotate() // Auto-rotate based on EXIF
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(destPath);
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
}
async function migrateEvents() {
console.log('\n=== Migrating Events ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const eventsImageDir = path.join(dataDir, 'images', 'events');
for (const event of oldEvents) {
try {
const filename = path.basename(event.image);
const newImageUrl = await copyAndConvertImage(
event.image,
eventsImageDir,
filename
);
const [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: newImageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error);
}
}
}
async function migrateGallery() {
console.log('\n=== Migrating Gallery Images ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const galleryImageDir = path.join(dataDir, 'images', 'gallery');
for (let i = 0; i < oldGalleryImages.length; i++) {
const img = oldGalleryImages[i];
try {
const filename = path.basename(img.src);
const newImageUrl = await copyAndConvertImage(
img.src,
galleryImageDir,
filename
);
const [newImage] = await db.insert(galleryImages).values({
imageUrl: newImageUrl,
altText: img.alt,
displayOrder: i,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error);
}
}
}
async function main() {
console.log('Starting migration of old data...\n');
console.log('Working directory:', process.cwd());
console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'));
try {
await migrateEvents();
await migrateGallery();
console.log('\n✓ Migration completed successfully!');
} catch (error) {
console.error('\n✗ Migration failed:', error);
process.exit(1);
}
}
main();
-94
View File
@@ -1,94 +0,0 @@
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;
}
}
+17 -40
View File
@@ -27,27 +27,6 @@ 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}",
@@ -64,7 +43,6 @@ export class FileGeneratorService {
return `---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
import Hero from "../components/Hero.astro";
import Welcome from "../components/Welcome.astro";
import EventsGrid from "../components/EventsGrid.astro";
@@ -84,7 +62,6 @@ ${imagesCode}
<Layout>
\t<Hero id="hero" />
\t<Banner />
\t<Welcome id="welcome" />
\t<EventsGrid id="events" events={events} />
\t<ImageCarousel id="gallery" images={images} />
@@ -107,9 +84,9 @@ const { id } = Astro.props;
\t\t<div class="hero-content">
\t\t\t<h1>${this.text(content.heading, 'Dein Irish Pub')}</h1>
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
\t\t\t<p>${this.text(content.subheading, 'Im Herzen von St.Gallen')}</p>
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
\t\t</div>
@@ -126,7 +103,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>${this.escapeHtml(h?.title)}:</b> ${this.escapeHtml(h?.description)}\n\t\t\t</li>`
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
).join('\n\n');
return `---
@@ -140,11 +117,11 @@ const { id } = Astro.props;
\t<div class="welcome-text">
\t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
\t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
\t\t<p>
\t\t\t${this.text(content.introText)}
\t\t\t${content.introText || ''}
\t\t</p>
\t\t<p><b>Unsere Highlights:</b></p>
@@ -154,14 +131,14 @@ ${highlightsList}
\t\t</ul>
\t\t<p>
\t\t\t${this.text(content.closingText)}
\t\t\t${content.closingText || ''}
\t\t</p>
\t</div>
\t<div class="welcome-image">
\t\t<img src="${this.text(content.imageUrl, '/images/Welcome.png')}" alt="Welcome background image" />
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
\t</div>
</section>
@@ -178,36 +155,36 @@ const { id } = Astro.props;
<h2 class="title">Drinks</h2>
<p class="note">
${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.')}
${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="${this.text(content.pdfUrl, '/pdf/Getraenke_Gallus_2025.pdf')}" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<a href="/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="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
<img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
</div>
<p class="note">
${this.text(content.whiskeyText, 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.')}
${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="${this.text(content.whiskeyImage1, '/images/Whiskey1.png')}" alt="Whiskey 1" class="circle-image" />
<img src="${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="${this.text(content.whiskeyImage2, '/images/Whiskey2.png')}" alt="Whiskey 2" class="circle-image" />
<img src="${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="${this.text(content.whiskeyImage3, '/images/Whiskey3.png')}" alt="Whiskey 3" class="circle-image" />
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
+11 -79
View File
@@ -1,94 +1,43 @@
import simpleGit, { SimpleGit } from 'simple-git';
import { mkdir, rm, cp } from 'fs/promises';
import { existsSync } from 'fs';
import { mkdir, rm } from 'fs/promises';
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;
private parentDir: string;
private repoUrl: string;
private token: string;
constructor() {
this.workspaceDir = env.GIT_WORKSPACE_DIR;
this.parentDir = path.dirname(this.workspaceDir);
this.repoUrl = env.GIT_REPO_URL;
this.token = env.GIT_TOKEN;
this.git = simpleGit();
}
async initialize() {
// Elternverzeichnis muss existieren - simple-git startet git von dort aus
await mkdir(this.parentDir, { recursive: true });
// Ensure workspace directory exists
await mkdir(this.workspaceDir, { recursive: true });
// Add token to repo URL for authentication
const authenticatedUrl = this.repoUrl.replace(
'https://',
`https://oauth2:${encodeURIComponent(this.token)}@`
`https://oauth2:${this.token}@`
);
let usable = false;
if (existsSync(path.join(this.workspaceDir, '.git'))) {
try {
this.git = simpleGit(this.workspaceDir);
// Check if repo already exists
await this.git.cwd(this.workspaceDir);
await this.git.status();
console.log('Repository already exists, pulling latest...');
await this.git.pull();
usable = true;
} catch (error) {
// Token aus der Meldung entfernen - git gibt die Remote-URL mit aus
const msg = error instanceof Error ? error.message : String(error);
console.warn(
'Existing workspace unusable, re-cloning:',
msg.replace(/\/\/[^@\s/]*@/g, '//***@')
);
}
}
if (!usable) {
} catch {
// Clone if doesn't exist
console.log('Cloning repository...');
// Hochgeladene Dateien liegen im Workspace und wuerden beim Loeschen
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
const backupRoot = path.join(this.parentDir, '.workspace-upload-backup');
await rm(backupRoot, { recursive: true, force: 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 });
// Aus dem existierenden Elternverzeichnis klonen, nicht aus dem
// soeben geloeschten Zielverzeichnis (sonst: spawn git ENOENT)
this.git = simpleGit(this.parentDir);
await this.git.clone(authenticatedUrl, this.workspaceDir);
this.git = simpleGit(this.workspaceDir);
for (const relative of saved) {
// force: false -> was schon im Repo liegt, bleibt unangetastet
await cp(path.join(backupRoot, relative), path.join(this.workspaceDir, relative), {
recursive: true,
force: false,
errorOnExist: false,
});
}
await rm(backupRoot, { recursive: true, force: true });
await this.git.cwd(this.workspaceDir);
}
// Configure git user
@@ -98,14 +47,6 @@ export class GitService {
async commitAndPush(message: string): Promise<string> {
await this.git.add('.');
const status = await this.git.status();
if (status.isClean()) {
// Nichts zu committen - aktuellen Stand zurueckgeben statt zu scheitern
const current = await this.git.log({ maxCount: 1 });
return current.latest?.hash || '';
}
await this.git.commit(message);
await this.git.push('origin', 'main');
@@ -118,16 +59,7 @@ export class GitService {
}
async reset() {
// Nur zuruecksetzen wenn wirklich ein Repo da ist - sonst laeuft git
// im Prozess-Arbeitsverzeichnis und raeumt dort auf
if (!existsSync(path.join(this.workspaceDir, '.git'))) {
return;
}
const git = simpleGit(this.workspaceDir);
await git.reset(['--hard', 'HEAD']);
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
const excludes = UPLOAD_DIRS.flatMap((dir) => ['-e', dir.split(path.sep).join('/')]);
await git.clean('f', ['-d', ...excludes]);
await this.git.reset(['--hard', 'HEAD']);
await this.git.clean('f', ['-d']);
}
}
-128
View File
@@ -1,128 +0,0 @@
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);
}
}
@@ -1,54 +0,0 @@
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
@@ -1,161 +0,0 @@
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;
}
-38
View File
@@ -1,38 +0,0 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
environment:
- BACKEND_URL=http://proxy:4321
depends_on:
- backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- ./backend/.env.local
environment:
- NODE_ENV=production
- PORT=8080
- DATABASE_PATH=/app/data/gallus_cms.db
- GIT_WORKSPACE_DIR=/app/workspace
volumes:
- backend_data:/app/data
- backend_workspace:/app/workspace
proxy:
build:
context: .
dockerfile: Dockerfile.caddy
depends_on:
- frontend
- backend
ports:
- "4321:80"
volumes:
backend_data:
backend_workspace:
-7
View File
@@ -9,9 +9,6 @@ kill_timeout = 5
[env]
PORT = "3000"
NODE_ENV = "production"
BACKEND_PORT = "8080"
DATABASE_PATH = "/app/data/db/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
[http_service]
internal_port = 3000
@@ -43,7 +40,3 @@ kill_timeout = 5
memory = "512MB"
cpu_kind = "shared"
cpus = 1
[[mounts]]
source = "gallus_data"
destination = "/app/data"
+2613 -2020
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,6 +9,6 @@
"astro": "astro"
},
"dependencies": {
"astro": "^7.2.1"
"astro": "^5.12.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

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: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="f68ef4028f"><path d="M 47.203125 40 L 322 40 L 322 335 L 47.203125 335 Z M 47.203125 40 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#f68ef4028f)"><path fill="#1d6b32" d="M 48.097656 139.574219 C 48.097656 128.285156 48.660156 119.429688 55.417969 109.972656 C 60.960938 102.21875 68.625 96.976562 75.371094 90.4375 C 90.03125 76.242188 102.058594 52.597656 122.386719 45.964844 C 140.441406 40.078125 161.71875 50.683594 170.175781 67.148438 C 174.488281 75.5625 175.570312 84.835938 176.609375 94.097656 C 178.023438 106.769531 179.671875 119.417969 181.199219 132.078125 C 186.097656 127.304688 184.324219 113.089844 184.800781 106.679688 C 185.414062 98.355469 185.589844 89.773438 187.167969 81.554688 C 190.136719 66.023438 202.230469 53.867188 215.535156 46.148438 C 230.378906 37.53125 245.867188 37.894531 259.476562 48.902344 C 265.457031 53.734375 269.027344 59.921875 273.558594 65.980469 C 278.105469 72.058594 283.742188 77.164062 289.878906 81.59375 C 301.03125 89.632812 313.222656 97.105469 314.707031 111.992188 C 315.859375 123.574219 312.664062 136.851562 306.261719 146.585938 C 298.824219 157.886719 283.566406 165.484375 271.140625 169.84375 C 263.703125 172.453125 256.171875 173.589844 248.375 174.402344 C 243.988281 174.855469 239.605469 175.347656 235.230469 175.863281 C 233.320312 176.085938 220.464844 177.1875 222.21875 178.914062 C 223.414062 180.097656 232.449219 178.90625 234.25 178.929688 C 238.816406 178.984375 243.378906 179.058594 247.9375 179.101562 C 258.195312 179.199219 268.472656 179.238281 278.703125 179.984375 C 294.078125 181.101562 308.125 186.960938 316.167969 200.828125 C 324.128906 214.566406 322.324219 235.332031 313.648438 248.554688 C 309.550781 254.800781 302.960938 258.933594 298.382812 264.773438 C 292.863281 271.808594 289.753906 280.417969 284.703125 287.777344 C 277.070312 298.902344 265.578125 308.597656 251.328125 307.664062 C 237.253906 306.738281 223.84375 295.984375 214.992188 285.644531 C 204.679688 273.609375 202.425781 260.40625 198.433594 245.617188 C 197.992188 243.980469 196.5 234.824219 195.105469 234.082031 C 192.15625 232.503906 193.15625 242.25 193.140625 243.652344 C 193.039062 252.289062 193.183594 260.898438 194.160156 269.492188 C 195.984375 285.542969 200.703125 301.367188 211.675781 313.625 C 215.640625 318.050781 226.738281 326.539062 219.757812 333.71875 C 216.042969 337.535156 203.546875 329.980469 199.726562 326.988281 C 169.355469 303.21875 175.738281 257.648438 175.738281 223.953125 C 158.667969 247.144531 172.769531 289.511719 141.984375 304.472656 C 130.417969 310.089844 113.792969 310.679688 102.148438 305.070312 C 88.4375 298.476562 81.277344 282.652344 73.429688 270.453125 C 69.234375 263.933594 63.84375 258.296875 58.707031 252.453125 C 55.953125 249.324219 52.542969 246.261719 50.21875 242.816406 C 47.457031 238.734375 47.503906 233.292969 48.097656 228.109375 C 49.5 215.78125 54.359375 203.136719 63.367188 194.40625 C 72.792969 185.277344 84.792969 181.804688 97.621094 181.160156 C 106.09375 180.734375 114.582031 180.5625 123.0625 180.191406 C 127.40625 180.003906 131.675781 179.730469 135.988281 179.113281 C 141.285156 178.359375 145.921875 177.105469 138.335938 175.230469 C 109.652344 168.167969 59.332031 176.03125 48.097656 139.574219 C 48.097656 135.066406 52.933594 155.265625 48.097656 139.574219 " fill-opacity="1" fill-rule="nonzero"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.
Binary file not shown.
-40
View File
@@ -1,40 +0,0 @@
---
// src/components/Banner.astro
import "../styles/components/Banner.css"
---
<div id="banner-container"></div>
<script>
const API_BASE = 'https://cms.gallus-pub.ch';
async function loadBanner() {
try {
const response = await fetch(`${API_BASE}/api/banners/active`);
if (response.ok) {
const data = await response.json();
if (data.banner) {
const container = document.getElementById('banner-container');
if (container) {
container.innerHTML = `
<div class="banner-wrapper">
<div class="banner container">
<p>${data.banner.text}</p>
</div>
</div>
`;
}
}
}
} catch (error) {
console.error('Failed to fetch banner:', error);
}
}
// Load banner when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadBanner);
} else {
loadBanner();
}
</script>
+9 -8
View File
@@ -7,19 +7,20 @@ const { id } = Astro.props;
<h2 class="title">Drinks</h2>
<p class="note">
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.
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. Natürlich dürfen auch Cocktails nicht fehlen. Vieles kreieren wir auch selber - Sláinte!
</p>
<a href="/pdf/gallus-getraenkekarte-2026.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<a href="/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="mit oder ohne Schuss ;)">
<img src="/images/content/mit-oder-ohne-schuss.avif" alt="Monats Hit" class="circle-image" />
<div class="circle" title="Mate Vodka">
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>mit oder ohne Schuss ;)</div>
<div>Mate Vodka</div>
</div>
<p class="note">
@@ -28,15 +29,15 @@ const { id } = Astro.props;
<div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="/images/content/whiskey-1.avif" alt="Whiskey 1" class="circle-image" />
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="/images/content/whiskey-2.avif" alt="Whiskey 2" class="circle-image" />
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="/images/content/whiskey-3.avif" alt="Whiskey 3" class="circle-image" />
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
-1
View File
@@ -15,7 +15,6 @@ import "../styles/index.css"
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>Gallus Pub</title>
</head>
+19 -10
View File
@@ -13,32 +13,41 @@ const { id } = Astro.props;
<h2>Gallus Pub!</h2>
<p>
Bei uns ist jede*r willkommen ob jung oder alt, Rocker, Nerd, Meerjungfrau oder einfach du selbst. Unsere Türen stehen allen offen, die Spass und gute Gesellschaft suchen.
Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung
oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du
selbst. Unsere Türen stehen offen für alle, die Spass haben wollen
und gute Gesellschaft suchen!
</p>
<br />
<p><b>Unsere Highlights:</b></p>
<ul>
<li>
<b>Karaoke:</b> Von Mittwoch bis Samstag kannst du auf zwei Stockwerken deine Stimme zum Besten geben ganz ohne Perfektionsdruck, Hauptsache Spass. Du singst lieber im kleinen Rahmen?
Dann kannst du den Sangallerruum im 2.OG auch privat mieten.
<b>Karaoke:</b> Von Mittwoch bis Samstag kannst du deine
Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht
nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst
gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat
mieten.
</li>
<li>
<b>Pub Quiz:</b> Jeden Freitag ab 20:00 Uhr testet ihr euer Wissen in mehreren Runden. Jede Woche warten ein neues Thema und ein neuer Sieger der Herzen.
<b>Pub Quiz:</b> Jeden Freitag ab 20:00 Uhr testet ihr
euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues
Thema und einen neuen Champion.
</li>
<li>
<b>Getränke:</b> Geniesst frisches Guinness, Smithwicks, Gallus Old Style Ale, leckere Cocktails und ausgewählte Whiskys aus Schottland und Irland.
<b>Getränke:</b> Geniesst frisches Guinness, Smithwicks,
Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber
haben wir erlesene Sorten aus Schottland und Irland im Angebot.
</li>
</ul>
<p>
Wir freuen uns, euch bald bei uns zu begrüssen auf viele unvergessliche Abende!
</p>
<p>
Sabrina & Raphael
Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst
uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael
</p>
</div>
-2
View File
@@ -1,10 +1,8 @@
---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
---
<Layout>
<Banner />
<h1>Gallery</h1>
-2
View File
@@ -1,10 +1,8 @@
---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
---
<Layout>
<Banner />
<h1>Openings</h1>
-783
View File
@@ -1,783 +0,0 @@
---
const title = 'Admin';
---
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
{/*
is:global ist hier zwingend. Astro wuerde die Regeln sonst auf
[data-astro-cid-...] einschraenken, und dieses Attribut setzt es nur auf
Elemente, die zur Bauzeit im Template stehen. Die Karten und Bilder der
Listen entstehen aber erst zur Laufzeit per innerHTML - fuer die haette
kein einziger Selektor gegriffen.
*/}
<style is:global>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
h1, h2 { margin: 0.5rem 0; }
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
.muted { color: #666; }
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
.btn:hover { background: #444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 0.75rem; }
.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; }
/* 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; }
.drag-handle { cursor:grab; padding:.25rem .5rem; border:1px dashed #bbb; border-radius:6px; font-size:.85rem; }
.row-buttons { display:flex; gap:.5rem; margin-top:.5rem; }
</style>
</head>
<body>
<h1>Admin</h1>
<section>
<h2>Authentifizierung</h2>
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
<div class="row">
<a id="login-link" class="btn" href="https://cms.gallus-pub.ch/api/auth/gitea">Mit Gitea anmelden</a>
<button id="btn-relogin">Neu anmelden</button>
<button id="btn-logout">Abmelden</button>
</div>
</section>
<section id="sec-events" style="display:none">
<h2>Events verwalten</h2>
<div class="events-row">
<div class="card">
<h3>Neues Event</h3>
<label>Titel<input id="ev-title" /></label>
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
<button id="btn-create-ev">Event anlegen</button>
<div id="ev-create-msg" class="muted"></div>
</div>
<div class="card" style="min-width:380px;">
<h3>Liste</h3>
<div class="toolbar">
<span class="pill" id="lbl-order">Anzeige: automatisch nach Datum (neueste zuerst)</span>
<button id="btn-toggle-reorder">Reihenfolge bearbeiten</button>
<button id="btn-save-order" style="display:none">Reihenfolge speichern</button>
<span id="order-msg" class="muted"></span>
</div>
<div id="events-list" class="grid"></div>
</div>
</div>
</section>
<section id="sec-gallery" style="display:none">
<h2>Gallery verwalten</h2>
<div class="events-row">
<div class="card">
<h3>Neues Gallery-Bild</h3>
<label>Bild-Datei<input id="gal-file" type="file" accept="image/*" /></label>
<label>Alt-Text<input id="gal-alt" placeholder="Bildbeschreibung" /></label>
<button id="btn-create-gal">Bild hochladen</button>
<div id="gal-create-msg" class="muted"></div>
</div>
<div class="card" style="min-width:380px;">
<h3>Gallery-Liste</h3>
<div id="gallery-list" class="grid"></div>
</div>
</div>
</section>
<section id="sec-banner" style="display:none">
<h2>Banner verwalten</h2>
<div class="events-row">
<div class="card">
<h3>Neuen Banner erstellen</h3>
<label>Text<textarea id="banner-text" rows="3" placeholder="z.B. Wir sind vom 24.12. bis 02.01. geschlossen"></textarea></label>
<label>Von (Datum)<input id="banner-start" type="date" /></label>
<label>Bis (Datum)<input id="banner-end" type="date" /></label>
<button id="btn-create-banner">Banner erstellen</button>
<div id="banner-create-msg" class="muted"></div>
</div>
<div class="card" style="min-width:380px;">
<h3>Banner-Liste</h3>
<div id="banner-list" class="grid"></div>
</div>
</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>
<button id="btn-publish">Publish</button>
<div id="pub-status" class="muted"></div>
</section>
<script>
// Base-URL des Backends (separate Subdomain)
const API_BASE = 'https://cms.gallus-pub.ch';
const api = async (path, opts = {}) => {
const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get('content-type') || '';
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');
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
// UI-Bereiche für eingeloggte Nutzer einblenden
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');
el.textContent = 'Nicht angemeldet';
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
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';
}
}
// Fallback: falls der Link von Browser/Extensions blockiert wäre
const loginLink = document.getElementById('login-link');
loginLink.addEventListener('click', (e) => {
try {
// Stelle sicher, dass Navigieren erzwungen wird
window.location.assign(API_BASE + '/api/auth/gitea');
} catch {}
});
document.getElementById('btn-relogin').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
document.cookie = 'token=; Path=/; Max-Age=0;';
window.location.assign(API_BASE + '/api/auth/gitea');
});
document.getElementById('btn-logout').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
document.cookie = 'token=; Path=/; Max-Age=0;';
await refreshAuth();
});
// ========== Events & Publish ==========
// Wichtig: Textfelder IMMER vor der Datei anhaengen. Der Server liest
// sie beim Empfang der Datei aus; was danach kommt, ist zu spaet.
async function uploadEventImage(file, title) {
const fd = new FormData();
fd.append('title', title || '');
fd.append('file', file);
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function uploadGalleryImage(file, altText) {
const fd = new FormData();
if (altText) fd.append('altText', altText);
fd.append('displayOrder', '0');
fd.append('file', file);
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
let reorderMode = false;
let lastEvents = [];
function parseDateSafe(s){
const d = new Date(s);
return isNaN(+d) ? new Date(0) : d;
}
async function loadEvents() {
const listEl = document.getElementById('events-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/events');
listEl.innerHTML = '';
// Merken, globale Liste aktualisieren
lastEvents = (data.events || []).slice();
let renderList = lastEvents.slice();
if (!reorderMode) {
// Automatisch nach Datum sortieren (neueste zuerst)
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
} else {
// Nach displayOrder aufsteigend
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
}
renderList.forEach((ev, idx) => {
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('draggable', String(reorderMode));
card.dataset.id = ev.id;
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
card.innerHTML = `
<div class="row" style="justify-content:space-between;align-items:center">
<div><strong>${esc(ev.title)}</strong></div>
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
</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>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Event wirklich löschen?')) return;
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
})
})
if (reorderMode) {
enableDragAndDrop(listEl);
}
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
// Drag & Drop Reorder
function enableDragAndDrop(container){
let draggingEl = null;
container.querySelectorAll('.card').forEach(card => {
card.addEventListener('dragstart', (e) => {
draggingEl = card; card.classList.add('dragging');
e.dataTransfer.setData('text/plain', card.dataset.id || '');
});
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
card.addEventListener('dragover', (e) => { e.preventDefault(); });
card.addEventListener('drop', (e) => {
e.preventDefault();
const target = card;
if (!draggingEl || draggingEl === target) return;
const cards = Array.from(container.querySelectorAll('.card'));
const draggingIdx = cards.indexOf(draggingEl);
const targetIdx = cards.indexOf(target);
if (draggingIdx < targetIdx) {
target.after(draggingEl);
} else {
target.before(draggingEl);
}
});
});
}
document.getElementById('btn-create-ev').addEventListener('click', async () => {
const title = (document.getElementById('ev-title')).value.trim();
const date = (document.getElementById('ev-date')).value.trim();
const desc = (document.getElementById('ev-desc')).value.trim();
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
const msg = document.getElementById('ev-create-msg');
msg.textContent = 'Lade Bild hoch...';
try {
let imageUrl = '';
if (file) {
// Titel mitgeben, damit die Datei danach benannt wird
const up = await uploadEventImage(file, title);
imageUrl = up?.imageUrl || '';
}
msg.textContent = 'Lege Event an...';
await api('/api/events', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, date, description: desc, imageUrl, displayOrder: 0, isPublished: true })
});
msg.textContent = 'Event erstellt';
(document.getElementById('ev-title')).value = '';
(document.getElementById('ev-date')).value = '';
(document.getElementById('ev-desc')).value = '';
(document.getElementById('ev-file')).value = '';
await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
document.getElementById('btn-publish').addEventListener('click', async () => {
const s = document.getElementById('pub-status');
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
s.textContent = 'Veröffentliche...';
try {
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
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 }
});
// Toggle Reorder
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
reorderMode = !reorderMode;
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
await loadEvents();
});
// Save Order
document.getElementById('btn-save-order').addEventListener('click', async () => {
const container = document.getElementById('events-list');
const cards = Array.from(container.querySelectorAll('.card'));
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
const msg = document.getElementById('order-msg');
msg.textContent = 'Speichere Reihenfolge...';
try {
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
msg.textContent = 'Reihenfolge gespeichert';
// Bleibe in Reorder-Mode oder verlasse ihn? Hier: verlassen
reorderMode = false;
document.getElementById('btn-save-order').style.display = 'none';
await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
// ========== Gallery ==========
async function loadGallery() {
const listEl = document.getElementById('gallery-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/gallery');
listEl.innerHTML = '';
const galleryImages = (data.images || []).slice();
galleryImages.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
galleryImages.forEach((img) => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<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>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Bild wirklich löschen?')) return;
try { await api(`/api/gallery/${id}`, { method: 'DELETE' }); await loadGallery(); } catch(e){ alert('Fehler: '+e.message); }
})
})
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
document.getElementById('btn-create-gal').addEventListener('click', async () => {
const file = /** @type {HTMLInputElement} */ (document.getElementById('gal-file')).files[0];
const alt = (document.getElementById('gal-alt')).value.trim();
const msg = document.getElementById('gal-create-msg');
if (!file) {
msg.textContent = 'Bitte Datei auswählen';
return;
}
msg.textContent = 'Lade Bild hoch...';
try {
await uploadGalleryImage(file, alt);
msg.textContent = 'Bild hochgeladen';
(document.getElementById('gal-file')).value = '';
(document.getElementById('gal-alt')).value = '';
await loadGallery();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
// ========== Banner ==========
async function loadBanners() {
const listEl = document.getElementById('banner-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/banners');
listEl.innerHTML = '';
const bannersList = (data.banners || []).slice();
bannersList.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
bannersList.forEach((banner) => {
const card = document.createElement('div');
card.className = 'card';
const statusText = banner.isActive ? '✓ Aktiv' : '✗ Inaktiv';
card.innerHTML = `
<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>
<button data-id="${banner.id}" class="btn-del-banner">Löschen</button>
</div>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-toggle-banner').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
const banner = bannersList.find(b => b.id === id);
if (!banner) return;
try {
await api(`/api/banners/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: banner.text,
startDate: banner.startDate,
endDate: banner.endDate,
isActive: !banner.isActive
})
});
await loadBanners();
} catch(e){ alert('Fehler: '+e.message); }
})
});
listEl.querySelectorAll('.btn-del-banner').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Banner wirklich löschen?')) return;
try { await api(`/api/banners/${id}`, { method: 'DELETE' }); await loadBanners(); } catch(e){ alert('Fehler: '+e.message); }
})
})
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
document.getElementById('btn-create-banner').addEventListener('click', async () => {
const text = (document.getElementById('banner-text')).value.trim();
const startDate = (document.getElementById('banner-start')).value.trim();
const endDate = (document.getElementById('banner-end')).value.trim();
const msg = document.getElementById('banner-create-msg');
if (!text || !startDate || !endDate) {
msg.textContent = 'Bitte alle Felder ausfüllen';
return;
}
msg.textContent = 'Erstelle Banner...';
try {
await api('/api/banners', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, startDate, endDate, isActive: true })
});
msg.textContent = 'Banner erstellt';
(document.getElementById('banner-text')).value = '';
(document.getElementById('banner-start')).value = '';
(document.getElementById('banner-end')).value = '';
await loadBanners();
} 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, name) {
const fd = new FormData();
fd.append('name', name || '');
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, name) {
const file = byId(inputId).files[0];
if (!file) return current;
const up = await uploadContentImage(file, name);
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, 'willkommen');
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 specialName = byId('dr-special-name').value.trim();
const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage, specialName || 'monats-hit');
const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1, 'whiskey-1');
const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2, 'whiskey-2');
const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3, 'whiskey-3');
// PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt
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>
</html>
-29
View File
@@ -1,29 +0,0 @@
---
const title = 'Anmeldung wird abgeschlossen...';
---
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
</head>
<body>
<p>{title}</p>
<script>
(function(){
try {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (token) {
const secure = window.location.protocol === 'https:';
document.cookie = `token=${encodeURIComponent(token)}; Path=/; Max-Age=${60*60*24}; SameSite=Lax; ${secure ? 'Secure' : ''}`.trim();
}
} catch(e) {
console.error('Failed to process OAuth token', e);
}
window.location.replace('/admin');
})();
</script>
</body>
</html>
+44 -63
View File
@@ -1,109 +1,90 @@
---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
import Hero from "../components/Hero.astro";
import Welcome from "../components/Welcome.astro";
import EventsGrid from "../components/EventsGrid.astro";
import Drinks from "../components/Drinks.astro";
import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
const events = [
{
image: "/images/events/mmnm5yob-w9xjor.jpeg",
image: "/images/events/event_karaoke.jpg",
title: "Karaoke",
date: "2026-03-12",
date: "Mittwoch - Samstag",
description: `
Du singst gerne, aber lieber für dich? Dann kannst du den Sangallerruum im 2OG auch privat mieten. (Optimal für 10-20Pers). Mehr Info's🍀via WA 077 232 27 70
Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/events/mj67800i-6ng82x.jpeg",
image: "/images/events/event_pub-quiz.jpg",
title: "Pub Quiz",
date: "2025-12-02",
date: "Jeden Freitag",
description: `
Jeden Freitag 20:00Uhr-ca 21:30Uhr.
Plätze sind begrenzt! Jetzt reservieren unter 🍀WA 077 232 27 70
Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
Auch Einzelpersonen sind herzlich willkommen! <br>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
`,
},
{
image: "/images/events/noeg.avif",
title: "NOeg",
date: "2026-09-16",
image: "/images/events/event_schlager-karaoke.jpeg",
title: "Schlager Hüttenzauber Karaoke",
date: "27. November - 19:00 Uhr",
description: `
🎤 Singe die größten Hits auf Spanisch, Italienisch, Französisch, Türkisch oder vielleicht sogar Japanisch?!
✨ Lass das Gallus zum Leben erwachen bei unserer multilingualen Karaoke-Nacht!
Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/events/yutaro.avif",
title: "Yutaro",
date: "2026-09-17",
image: "/images/events/event_advents-kalender.jpeg",
title: "Adventskalender",
date: "03. Dezember - 20. Dezember 2025",
description: `
Mit seiner Gitarre und seiner markanten Stimme verbindet er Singer-Songwriter-Sound mit Einflüssen aus Rock, Blues und Progressive Rock. 🍀Reservationen🍀via WA 077 232 27 70
Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
`,
},
{
image: "/images/events/mrngoyyy-49rz7u.jpeg",
title: "Danielle&Nina",
date: "2026-09-24",
image: "/images/events/event_santa_karaoke.jpeg",
title: "Santa Karaoke-Party",
date: "06. Dezember 2025",
description: `
SOLD OUT! - First time on stage und das direkt im Gallus Pub!🎤✨
Danielle & Nina bringen Irish Folk & liebevolle Cover Songs.🍀Warteliste: WA 077 232 27 70
🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻 `,
},
{
image: "/images/events/event_ferien.jpeg",
title: "Weihnachtsferien",
date: "21. Dezember 2025 - 01. Januar 2026",
description: `
Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
`,
},
{
image: "/images/events/dirty-karaoke.avif",
title: "Dirty Karaoke",
date: "2026-10-07",
image: "/images/events/event_neujahrs-apero.jpeg",
title: "Neujahrs-Apero",
date: "02. Januar 2026 - 18:00-20:00 Uhr",
description: `
*Nicht für alle Ohren geeignet! Ob eindeutig zweideutig, hemmungslos daneben oder einfach nur zum Fremdschämen an diesem Abend gilt: Was gesungen wird, bleibt auf der Bühne.😉
`,
},
{
image: "/images/events/voices.avif",
title: "Voices",
date: "2026-10-22",
description: `
Ein Abend vol­ler Songs, Stim­men & Live Mu­sic.
Mit besonderen Stimmen, guter Stimmung und ganz viel musikalischer Energie. 🍀Reservationen🍀via WA 077 232 27 70
`,
},
{
image: "/images/events/disney-karaoke.avif",
title: "Disney Karaoke",
date: "2026-10-28",
description: `
Die nächste Disney Karaoke Night steht an und passend zur Gruselweek: wer mit Grusel-Kostüm kommt, kriegt einen Glitzer-Shot auf's Haus.🎤✨
`,
},
{
image: "/images/events/katy-elias.avif",
title: "Katy & Elias",
date: "2026-11-21",
description: `
ACHTUNG Kurz-Konzert 19:30-20:30! Danach gehts für die beiden direkt weiter zu einem zweiten Konzert in der Lukas Bar in St. Georgen.
🍀Reservationen🍀via WA 077 232 27 70
`,
}
];
const images = [
{ src: "/images/gallery/mndhtjvz-965yra.jpeg", alt: "mndhtjvz-965yra.jpeg" },
{ src: "/images/gallery/mndi7upa-qxukqg.jpeg", alt: "mndi7upa-qxukqg.jpeg" },
{ src: "/images/gallery/mndif8nv-aptjf6.jpeg", alt: "mndif8nv-aptjf6.jpeg" },
{ src: "/images/gallery/halloween.avif", alt: "Halloween" },
{ src: "/images/gallery/sangallerruum.avif", alt: "Sangallerruum" },
{ src: "/images/gallery/welovegalluspub.avif", alt: "WeLoveGallusPub" },
{ src: "/images/gallery/raphi-sabrina.jpeg", alt: "Raphi&Sabrina" },
{ src: "/images/gallery/pubquiz.avif", alt: "PubQuiz" }
{ src: "/images/gallery/Gallery7.png", alt: "Siebtes Bild" },
{ src: "/images/gallery/Gallery8.png", alt: "Achtes Bild" },
{ src: "/images/gallery/Gallery9.png", alt: "Neuntes Bild" },
{ src: "/images/gallery/Gallery6.png", alt: "Sechstes Bild" },
{ src: "/images/gallery/Gallery1.png", alt: "Erstes Bild" },
{ src: "/images/gallery/Gallery2.png", alt: "Zweites Bild" },
{ src: "/images/gallery/Gallery3.png", alt: "Drittes Bild" },
{ src: "/images/gallery/Gallery4.png", alt: "Viertes Bild" },
{ src: "/images/gallery/Gallery5.png", alt: "Fünftes Bild" },
];
---
<Layout>
<Hero id="hero" />
<Banner />
<Welcome id="welcome" />
<EventsGrid id="events" events={events} />
<ImageCarousel id="gallery" images={images} />
-26
View File
@@ -1,26 +0,0 @@
.banner-wrapper {
width: 100%;
background-color: var(--color-orange1);
padding: 1rem 0;
}
.banner {
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--padding-horizontal);
}
.banner p {
color: #000;
font-size: var(--font-size-small-medium);
font-weight: 600;
margin: 0;
text-align: center;
line-height: 1.4;
}
@media (max-width: 768px) {
.banner p {
font-size: var(--font-size-small);
}
}