Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7a4f8a02 | ||
|
|
60246f3941 | ||
|
|
2af70c238b | ||
|
|
30f4b9d8c5 | ||
|
|
390b016cc2 | ||
|
|
e9d7ce262d | ||
|
|
fdd3793ee1 | ||
|
|
88ad16c86f | ||
|
|
45ce268db6 | ||
|
|
c1ab18fd9f | ||
|
|
52d6457797 | ||
|
|
4c9be79282 | ||
|
|
324f0f706f | ||
|
|
f7a8c1816f | ||
|
|
d14914a453 | ||
|
|
2a1d56d2fc | ||
|
|
c8ed065486 | ||
|
|
8e6c1924b8 | ||
|
|
04dbd0bb0c | ||
|
|
9b697fbb05 | ||
|
|
48ae3d8166 | ||
|
|
4643ab9b59 | ||
|
|
7f744de577 | ||
|
|
9623abb44a | ||
|
|
d34c55a40b | ||
|
|
9064d58796 | ||
|
|
2ccf195769 | ||
|
|
37495cfca8 | ||
|
|
7097aa7336 | ||
|
|
0b7d3a45b6 | ||
|
|
43f529ad7c | ||
|
|
c026a98d1e | ||
|
|
090da72e6b | ||
|
|
94b1d2c3b4 | ||
|
|
dbe18e6704 | ||
|
|
387ef209ab | ||
|
|
3b27cbd194 | ||
|
|
61842ebc70 | ||
|
|
4e2418116f | ||
|
|
c1fd535549 | ||
|
|
78f5da9cff | ||
|
|
b283816713 | ||
|
|
c77bf3e757 | ||
|
|
36b2053642 | ||
|
|
c3898170fd | ||
|
|
4cc1b21c05 | ||
|
|
e41334a7cc | ||
|
|
47743e9239 | ||
|
|
d271378912 | ||
|
|
4cc6c4f210 | ||
|
|
fde4adfad5 | ||
|
|
3e530e0ac5 | ||
|
|
d8153ed619 | ||
|
|
3df25da009 | ||
|
|
a181993ed5 | ||
|
|
c289541cd5 | ||
|
|
c9d067b1e3 | ||
|
|
4533f6cc3d | ||
|
|
4f12ebaa9a | ||
|
|
a7d53ffe21 | ||
|
|
c723e4919d | ||
|
|
f8cbc60a60 | ||
|
|
feec8ed314 | ||
|
|
2b64a21f16 | ||
|
|
20feee84a6 | ||
|
|
bf7e38ba2d | ||
|
|
d0101b2974 | ||
|
|
75f0c41e5c | ||
|
|
ffadf378f9 | ||
|
|
5e7425cadf | ||
|
|
7a067f60ff | ||
|
|
980200f963 | ||
|
|
de278fab29 | ||
|
|
160d384143 | ||
|
|
f440cbb7f3 | ||
|
|
72faefc88d | ||
|
|
e4ada94390 | ||
|
|
4eab0e6dd2 | ||
|
|
6222d5f19c | ||
|
|
c45e054787 | ||
|
|
8eb2be8628 | ||
|
|
3aafda5f70 | ||
|
|
f27e9a0027 | ||
|
|
921d2527e0 | ||
|
|
901223fcd9 | ||
|
|
357d5ba077 | ||
|
|
5d0c0a0b17 | ||
|
|
cb483d8715 | ||
|
|
3fd5dcf6dd | ||
|
|
86c2e4e306 | ||
|
|
0f16b944bc | ||
|
|
10752a7337 | ||
|
|
901b6a11db | ||
|
|
10192e2627 | ||
|
|
b1d2f8b441 | ||
|
|
5215765588 | ||
|
|
c368be5a27 | ||
|
|
54de8e36e2 | ||
|
|
57d7d48d5d | ||
|
|
b2f04dc726 | ||
|
|
6ea6a58532 | ||
|
|
f00a2ef934 | ||
|
|
ccc5c028ba | ||
|
|
7c96a15c2e | ||
|
|
7bfb777a74 | ||
|
|
9c3b4be79d | ||
|
|
89640a3372 | ||
|
|
4ed0016be9 | ||
|
|
745888d01b | ||
|
|
febd5a886c | ||
|
|
25305c4aad | ||
|
|
0597c73690 | ||
|
|
0a2aa84a8c | ||
|
|
1120472af8 | ||
|
|
db3a38ed45 | ||
|
|
4f8feb8652 | ||
|
|
0c291079ff | ||
|
|
fe2f61cdc2 | ||
|
|
af4877300f | ||
|
|
4a103cf7d6 | ||
|
|
97e7f88906 | ||
|
|
807c56de5a | ||
|
|
8ca30ae5f3 | ||
|
|
8f1254840c | ||
|
|
8b2d00385a | ||
|
|
c55e274718 | ||
|
|
e9a95ccf8d | ||
|
|
b16ac76620 | ||
|
|
0e03b9dea9 | ||
|
|
da3a950a1a |
@@ -0,0 +1,29 @@
|
||||
# Ohne diese Datei wandert das komplette Repo zum Fly-Remote-Builder,
|
||||
# inklusive der ~50 MB Git-Historie und des gesamten Backends. Der
|
||||
# Frontend-Build braucht davon nichts.
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
|
||||
# Das Backend wird als eigene Fly-App aus backend/ heraus gebaut
|
||||
backend
|
||||
|
||||
# Nicht vom Frontend-Build verwendet
|
||||
.woodpecker.yml
|
||||
Dockerfile.caddy
|
||||
docker-compose.yml
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
MIGRATION_README.md
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -22,3 +22,4 @@ pnpm-debug.log*
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
/ai/
|
||||
|
||||
@@ -1,5 +1,106 @@
|
||||
steps:
|
||||
deploy:
|
||||
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:
|
||||
image: node:20
|
||||
environment:
|
||||
FLY_API_TOKEN:
|
||||
@@ -7,10 +108,122 @@ steps:
|
||||
commands:
|
||||
- curl -L https://fly.io/install.sh | sh
|
||||
- export PATH="$HOME/.fly/bin:$PATH"
|
||||
- flyctl deploy --config fly.toml --app gallus-pub
|
||||
|
||||
# aus backend/ heraus, damit Build-Kontext und Dockerfile stimmen
|
||||
- cd backend && flyctl deploy --app gallus-cms-backend --remote-only
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- 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
|
||||
@@ -5,9 +5,6 @@ COPY package*.json ./
|
||||
# Fallback to npm install if no lockfile is present
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
# Ensure CSS variables are present
|
||||
RUN mkdir -p public/styles
|
||||
RUN cp -r styles/* public/styles/ || true
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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
|
||||
@@ -45,3 +45,4 @@ 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
|
||||
|
||||
@@ -4,7 +4,9 @@ dist
|
||||
*.log
|
||||
.DS_Store
|
||||
/tmp
|
||||
/data
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
/data/*.db
|
||||
/data/*.db-wal
|
||||
/data/*.db-shm
|
||||
/data/workspace
|
||||
# Allow images to be committed
|
||||
!/data/images
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Prerequisites
|
||||
## Prerequisite
|
||||
|
||||
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
|
||||
2. Fly.io account: `flyctl auth login`
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
# Multi-stage build for Gallus CMS Backend
|
||||
|
||||
# Stage 1: Builder
|
||||
# Hier werden die nativen Module EINMAL uebersetzt. Vorher lief npm ci in
|
||||
# beiden Stages, better-sqlite3 wurde also doppelt kompiliert.
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules (better-sqlite3)
|
||||
# Nur fuer better-sqlite3 - fuer musl gibt es davon keine Fertigbauten.
|
||||
# sharp ab 0.33 bringt eigene Binaries samt libvips mit, vips-dev wird
|
||||
# dadurch nicht mehr gebraucht.
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Install dependencies
|
||||
# Eigener Layer: bleibt im Cache, solange sich das Lockfile nicht aendert
|
||||
COPY package*.json ./
|
||||
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
||||
RUN npm ci || npm install
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Dev-Abhaengigkeiten entfernen. Was uebrig bleibt, ist fertig uebersetzt
|
||||
# und wandert unveraendert ins Laufzeit-Image.
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
||||
# git fuer simple-git, sqlite fuer die CLI. Keine Build-Werkzeuge mehr -
|
||||
# die blieben vorher als Layer im Image liegen, auch nach dem apk del.
|
||||
RUN apk add --no-cache git sqlite
|
||||
|
||||
# Copy production dependencies from builder (already compiled native modules)
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
|
||||
COPY --from=builder /app/migrate-production.js ./migrate-production.js
|
||||
COPY --from=builder /app/data/images ./data/images
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/workspace /app/data
|
||||
|
||||
# Ensure proper permissions
|
||||
RUN chown -R node:node /app
|
||||
# Create directories and ensure proper permissions
|
||||
RUN mkdir -p /app/workspace /app/data && chown -R node:node /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
@@ -56,4 +58,4 @@ 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", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
||||
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"]
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -3,6 +3,8 @@ app = "gallus-cms-backend"
|
||||
primary_region = "ams"
|
||||
|
||||
[build]
|
||||
# Ensure Fly uses the Dockerfile in this backend directory
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
PORT = "8080"
|
||||
@@ -10,6 +12,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -9,12 +9,14 @@
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
|
||||
},
|
||||
"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",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
||||
@@ -2,14 +2,103 @@ 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 {
|
||||
// Check if users table exists (acts as a sentinel for initial setup)
|
||||
const tableCheck = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||
.get();
|
||||
|
||||
if (!tableCheck) {
|
||||
console.log('📝 Creating database schema...');
|
||||
|
||||
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 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 created successfully!');
|
||||
} else {
|
||||
console.log('✅ Database already initialized.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error initializing database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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(),
|
||||
@@ -60,3 +59,14 @@ export const publishHistory = sqliteTable('publish_history', {
|
||||
commitMessage: text('commit_message'),
|
||||
publishedAt: integer('published_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())`),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import multipart from '@fastify/multipart';
|
||||
import cookie from '@fastify/cookie';
|
||||
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';
|
||||
@@ -13,6 +17,8 @@ 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 {
|
||||
@@ -36,8 +42,24 @@ const fastify = Fastify({
|
||||
});
|
||||
|
||||
// Register plugins
|
||||
// Support multiple origins for CORS
|
||||
const allowedOrigins = env.CORS_ORIGIN.split(',').map(o => o.trim());
|
||||
fastify.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
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);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -57,6 +79,36 @@ fastify.register(multipart, {
|
||||
},
|
||||
});
|
||||
|
||||
// Serve static files (uploaded images, etc.) from persistent volume
|
||||
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||
// Muss existieren, sonst verweigert @fastify/static die Registrierung
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fastify.register(fastifyStatic, {
|
||||
root: dataDir,
|
||||
prefix: '/static/',
|
||||
decorateReply: false
|
||||
});
|
||||
|
||||
// Uploads liegen unter <workspace>/public/images. In der DB stehen sie als
|
||||
// /images/... - das ist die URL auf der publizierten Astro-Seite. Damit die
|
||||
// Admin-Oberflaeche dieselben Pfade verwenden kann, hier ebenso ausliefern.
|
||||
const imagesDir = path.join(dataDir, 'public', 'images');
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
fastify.register(fastifyStatic, {
|
||||
root: imagesDir,
|
||||
prefix: '/images/',
|
||||
decorateReply: false
|
||||
});
|
||||
|
||||
// Dasselbe fuer die hochgeladenen PDFs (Getraenkekarte)
|
||||
const pdfDir = path.join(dataDir, 'public', 'pdf');
|
||||
fs.mkdirSync(pdfDir, { recursive: true });
|
||||
fastify.register(fastifyStatic, {
|
||||
root: pdfDir,
|
||||
prefix: '/pdf/',
|
||||
decorateReply: false
|
||||
});
|
||||
|
||||
// Decorate fastify with authenticate method
|
||||
fastify.decorate('authenticate', authenticate);
|
||||
|
||||
@@ -67,6 +119,8 @@ 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 () => {
|
||||
@@ -99,6 +153,8 @@ 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}`);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { eq } from 'drizzle-orm';
|
||||
import { GiteaService } from '../services/gitea.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
// Use explicit JSON schema for Fastify route validation to avoid provider issues
|
||||
const callbackQueryJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['code', 'state'],
|
||||
@@ -31,9 +30,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||
reply.setCookie('oauth_state', state, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
|
||||
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
maxAge: 10 * 60, // 10 minutes
|
||||
});
|
||||
|
||||
@@ -122,11 +120,12 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||
);
|
||||
|
||||
// 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: 'lax',
|
||||
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
||||
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
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { contentSections } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { saveUploadedImage } from '../services/upload.service.js';
|
||||
import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js';
|
||||
|
||||
// Fastify JSON schema for content section body
|
||||
const contentBodyJsonSchema = {
|
||||
@@ -78,6 +80,20 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Bilder, die durch die Aenderung herausgefallen sind, wegraeumen
|
||||
const before = extractImageUrls((existing as any)?.contentJson);
|
||||
const after = extractImageUrls(result.contentJson);
|
||||
for (const url of before) {
|
||||
if (after.has(url)) continue;
|
||||
try {
|
||||
if (await dropImageIfUnused(url)) {
|
||||
fastify.log.info(`Removed unused content image ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.warn({ err }, 'Could not remove unused content image');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
section: result.sectionName,
|
||||
content: result.contentJson,
|
||||
@@ -85,6 +101,34 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Bild fuer einen Textbereich hochladen (Welcome-Bild, Monatshit, Whiskey)
|
||||
fastify.post('/content/upload', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const file = await (request as any).file();
|
||||
if (!file) {
|
||||
return reply.code(400).send({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const mime = file.mimetype as string | undefined;
|
||||
if (!mime || !mime.startsWith('image/')) {
|
||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||
}
|
||||
|
||||
const saved = await saveUploadedImage(file, 'content', fastify.log);
|
||||
|
||||
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
||||
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
return reply.code(413).send({ error: err.message });
|
||||
}
|
||||
fastify.log.error({ err }, 'Upload failed');
|
||||
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||
}
|
||||
});
|
||||
|
||||
// List all content sections
|
||||
fastify.get('/content', {
|
||||
preHandler: [fastify.authenticate],
|
||||
|
||||
@@ -2,6 +2,19 @@ import { FastifyPluginAsync } from 'fastify';
|
||||
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';
|
||||
|
||||
/** 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 = {
|
||||
@@ -36,7 +49,15 @@ const reorderBodyJsonSchema = {
|
||||
} as const;
|
||||
|
||||
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
// List all events (by displayOrder)
|
||||
// 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 };
|
||||
});
|
||||
|
||||
// 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 };
|
||||
@@ -61,16 +82,58 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as any;
|
||||
|
||||
const [previous] = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||
|
||||
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||
|
||||
// Ausgetauschtes Bild wegraeumen, sonst bleibt es fuer immer liegen
|
||||
if (previous && previous.imageUrl !== row.imageUrl) {
|
||||
await dropUnusedImage(fastify, previous.imageUrl, 'replaced event image');
|
||||
}
|
||||
|
||||
return { event: row };
|
||||
});
|
||||
|
||||
// Upload event image file (multipart)
|
||||
fastify.post('/events/upload', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
// Expect a single file field named "file"
|
||||
const file = await (request as any).file();
|
||||
if (!file) {
|
||||
return reply.code(400).send({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const mime = file.mimetype as string | undefined;
|
||||
if (!mime || !mime.startsWith('image/')) {
|
||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||
}
|
||||
|
||||
const saved = await saveUploadedImage(file, 'events', 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event
|
||||
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');
|
||||
|
||||
return { message: 'Event deleted successfully' };
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@ 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';
|
||||
|
||||
/** 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 = {
|
||||
@@ -18,7 +31,15 @@ const galleryBodyJsonSchema = {
|
||||
|
||||
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// List all gallery images
|
||||
// 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
|
||||
fastify.get('/gallery', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
@@ -54,6 +75,47 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
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();
|
||||
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', 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: {
|
||||
@@ -64,6 +126,8 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as any;
|
||||
|
||||
const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||
|
||||
const [updated] = await db
|
||||
.update(galleryImages)
|
||||
.set(data)
|
||||
@@ -74,6 +138,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
// Ausgetauschte Datei wegraeumen
|
||||
if (previous && previous.imageUrl !== updated.imageUrl) {
|
||||
await dropUnusedImage(fastify, previous.imageUrl, 'replaced gallery image');
|
||||
}
|
||||
|
||||
return { image: updated };
|
||||
});
|
||||
|
||||
@@ -92,6 +161,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
// Zugehoerige Bilddatei mitnehmen
|
||||
await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image');
|
||||
|
||||
return { message: 'Image deleted successfully' };
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../config/database.js';
|
||||
import { contentSections } from '../db/schema.js';
|
||||
import { AssetService } from '../services/asset.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const assets = new AssetService();
|
||||
|
||||
/**
|
||||
* Feste PDF-Plaetze. Die URL landet jeweils in einer Content-Section, damit
|
||||
* der Generator sie beim Publish in die Astro-Komponente schreiben kann.
|
||||
*/
|
||||
const PDF_SLOTS: Record<string, { section: string; field: string }> = {
|
||||
drinks: { section: 'drinks', field: 'pdfUrl' },
|
||||
};
|
||||
|
||||
/** contentJson kommt je nach Treiber als Objekt oder als String zurueck. */
|
||||
function asObject(value: any): Record<string, any> {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
const pdfRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// PDF fuer einen festen Platz hochladen und verlinken
|
||||
fastify.post('/pdf/:slot', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { slot } = request.params as { slot: string };
|
||||
const target = PDF_SLOTS[slot];
|
||||
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: `Unknown PDF slot "${slot}"` });
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await (request as any).file();
|
||||
if (!file) {
|
||||
return reply.code(400).send({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const mime = file.mimetype as string | undefined;
|
||||
const originalName = (file.filename as string | undefined) || '';
|
||||
if (mime !== 'application/pdf' && !originalName.toLowerCase().endsWith('.pdf')) {
|
||||
return reply.code(400).send({ error: 'Only PDF uploads are allowed' });
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if ((file.file as any)?.truncated) {
|
||||
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
|
||||
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
||||
}
|
||||
|
||||
// Inhalt gegenpruefen, damit nicht irgendetwas mit .pdf-Endung landet
|
||||
if (buffer.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||
return reply.code(400).send({ error: 'File is not a valid PDF' });
|
||||
}
|
||||
|
||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const stamp = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
const filename = `${stamp}-${rand}.pdf`;
|
||||
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
||||
|
||||
const pdfUrl = `/pdf/${filename}`;
|
||||
|
||||
// URL in der Content-Section hinterlegen
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(contentSections)
|
||||
.where(eq(contentSections.sectionName, target.section))
|
||||
.limit(1);
|
||||
|
||||
const previousContent = asObject((existing as any)?.contentJson);
|
||||
const previousUrl = previousContent[target.field];
|
||||
const content = { ...previousContent, [target.field]: pdfUrl };
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(contentSections)
|
||||
.set({ contentJson: content, updatedAt: new Date() })
|
||||
.where(eq(contentSections.sectionName, target.section));
|
||||
} else {
|
||||
await db
|
||||
.insert(contentSections)
|
||||
.values({ sectionName: target.section, contentJson: content });
|
||||
}
|
||||
|
||||
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
|
||||
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
|
||||
if (previousUrl && previousUrl !== pdfUrl) {
|
||||
try {
|
||||
if (assets.deletePdf(previousUrl)) {
|
||||
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.warn({ err }, 'Could not remove replaced PDF');
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(201).send({ pdfUrl, section: target.section });
|
||||
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'FST_REQ_FILE_TOO_LARGE') {
|
||||
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
|
||||
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
||||
}
|
||||
fastify.log.error({ err }, 'PDF upload failed');
|
||||
return reply.code(500).send({ error: 'Failed to upload PDF' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default pdfRoute;
|
||||
@@ -2,6 +2,8 @@ import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { GitService } from '../services/git.service.js';
|
||||
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||
import { AssetService } from '../services/asset.service.js';
|
||||
import { collectReferencedImageUrls } from '../services/image-refs.service.js';
|
||||
import { db } from '../config/database.js';
|
||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -34,6 +36,17 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
fastify.log.info('Git repository initialized');
|
||||
|
||||
// Verwaiste Uploads entfernen, bevor committet wird. Die Referenzliste
|
||||
// deckt bewusst alle Zeilen ab, auch unveroeffentlichte, und zusaetzlich
|
||||
// die Bildpfade aus den Textbereichen.
|
||||
const assetService = new AssetService();
|
||||
const referencedImages = await collectReferencedImageUrls();
|
||||
const removedImages = assetService.sweepOrphanedImages([...referencedImages]);
|
||||
|
||||
if (removedImages.length > 0) {
|
||||
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
|
||||
}
|
||||
|
||||
// Fetch all content from database
|
||||
const eventsData = await db
|
||||
.select()
|
||||
@@ -78,16 +91,23 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||
|
||||
// Record in history
|
||||
// Record in history. Der Push ist an dieser Stelle bereits durch -
|
||||
// ein Fehler im Protokoll darf die Veroeffentlichung nicht als
|
||||
// gescheitert melden und den Workspace zuruecksetzen.
|
||||
try {
|
||||
await db.insert(publishHistory).values({
|
||||
userId,
|
||||
commitHash,
|
||||
commitMessage,
|
||||
});
|
||||
} catch (historyError) {
|
||||
fastify.log.warn({ err: historyError }, 'Could not record publish history');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitHash,
|
||||
removedImages: removedImages.length,
|
||||
message: 'Changes published successfully',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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();
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
/**
|
||||
* Verwaltet die Dateien, die das CMS in den Git-Workspace schreibt.
|
||||
*
|
||||
* Grundregel: geloescht wird ausschliesslich, was das CMS selbst angelegt hat.
|
||||
* Uploads bekommen den Namen <base36-zeitstempel>-<6 zufaellige zeichen>.<ext>
|
||||
* (siehe events.ts / gallery.ts). Handgepflegte Assets aus dem Repo heissen
|
||||
* anders - event_karaoke.jpg, Welcome.png, Gallery1.webp - und werden dadurch
|
||||
* nie angefasst, auch wenn sie in keinem Datensatz mehr vorkommen.
|
||||
*/
|
||||
|
||||
// Unterordner unterhalb von public/, in denen das CMS aufraeumen darf
|
||||
const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
|
||||
const MANAGED_PDF_DIR = 'pdf';
|
||||
|
||||
// Namensmuster der CMS-Uploads
|
||||
const GENERATED_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
|
||||
|
||||
export class AssetService {
|
||||
private publicDir: string;
|
||||
|
||||
constructor() {
|
||||
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
|
||||
}
|
||||
|
||||
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb */
|
||||
private resolveInPublic(url: string): string | null {
|
||||
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
|
||||
|
||||
const relative = url.replace(/^\/+/, '').split('?')[0].split('#')[0];
|
||||
const absolute = path.resolve(this.publicDir, relative);
|
||||
|
||||
// Traversal-Schutz: muss unterhalb von public/ bleiben
|
||||
if (absolute !== this.publicDir && !absolute.startsWith(this.publicDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pfad einer Datei, die das CMS loeschen darf.
|
||||
* Liefert null fuer fremde Pfade und fuer handgepflegte Dateien.
|
||||
*/
|
||||
resolveDeletable(url: string, dirs: string[]): string | null {
|
||||
const absolute = this.resolveInPublic(url);
|
||||
if (!absolute) return null;
|
||||
|
||||
const relative = path.relative(this.publicDir, absolute);
|
||||
const dir = path.dirname(relative).split(path.sep).join('/');
|
||||
if (!dirs.includes(dir)) return null;
|
||||
|
||||
if (!GENERATED_NAME.test(path.basename(absolute))) return null;
|
||||
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/** Loescht ein hochgeladenes Bild. Gibt zurueck, ob wirklich etwas weg ist. */
|
||||
deleteImage(url: string): boolean {
|
||||
return this.unlink(this.resolveDeletable(url, MANAGED_IMAGE_DIRS));
|
||||
}
|
||||
|
||||
/** Loescht ein hochgeladenes PDF. */
|
||||
deletePdf(url: string): boolean {
|
||||
return this.unlink(this.resolveDeletable(url, [MANAGED_PDF_DIR]));
|
||||
}
|
||||
|
||||
private unlink(absolute: string | null): boolean {
|
||||
if (!absolute) return false;
|
||||
try {
|
||||
fs.unlinkSync(absolute);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alle hochgeladenen Bilder, die in keiner der uebergebenen URLs
|
||||
* mehr vorkommen. Die Liste muss ALLE Datensaetze abdecken, auch
|
||||
* unveroeffentlichte - sonst verlieren die ihr Bild.
|
||||
*/
|
||||
sweepOrphanedImages(referencedUrls: string[]): string[] {
|
||||
const keep = new Set<string>();
|
||||
for (const url of referencedUrls) {
|
||||
const absolute = this.resolveInPublic(url);
|
||||
if (absolute) keep.add(absolute);
|
||||
}
|
||||
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const dir of MANAGED_IMAGE_DIRS) {
|
||||
const absoluteDir = path.join(this.publicDir, dir);
|
||||
if (!fs.existsSync(absoluteDir)) continue;
|
||||
|
||||
for (const name of fs.readdirSync(absoluteDir)) {
|
||||
const absolute = path.join(absoluteDir, name);
|
||||
|
||||
if (!GENERATED_NAME.test(name)) continue;
|
||||
if (keep.has(absolute)) continue;
|
||||
if (!fs.statSync(absolute).isFile()) continue;
|
||||
|
||||
fs.unlinkSync(absolute);
|
||||
removed.push('/' + dir + '/' + name);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,27 @@ export class FileGeneratorService {
|
||||
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte aus dem Adminbereich landen direkt im Astro-Markup. Ohne Maskierung
|
||||
* reicht ein "<" oder "&" in einem Feld, damit der Build der Seite scheitert
|
||||
* und der Deploy stehen bleibt.
|
||||
*/
|
||||
escapeHtml(value: any): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Wie escapeHtml, aber mit Rueckfallwert wenn nichts gesetzt ist. */
|
||||
text(value: any, fallback = ''): string {
|
||||
const raw = value === undefined || value === null || value === '' ? fallback : value;
|
||||
return this.escapeHtml(raw);
|
||||
}
|
||||
|
||||
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||
const eventsCode = events.map(e => `\t{
|
||||
\t\timage: "${e.imageUrl}",
|
||||
@@ -43,6 +64,7 @@ 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";
|
||||
@@ -62,6 +84,7 @@ ${imagesCode}
|
||||
|
||||
<Layout>
|
||||
\t<Hero id="hero" />
|
||||
\t<Banner />
|
||||
\t<Welcome id="welcome" />
|
||||
\t<EventsGrid id="events" events={events} />
|
||||
\t<ImageCarousel id="gallery" images={images} />
|
||||
@@ -84,9 +107,9 @@ const { id } = Astro.props;
|
||||
|
||||
\t\t<div class="hero-content">
|
||||
|
||||
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
||||
\t\t\t<h1>${this.text(content.heading, 'Dein Irish Pub')}</h1>
|
||||
|
||||
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
||||
\t\t\t<p>${this.text(content.subheading, 'Im Herzen von St.Gallen')}</p>
|
||||
|
||||
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||
\t\t</div>
|
||||
@@ -103,7 +126,7 @@ const { id } = Astro.props;
|
||||
|
||||
generateWelcomeComponent(content: ContentSection): string {
|
||||
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
||||
`\t\t\t<li>\n\t\t\t\t<b>${this.escapeHtml(h?.title)}:</b> ${this.escapeHtml(h?.description)}\n\t\t\t</li>`
|
||||
).join('\n\n');
|
||||
|
||||
return `---
|
||||
@@ -117,11 +140,11 @@ const { id } = Astro.props;
|
||||
|
||||
\t<div class="welcome-text">
|
||||
|
||||
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
||||
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
||||
\t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
|
||||
\t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.introText || ''}
|
||||
\t\t\t${this.text(content.introText)}
|
||||
\t\t</p>
|
||||
|
||||
\t\t<p><b>Unsere Highlights:</b></p>
|
||||
@@ -131,14 +154,14 @@ ${highlightsList}
|
||||
\t\t</ul>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.closingText || ''}
|
||||
\t\t\t${this.text(content.closingText)}
|
||||
\t\t</p>
|
||||
|
||||
\t</div>
|
||||
|
||||
|
||||
\t<div class="welcome-image">
|
||||
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
||||
\t\t<img src="${this.text(content.imageUrl, '/images/Welcome.png')}" alt="Welcome background image" />
|
||||
\t</div>
|
||||
|
||||
</section>
|
||||
@@ -155,36 +178,36 @@ const { id } = Astro.props;
|
||||
<h2 class="title">Drinks</h2>
|
||||
|
||||
<p class="note">
|
||||
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
||||
${this.text(content.introText, 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.')}
|
||||
</p>
|
||||
|
||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
<a href="${this.text(content.pdfUrl, '/pdf/Getraenke_Gallus_2025.pdf')}" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
|
||||
<h3 class="monats-hit">Monats Hit</h3>
|
||||
|
||||
<div class="mate-vodka">
|
||||
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
||||
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
||||
<div class="circle" title="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
|
||||
<img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
||||
<div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
||||
${this.text(content.whiskeyText, 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.')}
|
||||
</p>
|
||||
|
||||
<div class="circle-row">
|
||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
||||
<img src="${this.text(content.whiskeyImage1, '/images/Whiskey1.png')}" alt="Whiskey 1" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
||||
<img src="${this.text(content.whiskeyImage2, '/images/Whiskey2.png')}" alt="Whiskey 2" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
||||
<img src="${this.text(content.whiskeyImage3, '/images/Whiskey3.png')}" alt="Whiskey 3" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,94 @@
|
||||
import simpleGit, { SimpleGit } from 'simple-git';
|
||||
import { mkdir, rm } from 'fs/promises';
|
||||
import { mkdir, rm, cp } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
// Verzeichnisse, in die das CMS hochlaedt. Die muessen einen Neu-Clone des
|
||||
// Workspace ueberleben, sonst sind Bilder und Getraenkekarte weg.
|
||||
const UPLOAD_DIRS = [
|
||||
path.join('public', 'images'),
|
||||
path.join('public', 'pdf'),
|
||||
];
|
||||
|
||||
export class GitService {
|
||||
private git: SimpleGit;
|
||||
private workspaceDir: string;
|
||||
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() {
|
||||
// Ensure workspace directory exists
|
||||
await mkdir(this.workspaceDir, { recursive: true });
|
||||
// Elternverzeichnis muss existieren - simple-git startet git von dort aus
|
||||
await mkdir(this.parentDir, { recursive: true });
|
||||
|
||||
// Add token to repo URL for authentication
|
||||
const authenticatedUrl = this.repoUrl.replace(
|
||||
'https://',
|
||||
`https://oauth2:${this.token}@`
|
||||
`https://oauth2:${encodeURIComponent(this.token)}@`
|
||||
);
|
||||
|
||||
let usable = false;
|
||||
|
||||
if (existsSync(path.join(this.workspaceDir, '.git'))) {
|
||||
try {
|
||||
// Check if repo already exists
|
||||
await this.git.cwd(this.workspaceDir);
|
||||
this.git = simpleGit(this.workspaceDir);
|
||||
await this.git.status();
|
||||
console.log('Repository already exists, pulling latest...');
|
||||
await this.git.pull();
|
||||
} catch {
|
||||
// Clone if doesn't exist
|
||||
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) {
|
||||
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);
|
||||
await this.git.cwd(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 });
|
||||
}
|
||||
|
||||
// Configure git user
|
||||
@@ -47,6 +98,14 @@ 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');
|
||||
|
||||
@@ -59,7 +118,16 @@ export class GitService {
|
||||
}
|
||||
|
||||
async reset() {
|
||||
await this.git.reset(['--hard', 'HEAD']);
|
||||
await this.git.clean('f', ['-d']);
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { events, galleryImages, contentSections } from '../db/schema.js';
|
||||
import { AssetService } from './asset.service.js';
|
||||
|
||||
const assets = new AssetService();
|
||||
|
||||
/**
|
||||
* Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird.
|
||||
* Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden.
|
||||
*/
|
||||
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
const referenced = await collectReferencedImageUrls();
|
||||
if (referenced.has(url)) return false;
|
||||
return assets.deleteImage(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt jede Bild-URL, die irgendwo in der Datenbank vorkommt.
|
||||
*
|
||||
* Bewusst ueber ALLE Zeilen, nicht nur die veroeffentlichten - sonst wuerde
|
||||
* ein unveroeffentlichtes Event sein Bild verlieren, sobald jemand publisht.
|
||||
*
|
||||
* Die Content-Sections enthalten beliebiges JSON (Welcome-Bild, Monatshit,
|
||||
* Whiskey-Bilder), deshalb wird es rekursiv nach Bildpfaden durchsucht.
|
||||
*/
|
||||
export async function collectReferencedImageUrls(): Promise<Set<string>> {
|
||||
const urls = new Set<string>();
|
||||
|
||||
for (const row of (await db.select().from(events)) as any[]) {
|
||||
if (row.imageUrl) urls.add(row.imageUrl);
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
||||
if (row.imageUrl) urls.add(row.imageUrl);
|
||||
}
|
||||
|
||||
for (const row of (await db.select().from(contentSections)) as any[]) {
|
||||
collectFromJson(row.contentJson, urls);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
|
||||
export function extractImageUrls(value: any): Set<string> {
|
||||
const out = new Set<string>();
|
||||
collectFromJson(value, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectFromJson(value: any, out: Set<string>): void {
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('/images/')) {
|
||||
out.add(value);
|
||||
return;
|
||||
}
|
||||
// Je nach Treiber kommt das JSON als String zurueck
|
||||
if (value.startsWith('{') || value.startsWith('[')) {
|
||||
try {
|
||||
collectFromJson(JSON.parse(value), out);
|
||||
} catch {
|
||||
// kein JSON - ignorieren
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) collectFromJson(entry, out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
for (const entry of Object.values(value)) collectFromJson(entry, out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export type UploadSubdir = 'events' | 'gallery' | 'content';
|
||||
|
||||
export interface SavedImage {
|
||||
filename: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt einen Multipart-Upload entgegen, rechnet ihn nach WebP herunter und
|
||||
* legt ihn unter public/images/<subdir> im Git-Workspace ab.
|
||||
*
|
||||
* Der Dateiname folgt dem Muster <zeitstempel>-<zufall>.<ext>. Daran erkennt
|
||||
* der AssetService spaeter, dass er die Datei wieder loeschen darf.
|
||||
*/
|
||||
export async function saveUploadedImage(
|
||||
file: any,
|
||||
subdir: UploadSubdir,
|
||||
log?: { warn: (obj: any, msg: string) => void }
|
||||
): Promise<SavedImage> {
|
||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const inputBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Ohne diese Pruefung landet bei zu grossen Dateien ein abgeschnittenes,
|
||||
// kaputtes Bild auf der Platte
|
||||
if (file.file?.truncated) {
|
||||
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
|
||||
const error: any = new Error(`Image too large. Maximum is ${limit} MB`);
|
||||
error.statusCode = 413;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const stamp = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
let outBuffer: Buffer;
|
||||
let outExt = '.webp';
|
||||
|
||||
try {
|
||||
// Sharp erst laden wenn wirklich gebraucht
|
||||
const sharp = (await import('sharp')).default;
|
||||
outBuffer = await sharp(inputBuffer)
|
||||
.rotate()
|
||||
.resize({ width: 1600, withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
} catch (err) {
|
||||
log?.warn({ err }, 'Sharp processing failed, using original image');
|
||||
outBuffer = inputBuffer;
|
||||
const extFromMime = (file.mimetype || '').split('/')[1] || 'bin';
|
||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
const filename = `${stamp}-${rand}${outExt}`;
|
||||
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
|
||||
|
||||
return { filename, imageUrl: `/images/${subdir}/${filename}` };
|
||||
}
|
||||
@@ -4,14 +4,14 @@ kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile.fly"
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
PORT = "3000" # Caddy (serves frontend + proxies /api/*)
|
||||
PORT = "3000"
|
||||
NODE_ENV = "production"
|
||||
BACKEND_PORT = "8080" # Fastify backend will listen here
|
||||
DATABASE_PATH = "/app/data/gallus_cms.db"
|
||||
GIT_WORKSPACE_DIR = "/app/workspace"
|
||||
BACKEND_PORT = "8080"
|
||||
DATABASE_PATH = "/app/data/db/gallus_cms.db"
|
||||
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
@@ -47,7 +47,3 @@ kill_timeout = 5
|
||||
[[mounts]]
|
||||
source = "gallus_data"
|
||||
destination = "/app/data"
|
||||
|
||||
[[mounts]]
|
||||
source = "gallus_workspace"
|
||||
destination = "/app/workspace"
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 604 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 128 KiB |