106 Commits

Author SHA1 Message Date
387ef209ab Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-09 13:14:23 +00:00
3b27cbd194 chore(woodpecker): simplify audit file handling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Removed redundant `/tmp/` paths for audit result and output files.
- Ensured consistent file access in vulnerability checks and Discord notifications.
- Added workspace file listing for better debugging in case of missing audit results.
2026-01-07 16:47:03 +01:00
61842ebc70 refactor(woodpecker): improve commit message handling for payload preparation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Redirected commit messages to a temporary file to handle special characters safely.
- Adjusted jq payload process for better reliability.
2026-01-07 16:41:58 +01:00
4e2418116f feat(woodpecker): enhance npm audit and Discord notification steps
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Refined npm audit process to generate detailed JSON and text outputs.
- Improved Discord notifications with comprehensive vulnerability details and formatting.
- Replaced `apt-get` with `apk` for faster lightweight image handling.
2026-01-07 16:38:15 +01:00
c1fd535549 refactor(woodpecker): streamline Discord payload handling with temporary file usage
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Simplified payload preparation by redirecting commit messages to a temporary file.
- Ensured cleanup with `rm -f` for improved reliability and maintainability.
2026-01-07 16:34:25 +01:00
78f5da9cff feat(woodpecker): add Discord notifications for build status
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Implemented success and failure notifications using `jq` for secure payload formatting.
- Enhanced YAML to manage build alerts and improve CI visibility.
2026-01-07 16:32:00 +01:00
b283816713 refactor(schema): remove redundant comment from users table definition
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-07 16:27:58 +01:00
c77bf3e757 Update events 2026-01-07 15:03:52 +00:00
k
36b2053642 Add dependency audit step to CI and update package dependencies. 2026-01-07 12:35:54 +01:00
c3898170fd Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-02 11:46:37 +00:00
4cc1b21c05 Reorder components in file-generator.service.ts to display Hero above Banner
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-24 14:27:47 +01:00
e41334a7cc Reorder components in index.astro to display Hero above Banner 2025-12-24 14:27:16 +01:00
47743e9239 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-24 12:55:28 +00:00
d271378912 feat: Add Banner component to file generator output
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Integrated `Banner.astro` into generated file layout for consistent use across components.
2025-12-22 23:06:48 +01:00
4cc6c4f210 refactor(Banner): remove redundant debug logs in loadBanner function
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-22 23:01:25 +01:00
fde4adfad5 feat: Add Banner component across Gallery, Openings, and Homepage layouts
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Integrated `Banner.astro` into `Gallery.astro`, `Openings.astro`, and `index.astro` for consistent banner display.
2025-12-22 22:57:28 +01:00
3e530e0ac5 Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-22 22:44:39 +01:00
d8153ed619 feat(Banner): enhance loading logic and add detailed debug logs
- Updated `loadBanner` to wait for DOM readiness with `DOMContentLoaded` support.
- Added comprehensive debug logs for banner loading, response status, and DOM interactions.
2025-12-22 22:44:22 +01:00
3df25da009 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 13:21:25 +00:00
a181993ed5 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 12:59:40 +00:00
c289541cd5 refactor(cors): simplify origin validation logic in index.ts
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Streamlined CORS logic by consolidating `allowedOrigins` checks and improving readability.
- Updated callback invocations for consistency and clarity.
2025-12-18 13:54:41 +01:00
c9d067b1e3 feat: Support multiple CORS origins and enhance origin validation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Updated `fly.toml` to allow multiple CORS origins.
- Refactored CORS logic in `index.ts` to validate and support multiple origins, including handling requests with no origin.
2025-12-18 13:51:29 +01:00
4533f6cc3d Reorder components in index.astro to display Hero before Banner
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 13:30:01 +01:00
4f12ebaa9a Refactor: Update banner sorting logic to prioritize relevance
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 13:24:22 +01:00
a7d53ffe21 feat: Improve banner fetching logic and integrate Banner component
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Adjusted server logic in `/banners/active` to resolve timezone issues and ensure consistent date handling.
- Sorted active banners by creation date in descending order for better relevance.
- Integrated `Banner.astro` component into the homepage layout for displaying active banners.
2025-12-18 13:16:49 +01:00
c723e4919d chore: Remove outdated comment in auth route JSON schema definition
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-17 21:51:22 +01:00
f8cbc60a60 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-17 20:10:35 +00:00
feec8ed314 feat: Add backend routes and styles for banner management
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Introduced `banners.ts` with CRUD operations for managing banners.
- Added `/banners/active` endpoint to fetch active banners.
- Secured admin-only routes for banner creation, update, and deletion.
- Created `Banner.css` for banner styling.
2025-12-17 21:02:00 +01:00
2b64a21f16 feat: Add Banner component for fetching and displaying active banners
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Implemented `Banner.astro` component to retrieve active banners from the CMS.
- Integrated styling via `Banner.css`.
- Handles errors gracefully during banner fetch.
2025-12-17 20:59:23 +01:00
20feee84a6 Jetzt wird in der Event-Liste das Bild als Vorschau angezeigt mit dem Event-Titel als Alt-Text
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-17 20:54:20 +01:00
bf7e38ba2d feat: Add banner management feature and improve event/gallery image handling
- Introduced a new "Banners" feature, enabling banner creation, management, and display across the admin panel and frontend.
- Enhanced image handling for events and gallery by converting images to optimized webp format.
- Added `banners` table in the database schema for storing announcements.
- Integrated new `/api/banners` route in backend for banner operations.
- Updated `index.astro` to include banner display component.
- Added supporting UI and APIs in the admin panel for banner management.
2025-12-17 20:47:38 +01:00
d0101b2974 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-15 16:38:55 +00:00
75f0c41e5c Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-15 16:34:32 +00:00
ffadf378f9 Update events
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 16:32:41 +00:00
5e7425cadf Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 21:06:33 +00:00
7a067f60ff Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 21:03:20 +00:00
980200f963 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:50:25 +00:00
de278fab29 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:45:37 +00:00
160d384143 Update events
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-14 20:45:04 +00:00
f440cbb7f3 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:40:57 +00:00
72faefc88d Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:36:20 +00:00
e4ada94390 Update events
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-14 20:34:31 +00:00
4eab0e6dd2 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:31:47 +00:00
6222d5f19c Revert "images fixing with database saves"
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This reverts commit c45e054787.
2025-12-09 20:26:55 +01:00
c45e054787 images fixing with database saves
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 20:12:08 +01:00
8eb2be8628 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:42:28 +00:00
3aafda5f70 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:35:29 +00:00
f27e9a0027 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:27:38 +00:00
921d2527e0 Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:25:24 +01:00
901223fcd9 events and gallery backend fix 2025-12-09 19:25:17 +01:00
357d5ba077 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:22:09 +00:00
5d0c0a0b17 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:15:34 +00:00
cb483d8715 picture test
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:08:55 +01:00
3fd5dcf6dd picture test
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:05:37 +01:00
86c2e4e306 picture test
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:02:06 +01:00
0f16b944bc picture test 2025-12-09 19:02:06 +01:00
10752a7337 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:57:06 +00:00
901b6a11db Update events 2025-12-09 17:54:50 +00:00
10192e2627 feat: Add vips dependencies for sharp in backend Dockerfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Added `vips-dev` and `vips` to build and runtime dependencies.
- Updated installation process to ensure compilation
2025-12-09 18:37:30 +01:00
b1d2f8b441 feat: Add vips dependencies for sharp in backend Dockerfile
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Added `vips-dev` and `vips` to build and runtime dependencies.
- Updated installation process to ensure compilation
2025-12-09 18:35:57 +01:00
5215765588 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:31:37 +00:00
c368be5a27 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:27:33 +00:00
54de8e36e2 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:25:00 +00:00
57d7d48d5d Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:16:54 +00:00
b2f04dc726 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:13:20 +00:00
6ea6a58532 feat: Add vips dependencies for sharp in backend Dockerfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Added `vips-dev` and `vips` to build and runtime dependencies.
- Updated installation process to ensure compilation
2025-12-09 18:10:25 +01:00
f00a2ef934 feat: Add vips dependencies for sharp in backend Dockerfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Added `vips-dev` and `vips` to build and runtime dependencies.
- Updated installation process to ensure compilation
2025-12-09 18:06:26 +01:00
ccc5c028ba feat: Add vips dependencies for sharp in backend Dockerfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Added `vips-dev` and `vips` to build and runtime dependencies.
- Updated installation process to ensure compilation
2025-12-09 18:00:32 +01:00
7c96a15c2e feat: Add vips dependencies for sharp in backend Dockerfile
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Added `vips-dev` and `vips` to build and runtime dependencies.
- Updated installation process to ensure compilation of native modules during build.
2025-12-09 17:58:21 +01:00
7bfb777a74 feat: Add gallery management and dynamic API-based data loading
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Introduced a gallery management section in `admin.astro` for uploading, listing, and deleting gallery images.
- Added dynamic fetching of events and gallery images from the backend in `index.astro`.
- Updated authentication to handle gallery-related UI visibility and actions.
2025-12-09 17:42:27 +01:00
9c3b4be79d Add event image upload endpoint and refactor image upload handling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Introduced `/events/upload` endpoint for securely uploading and processing event images.
- Added image validation, resizing, and conversion to WebP with fallback support for original formats.
- Updated `uploadImage` to `uploadEventImage` and introduced `uploadGalleryImage` in `admin.astro`.
2025-12-09 17:34:06 +01:00
89640a3372 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 16:29:43 +00:00
4ed0016be9 Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 15:55:21 +00:00
745888d01b Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 16:51:32 +01:00
febd5a886c feat: Add production migration script for Fly.io deployment
- Create standalone migration script that works in production
- Include migration script and images in Docker build
- Images will be copied to /app/data/images on container start
- Can be run with: node migrate-production.js
2025-12-09 16:50:48 +01:00
25305c4aad Update events
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:50:36 +00:00
0597c73690 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 16:46:10 +01:00
0a2aa84a8c Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth. 2025-12-09 16:45:28 +01:00
1120472af8 feat: Migrate old events and gallery images to persistent storage
- Add migration script to convert and copy images
- Include 7 events (Karaoke, Pub Quiz, etc.) in WebP format
- Include 9 gallery images in WebP format
- Update .gitignore to allow images in data/images/
- Add migration documentation in MIGRATION_README.md

Images are stored in backend/data/images/ which maps to
the persistent Fly.io volume at /app/data/
2025-12-09 16:41:57 +01:00
db3a38ed45 Revert "Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth."
This reverts commit 4f8feb8652.
2025-12-09 16:36:16 +01:00
4f8feb8652 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is running
2025-12-09 15:56:55 +01:00
0c291079ff Test: Trigger Woodpecker CI 2025-12-09 15:56:10 +01:00
fe2f61cdc2 Simplify Woodpecker pipeline by consolidating when conditions and using secrets for Fly.io authentication 2025-12-09 15:55:51 +01:00
af4877300f Add public endpoints and refactor deployments
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Implemented public `/gallery/public` and `/events/public` endpoints for fetching published data without authentication.
- Updated persistent volume configuration for Fly.io across backend and static file serving.
- Adjusted frontend to dynamically fetch events and gallery images from backend API.
- Refined Woodpecker pipeline for clearer separation of backend and frontend deployments.
2025-12-09 15:53:39 +01:00
4a103cf7d6 Merge remote-tracking branch 'origin/main'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:17:25 +01:00
97e7f88906 Revert "ned soll endlich pushen"
This reverts commit 8ca30ae5f3.
2025-12-09 15:16:45 +01:00
807c56de5a Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:09:39 +00:00
8ca30ae5f3 ned soll endlich pushen
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:45:46 +01:00
8f1254840c anpassungen am woodpecker und fly .toml
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:36:31 +01:00
8b2d00385a test
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:03:36 +01:00
c55e274718 woodpecker soll nun auch das backend deployen
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 13:58:39 +01:00
e9a95ccf8d Implement cross-domain support for OAuth and API requests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Updated frontend to use `https://cms.gallus-pub.ch` as the API base URL.
- Configured cookies with `SameSite=None` and `Secure` for production in `auth.ts`.
- Enhanced `fly.toml` to include `FRONTEND_URL`, `CORS_ORIGIN`, and `GITEA_REDIRECT_URI`.
- Adjusted `.gitignore` to ignore `/ai/` directory.
2025-12-09 12:01:49 +01:00
b16ac76620 Update fly.toml to adjust [env] paths and simplify volume mounts configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-08 18:34:18 +01:00
0e03b9dea9 Fix typo in deployment guide section header
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:27:27 +01:00
da3a950a1a Update fly.toml to reference Dockerfile instead of Dockerfile.fly
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:19:01 +01:00
fb7eaa6bb2 Merge remote-tracking branch 'origin/feat/cms' into feat/cms
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:10:02 +01:00
daccc43677 Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 18:09:29 +01:00
3b6cb0a3fb Remove CLAUDE.md as it is no longer relevant
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 17:56:22 +01:00
6a3c77d7c5 Merge remote-tracking branch 'origin/feat/cms' into feat/cms 2025-12-08 17:53:06 +01:00
a28d43db45 Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 17:51:46 +01:00
af930f345c Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 16:00:40 +01:00
22494084ce Merge pull request 'feat(backend): initial setup for cms backend service' (#1) from feat/cms into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2025-12-08 09:03:00 +01:00
bc6c1e95d3 Merge branch 'main' into feat/cms 2025-12-08 09:02:52 +01:00
f2a0422f3b feat(events): add santa karaoke event
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-21 13:57:29 +01:00
9adec32839 build(deps): add missing better-sqlite3 dependency 2025-11-15 17:25:45 +01:00
688b4de945 feat(backend): initial setup for cms backend service 2025-11-15 14:56:43 +01:00
103 changed files with 10760 additions and 523 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
/ai/

View File

@ -1,5 +1,86 @@
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"
when:
- branch: main
event: push
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
when:
- branch: main
event: push
deploy_frontend:
image: node:20
environment:
FLY_API_TOKEN:
@ -7,10 +88,89 @@ steps:
commands:
- curl -L https://fly.io/install.sh | sh
- export PATH="$HOME/.fly/bin:$PATH"
- flyctl deploy --config fly.toml --app gallus-pub
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
when:
- branch: main
event: push
when:
branch:
- main
event:
- push
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

View File

@ -2,7 +2,8 @@ FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 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
@ -16,7 +17,8 @@ RUN npm install -g serve
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
CMD ["serve", "-l", "3000", "dist"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1

9
Dockerfile.caddy Normal file
View File

@ -0,0 +1,9 @@
FROM caddy:2-alpine
# Embed Caddyfile directly to avoid host path issues on Windows
RUN mkdir -p /etc/caddy \
&& printf ":80\nlog\nroute {\n handle /api/* {\n reverse_proxy backend:8080\n }\n handle {\n reverse_proxy frontend:3000\n }\n}\n" > /etc/caddy/Caddyfile
EXPOSE 80
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

142
MIGRATION_README.md Normal file
View File

@ -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

View File

@ -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

20
backend/.dockerignore Normal file
View File

@ -0,0 +1,20 @@
node_modules
dist
.env
.env.local
.env.*.local
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.git
.gitignore
.vscode
.idea
.DS_Store
*.md
!README.md
tmp
/tmp
coverage
.nyc_output

29
backend/.env.example Normal file
View File

@ -0,0 +1,29 @@
# Database (SQLite)
DATABASE_PATH=./data/gallus_cms.db
# Gitea OAuth
GITEA_URL=https://git.bookageek.ch
GITEA_CLIENT_ID=your-oauth-client-id-here
GITEA_CLIENT_SECRET=your-oauth-client-secret-here
GITEA_REDIRECT_URI=http://localhost:3000/api/auth/callback
GITEA_ALLOWED_USERS=sabrina,raphael,admin
# Git Configuration (use Gitea repository)
GIT_REPO_URL=https://git.bookageek.ch/yourusername/Gallus_Pub.git
GIT_TOKEN=your-gitea-personal-access-token-here
GIT_USER_NAME=Gallus CMS
GIT_USER_EMAIL=cms@galluspub.ch
GIT_WORKSPACE_DIR=./data/workspace
# JWT & Session
JWT_SECRET=your-super-secret-jwt-key-change-this
SESSION_SECRET=your-session-secret-change-this
# Server
PORT=3000
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
FRONTEND_URL=http://localhost:5173
# Upload
MAX_FILE_SIZE=5242880

34
backend/.env.local Normal file
View File

@ -0,0 +1,34 @@
# Local development environment for Gallus CMS Backend
# Database
DB_CLIENT=sqlite
DATABASE_URL=
DATABASE_PATH=./data/gallus_cms.db
# Gitea OAuth
GITEA_URL=https://git.bookageek.ch
GITEA_CLIENT_ID=bcddfe24-3099-41bc-bb7a-6c6d80bd9048
GITEA_CLIENT_SECRET=gto_me7hsswrsdq2ygey65edlc6qa2xxr3i5nrq7q4jvjwx654ytrh7q
# Frontend proxy callback in local dev
GITEA_REDIRECT_URI=http://localhost:4321/api/auth/callback
GITEA_ALLOWED_USERS=Gallus-maintanance
# Git repository for content versioning
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub
GIT_TOKEN=1482ae7bcdbd7610bf0cfd468b6757722d16a2a2
GIT_USER_NAME=Gallus-maintanance
GIT_USER_EMAIL=Admin@gallus-pub.ch
GIT_WORKSPACE_DIR=./data/workspace
# JWT & Session secrets (use strong random strings in real deployments)
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
# Server & CORS
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:4321
CORS_ORIGIN=http://localhost:4321
# Upload limits
MAX_FILE_SIZE=5242880

12
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist
.env
*.log
.DS_Store
/tmp
/data/*.db
/data/*.db-wal
/data/*.db-shm
/data/workspace
# Allow images to be committed
!/data/images

195
backend/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,195 @@
# Deployment Guide
## Prerequisite
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
2. Fly.io account: `flyctl auth login`
3. Gitea OAuth app configured at git.bookageek.ch
4. Gitea Personal Access Token for git operations
## Initial Setup
### 1. Create Fly.io App
```bash
cd backend
flyctl apps create gallus-cms-backend
```
### 2. Create Volume for Data (SQLite DB + Git Workspace)
```bash
flyctl volumes create gallus_data --size 2 --region ams
```
This volume will store:
- SQLite database at `/app/data/gallus_cms.db`
- Git workspace at `/app/data/workspace`
### 3. Set Secrets
```bash
flyctl secrets set \
GITEA_CLIENT_ID="<your-gitea-oauth-client-id>" \
GITEA_CLIENT_SECRET="<your-gitea-oauth-client-secret>" \
GIT_TOKEN="<your-gitea-personal-access-token>" \
JWT_SECRET="$(openssl rand -base64 32)" \
SESSION_SECRET="$(openssl rand -base64 32)" \
GIT_REPO_URL="https://git.bookageek.ch/yourusername/Gallus_Pub.git" \
GIT_USER_NAME="Gallus CMS" \
GIT_USER_EMAIL="cms@galluspub.ch" \
GITEA_REDIRECT_URI="https://gallus-cms-backend.fly.dev/api/auth/callback" \
FRONTEND_URL="https://cms.galluspub.ch" \
CORS_ORIGIN="https://cms.galluspub.ch" \
GITEA_ALLOWED_USERS="sabrina,raphael"
```
### 4. Deploy
```bash
flyctl deploy
```
### 5. Initialize Database
After first deployment, SSH into the container and run migrations:
```bash
flyctl ssh console
cd /app
node dist/index.js # Start once to create the database file
# Then exit (Ctrl+C) and run migrations
npm run db:migrate
exit
```
Or simply let the app run - the database will be created automatically on first start.
## Gitea OAuth Configuration
Update your Gitea OAuth application redirect URI to include:
```
https://gallus-cms-backend.fly.dev/api/auth/callback
```
## Useful Commands
### View Logs
```bash
flyctl logs
```
### Check Status
```bash
flyctl status
```
### SSH into Container
```bash
flyctl ssh console
```
### Scale App
```bash
flyctl scale count 2
```
### View Secrets
```bash
flyctl secrets list
```
### Update a Secret
```bash
flyctl secrets set KEY=VALUE
```
### Restart App
```bash
flyctl apps restart
```
## Monitoring
### Health Check
```bash
curl https://gallus-cms-backend.fly.dev/health
```
### View Metrics
```bash
flyctl dashboard
```
## Troubleshooting
### Deployment Fails
- Check logs: `flyctl logs`
- Verify all secrets are set: `flyctl secrets list`
- Ensure Docker builds locally: `docker build -t test .`
### OAuth Not Working
- Verify GITEA_REDIRECT_URI matches Gitea settings exactly
- Check CORS_ORIGIN includes frontend domain
- Review logs for authentication errors
### Git Push Fails
- Verify GIT_TOKEN has correct permissions
- Check GIT_REPO_URL is accessible
- Ensure workspace volume is mounted
### Database Issues
- Verify DATABASE_PATH is set correctly
- Check volume is mounted: `flyctl ssh console` then `ls -la /app/data`
- Verify database file permissions
- Run migrations if needed: `flyctl ssh console` then `npm run db:migrate`
## Cost Optimization
Current configuration uses:
- `shared-cpu-1x` with 512MB RAM
- Auto-suspend when idle
- 2GB volume for SQLite database + git workspace
Estimated cost: ~$5-10/month (no separate database cost with SQLite!)
## Updating
To deploy updates:
```bash
git pull
flyctl deploy
```
## Rollback
To rollback to previous version:
```bash
flyctl releases list
flyctl releases rollback <version-number>
```
## Environment Variables
All sensitive environment variables should be set as Fly.io secrets (not in fly.toml):
Note: DATABASE_PATH and GIT_WORKSPACE_DIR are set in fly.toml as they're not sensitive.
- `GITEA_CLIENT_ID` - OAuth client ID
- `GITEA_CLIENT_SECRET` - OAuth client secret
- `GIT_TOKEN` - Gitea personal access token
- `JWT_SECRET` - JWT signing secret
- `SESSION_SECRET` - Session cookie secret
- `GIT_REPO_URL` - Full git repository URL
- `GITEA_REDIRECT_URI` - OAuth callback URL
- `FRONTEND_URL` - Frontend application URL
- `CORS_ORIGIN` - Allowed CORS origin
- `GITEA_ALLOWED_USERS` - Comma-separated list of allowed usernames
## Security Checklist
- [ ] All secrets set and not exposed in logs
- [ ] HTTPS enforced (fly.toml: force_https = true)
- [ ] CORS configured correctly
- [ ] GITEA_ALLOWED_USERS whitelist configured
- [ ] Database backups enabled
- [ ] Health checks configured
- [ ] Monitoring and alerts set up

71
backend/Dockerfile Normal file
View File

@ -0,0 +1,71 @@
# Multi-stage build for Gallus CMS Backend
# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Install build dependencies for native modules (better-sqlite3, sharp)
RUN apk add --no-cache python3 make g++ vips-dev
# Install dependencies
COPY package*.json ./
# Use npm ci when lockfile exists, fallback to npm install for local/dev
RUN npm ci || npm install
# Copy source
COPY . .
# Build TypeScript
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool, vips for sharp)
# Note: python3, make, g++ are needed for native module compilation
RUN apk add --no-cache git sqlite vips vips-dev python3 make g++
# Copy package files first
COPY --from=builder /app/package*.json ./
# Install all production dependencies and rebuild sharp for linuxmusl-x64
RUN npm ci --omit=dev || npm install --production && \
npm rebuild sharp
# Clean up build dependencies after installation to reduce image size
RUN apk del python3 make g++ vips-dev
# Copy built files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
# Copy migration script and migrated images
COPY --from=builder /app/migrate-production.js ./migrate-production.js
COPY --from=builder /app/data/images ./data/images
# Create directories
RUN mkdir -p /app/workspace /app/data
# Ensure proper permissions
RUN chown -R node:node /app
# Switch to non-root user
USER node
# Expose port
EXPOSE 8080
# Set environment
ENV NODE_ENV=production
ENV PORT=8080
ENV DATABASE_PATH=/app/data/gallus_cms.db
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Run DB migrations if present, then start application
CMD ["/bin/sh", "-lc", "mkdir -p /app/data/images/events /app/data/images/gallery && [ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]

55
backend/README.md Normal file
View File

@ -0,0 +1,55 @@
# Gallus Pub CMS Backend
Headless CMS backend for managing Gallus Pub website content with Gitea OAuth authentication.
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create `.env` file from `.env.example`:
```bash
cp .env.example .env
```
3. Update environment variables in `.env`:
- Set Gitea OAuth credentials
- Set Git repository URL and token
- JWT secrets are already generated
4. Create data directory and run migrations:
```bash
mkdir -p data
```
5. Generate and run migrations:
```bash
npm run db:generate
npm run db:migrate
```
6. Start development server:
```bash
npm run dev
```
Server will run at http://localhost:3000
## Available Scripts
- `npm run dev` - Start development server with watch mode
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run db:generate` - Generate database migrations
- `npm run db:migrate` - Run database migrations
- `npm run db:studio` - Open Drizzle Studio
## Documentation
See parent directory for complete documentation:
- `CMS_CONCEPT.md` - System architecture
- `CMS_GITEA_AUTH.md` - Authentication details
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
- `CMS_SETUP_GUIDE.md` - Deployment guide

View File

@ -0,0 +1,216 @@
# Quick Start Guide - SQLite Version
## ✅ Migration Complete: PostgreSQL → SQLite
The backend now uses **SQLite** instead of PostgreSQL for simplified deployment and lower costs.
## 🚀 Quick Start (3 Steps)
### 1. Configure Environment
Edit `.env` file (already created):
```bash
# Required: Update these values
GITEA_CLIENT_ID=<your-gitea-oauth-client-id>
GITEA_CLIENT_SECRET=<your-gitea-oauth-client-secret>
GIT_REPO_URL=https://git.bookageek.ch/<yourusername>/Gallus_Pub.git
GIT_TOKEN=<your-gitea-personal-access-token>
GITEA_ALLOWED_USERS=sabrina,raphael
# Already set (JWT secrets generated)
JWT_SECRET=dOrvUqifjBLvk68kkDOvWPQper/gjsNMlAbWlVBQIrc=
SESSION_SECRET=SD0ZrvLkv9GrtI8+3GDkxZXA1UnCN4CE3c4+2vA/fIM=
# Database (SQLite - no changes needed)
DATABASE_PATH=./data/gallus_cms.db
```
### 2. Initialize Database
```bash
# Generate migration files from schema
pnpm run db:generate
# Run migrations to create tables
pnpm run db:migrate
```
### 3. Start Development Server
```bash
pnpm run dev
```
Server will start at **http://localhost:3000**
## 📝 What Changed?
### Before (PostgreSQL)
- Required PostgreSQL installation
- Separate database service
- Connection string configuration
- ~$15/month hosting cost on Fly.io
### After (SQLite)
- Single file database (`./data/gallus_cms.db`)
- No separate database service needed
- Works out of the box
- **$0 database cost** (included in app volume)
## 🗂️ Database Location
- **Local:** `./data/gallus_cms.db`
- **Production (Fly.io):** `/app/data/gallus_cms.db` (on persistent volume)
- **Git Workspace:** Same `data/` directory
## 🧪 Test Authentication Flow
1. Make sure you have Gitea OAuth credentials configured
2. Start dev server: `pnpm run dev`
3. Visit: http://localhost:3000/api/auth/gitea
4. Login with your Gitea credentials
5. Should redirect back with JWT token
## 📚 Available Endpoints
### Health Check
```bash
curl http://localhost:3000/health
```
### OAuth Flow
```
GET /api/auth/gitea - Initiate OAuth
GET /api/auth/callback - OAuth callback
GET /api/auth/me - Get current user (requires JWT)
```
### Content Management (all require JWT)
```
GET/POST/PUT/DELETE /api/events
GET/POST/PUT/DELETE /api/gallery
GET/PUT /api/content/:section
GET/PUT /api/settings/:key
POST /api/publish
```
## 🔐 Getting Gitea OAuth Credentials
1. Go to https://git.bookageek.ch/user/settings/applications
2. Click "Manage OAuth2 Applications"
3. Create new OAuth2 application:
- **Name:** Gallus Pub CMS
- **Redirect URI:** `http://localhost:3000/api/auth/callback`
- **Confidential:** Yes
4. Copy Client ID and Client Secret to `.env`
## 🎫 Getting Gitea Personal Access Token
1. Go to https://git.bookageek.ch/user/settings/applications
2. Generate New Token
3. **Name:** Gallus CMS Backend
4. **Scopes:** Select `repo` (full repository access)
5. Copy token to `.env` as `GIT_TOKEN`
## 📦 Project Structure
```
backend/
├── data/ # SQLite database & git workspace (gitignored)
│ ├── gallus_cms.db # Database file
│ └── workspace/ # Git repository clone
├── src/
│ ├── config/
│ │ ├── database.ts # SQLite connection (updated)
│ │ └── env.ts # DATABASE_PATH instead of URL
│ ├── db/
│ │ └── schema.ts # SQLite schema (updated)
│ ├── routes/ # API routes
│ ├── services/ # Core services
│ └── index.ts # Main server
├── .env # Your configuration
├── package.json # Updated with better-sqlite3
└── drizzle.config.ts # SQLite dialect
```
## ⚙️ Scripts
```bash
pnpm install # Install dependencies (done)
pnpm run dev # Start dev server with watch
pnpm run build # Build TypeScript
pnpm run start # Start production server
pnpm run db:generate # Generate migrations
pnpm run db:migrate # Run migrations
pnpm run db:studio # Open Drizzle Studio
```
## 🚀 Deploy to Fly.io
See `DEPLOYMENT.md` for full deployment guide.
**Quick version:**
```bash
# Create volume for database & git workspace
flyctl volumes create gallus_data --size 2 --region ams
# Set secrets
flyctl secrets set GITEA_CLIENT_ID=... GITEA_CLIENT_SECRET=... # etc
# Deploy
flyctl deploy
```
**Cost:** ~$5-10/month (no separate database!)
## 🐛 Troubleshooting
### "tsx: command not found"
```bash
pnpm install
```
### "DATABASE_PATH not set"
Check `.env` file exists and has `DATABASE_PATH=./data/gallus_cms.db`
### "Database file not found"
```bash
mkdir -p data
pnpm run db:migrate
```
### "better-sqlite3" build errors
Make sure you have build tools:
- **Linux:** `apt-get install python3 make g++`
- **macOS:** Install Xcode Command Line Tools
- **Windows:** Install windows-build-tools
Then rebuild:
```bash
pnpm rebuild better-sqlite3
```
## ✨ Benefits of SQLite
1. **Simpler** - No database server to manage
2. **Faster** - No network overhead
3. **Portable** - Single file, easy backups
4. **Cost-effective** - No hosting fees
5. **Perfect fit** - Low concurrency, simple queries
## 📖 Documentation
- `SQLITE_MIGRATION.md` - Detailed migration notes
- `DEPLOYMENT.md` - Fly.io deployment guide
- `README.md` - General setup instructions
- `CMS_GITEA_AUTH.md` - OAuth authentication details (parent dir)
- `CMS_CONCEPT.md` - Full system architecture (parent dir)
## ✅ Ready to Go!
Your backend is now configured for SQLite. Just:
1. Add your Gitea credentials to `.env`
2. Run `pnpm run db:generate && pnpm run db:migrate`
3. Start with `pnpm run dev`
Happy coding! 🎉

217
backend/SQLITE_MIGRATION.md Normal file
View File

@ -0,0 +1,217 @@
# SQLite Migration Summary
## Changes Made
The backend has been migrated from PostgreSQL to SQLite for both local development and production (Fly.io).
### Benefits of SQLite
1. **Simplified Deployment** - No separate database service needed
2. **Lower Cost** - Save ~$15/month (no Postgres hosting)
3. **Easier Development** - No need to install/run PostgreSQL locally
4. **Single File Database** - Easy backups and migrations
5. **Perfect for this use case** - Low concurrent writes, simple queries
## Modified Files
### Dependencies
- **package.json**
- Removed: `pg`, `@types/pg`
- Added: `better-sqlite3`, `@types/better-sqlite3`
### Database Configuration
- **src/config/database.ts**
- Changed from `drizzle-orm/node-postgres` to `drizzle-orm/better-sqlite3`
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
- Enabled WAL mode for better concurrent access
- **src/config/env.ts**
- Changed `DATABASE_URL` to `DATABASE_PATH`
- Default: `./data/gallus_cms.db`
- **src/db/schema.ts**
- Changed from `pgTable` to `sqliteTable`
- Changed `uuid()` to `text()` with `crypto.randomUUID()`
- Changed `jsonb()` to `text(..., { mode: 'json' })`
- Changed `timestamp()` to `integer(..., { mode: 'timestamp' })`
- Changed `boolean()` to `integer(..., { mode: 'boolean' })`
- Uses `sql\`(unixepoch())\`` for default timestamps
- **drizzle.config.ts**
- Changed dialect from `postgresql` to `sqlite`
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
### Environment Files
- **.env** and **.env.example**
- Changed `DATABASE_URL=postgresql://...` to `DATABASE_PATH=./data/gallus_cms.db`
- Changed `GIT_WORKSPACE_DIR=/tmp/gallus-repo` to `./data/workspace`
### Docker Configuration
- **Dockerfile**
- Added build tools for `better-sqlite3` native module (python3, make, g++)
- Added `sqlite` CLI tool
- Creates `/app/data` directory for database
- Sets `DATABASE_PATH=/app/data/gallus_cms.db`
- Proper permissions for non-root user
- **fly.toml**
- Added `DATABASE_PATH` and `GIT_WORKSPACE_DIR` to [env]
- Changed volume mount from `gallus_repo_workspace` to `gallus_data`
- Mount destination: `/app/data` (contains both DB and git workspace)
### Documentation
- **README.md** - Updated setup instructions
- **DEPLOYMENT.md** - Removed Postgres setup, updated volume creation
- **SQLITE_MIGRATION.md** - This file!
## Local Development
### Setup
```bash
# Dependencies already installed
pnpm install
# Create data directory (done)
mkdir -p data
# Database will be created automatically at ./data/gallus_cms.db
```
### Generate and Run Migrations
```bash
# Generate migration files from schema
pnpm run db:generate
# Run migrations to create tables
pnpm run db:migrate
```
### Start Development Server
```bash
pnpm run dev
```
The database file will be created at `./data/gallus_cms.db` on first run.
## Production (Fly.io)
### Volume Setup
```bash
# Create single volume for both database and git workspace
flyctl volumes create gallus_data --size 2 --region ams
```
### Environment Variables
Set in fly.toml (non-sensitive):
- `DATABASE_PATH=/app/data/gallus_cms.db`
- `GIT_WORKSPACE_DIR=/app/data/workspace`
Set as secrets (sensitive):
- All other env vars (OAuth credentials, tokens, etc.)
### Deployment
```bash
flyctl deploy
```
Database will be created automatically on first start. No need for separate database service!
## Database Location
### Local Development
- **Database:** `./data/gallus_cms.db`
- **WAL files:** `./data/gallus_cms.db-wal`, `./data/gallus_cms.db-shm`
- **Git workspace:** `./data/workspace/`
### Production (Fly.io)
- **Database:** `/app/data/gallus_cms.db` (on volume)
- **Git workspace:** `/app/data/workspace/` (on volume)
- **Volume name:** `gallus_data` (2GB)
## Backup Strategy
### Manual Backup
```bash
# Local
cp data/gallus_cms.db data/gallus_cms.backup.db
# Production (Fly.io)
flyctl ssh console
sqlite3 /app/data/gallus_cms.db ".backup /app/data/backup.db"
# Then copy back: flyctl ssh sftp get /app/data/backup.db
```
### Automated Backup (Optional)
Consider setting up a cron job or Fly.io machine to periodically:
1. Create SQLite backup
2. Upload to S3/Backblaze/etc.
## Performance Notes
SQLite is perfect for this use case because:
- **Low write concurrency** - Single admin user making changes
- **Read-heavy** - Mostly reading content for publish operations
- **Small dataset** - Events, gallery images, content sections
- **Simple queries** - No complex joins or aggregations
WAL mode is enabled for:
- Better concurrent read access
- Safer writes (crash recovery)
- Improved performance
## Migration from Existing Data
If you had PostgreSQL data to migrate:
1. Export from Postgres:
```sql
\copy events TO 'events.csv' CSV HEADER;
\copy gallery_images TO 'gallery.csv' CSV HEADER;
-- etc.
```
2. Import to SQLite:
```sql
.mode csv
.import events.csv events
.import gallery.csv gallery_images
-- etc.
```
## Known Limitations
1. **No native UUID type** - Using TEXT with UUID format
2. **No native JSON type** - Using TEXT with JSON serialization (Drizzle handles this)
3. **No native TIMESTAMP** - Using INTEGER with Unix epoch (Drizzle handles this)
4. **Single writer** - Only one write transaction at a time (not an issue for this use case)
## Troubleshooting
### "Database is locked" error
- WAL mode should prevent this
- Check if multiple processes are accessing the database
- Ensure proper file permissions
### Native module build errors
- Make sure build tools are installed: `apt-get install python3 make g++` (Linux)
- On Alpine: `apk add python3 make g++`
- Try rebuilding: `pnpm rebuild better-sqlite3`
### Database file not found
- Check `DATABASE_PATH` is set correctly
- Ensure `data/` directory exists
- Check file permissions
## Next Steps
1. ✅ Update dependencies
2. ✅ Update database configuration
3. ✅ Update schema
4. ✅ Update Docker configuration
5. ⏳ Generate migrations: `pnpm run db:generate`
6. ⏳ Run migrations: `pnpm run db:migrate`
7. ⏳ Test development server: `pnpm run dev`
8. ⏳ Test publish flow
9. ⏳ Deploy to Fly.io
The migration is complete! Just need to generate/run migrations and test.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

10
backend/drizzle.config.ts Normal file
View File

@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_PATH || './data/gallus_cms.db',
},
} satisfies Config;

41
backend/fly.toml Normal file
View File

@ -0,0 +1,41 @@
# Fly.io configuration for Gallus CMS Backend
app = "gallus-cms-backend"
primary_region = "ams"
[build]
# Ensure Fly uses the Dockerfile in this backend directory
dockerfile = "Dockerfile"
[env]
PORT = "8080"
NODE_ENV = "production"
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
force_https = true
auto_stop_machines = "suspend"
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
method = "GET"
timeout = "5s"
path = "/health"
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
[mounts]
source = "gallus_data"
destination = "/app/data"

View File

@ -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);
});

2704
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
backend/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "gallus-cms-backend",
"version": "1.0.0",
"type": "module",
"description": "Headless CMS backend for Gallus Pub website",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
},
"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",
"drizzle-orm": "^0.33.0",
"fastify": "^4.26.0",
"sharp": "^0.33.2",
"simple-git": "^3.22.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/better-sqlite3": "^7.6.9",
"@types/node": "^20.11.16",
"drizzle-kit": "^0.24.0",
"tsx": "^4.20.6",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,104 @@
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;
}
}

51
backend/src/config/env.ts Normal file
View File

@ -0,0 +1,51 @@
// Environment configuration with validation
export const env = {
// Database
DATABASE_PATH: process.env.DATABASE_PATH || './data/gallus_cms.db',
// Gitea OAuth
GITEA_URL: process.env.GITEA_URL || 'https://git.bookageek.ch',
GITEA_CLIENT_ID: process.env.GITEA_CLIENT_ID || '',
GITEA_CLIENT_SECRET: process.env.GITEA_CLIENT_SECRET || '',
GITEA_REDIRECT_URI: process.env.GITEA_REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
GITEA_ALLOWED_USERS: process.env.GITEA_ALLOWED_USERS || '',
// Git Configuration
GIT_REPO_URL: process.env.GIT_REPO_URL || '',
GIT_TOKEN: process.env.GIT_TOKEN || '',
GIT_USER_NAME: process.env.GIT_USER_NAME || 'Gallus CMS',
GIT_USER_EMAIL: process.env.GIT_USER_EMAIL || 'cms@galluspub.ch',
GIT_WORKSPACE_DIR: process.env.GIT_WORKSPACE_DIR || '/tmp/gallus-repo',
// JWT & Session
JWT_SECRET: process.env.JWT_SECRET || '',
SESSION_SECRET: process.env.SESSION_SECRET || '',
// Server
PORT: parseInt(process.env.PORT || '3000', 10),
NODE_ENV: process.env.NODE_ENV || 'development',
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
// Upload
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
};
// Validate required environment variables
export function validateEnv() {
const required = [
'DATABASE_PATH',
'GITEA_CLIENT_ID',
'GITEA_CLIENT_SECRET',
'GIT_REPO_URL',
'GIT_TOKEN',
'JWT_SECRET',
'SESSION_SECRET',
];
const missing = required.filter(key => !env[key as keyof typeof env]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
}

72
backend/src/db/schema.ts Normal file
View File

@ -0,0 +1,72 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
giteaId: text('gitea_id').notNull().unique(),
giteaUsername: text('gitea_username').notNull(),
giteaEmail: text('gitea_email'),
displayName: text('display_name'),
avatarUrl: text('avatar_url'),
role: text('role').default('admin'),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
lastLogin: integer('last_login', { mode: 'timestamp' }),
});
// Events table
export 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())`),
});
// Gallery images table
export 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())`),
});
// Content sections table (for text-based sections)
export const contentSections = sqliteTable('content_sections', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
sectionName: text('section_name').notNull().unique(),
contentJson: text('content_json', { mode: 'json' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Site settings table (global config)
export const siteSettings = sqliteTable('site_settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Publish history (audit log)
export const publishHistory = sqliteTable('publish_history', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').references(() => users.id),
commitHash: text('commit_hash'),
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())`),
});

143
backend/src/index.ts Normal file
View File

@ -0,0 +1,143 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
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 routes
import authRoute from './routes/auth.js';
import eventsRoute from './routes/events.js';
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';
// Validate environment variables
try {
validateEnv();
} catch (error) {
console.error('Environment validation failed:', error);
process.exit(1);
}
const fastify = Fastify({
logger: {
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
transport: env.NODE_ENV === 'development' ? {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
} : undefined,
},
});
// Register plugins
// Support multiple origins for CORS
const allowedOrigins = env.CORS_ORIGIN.split(',').map(o => o.trim());
fastify.register(cors, {
origin: (origin, cb) => {
// Allow requests with no origin (like mobile apps or curl)
if (!origin) {
return cb(null, true);
}
// Check if origin is in allowed list
const isAllowed = allowedOrigins.includes(origin);
if (isAllowed) {
return cb(null, true);
} else {
return cb(null, false);
}
},
credentials: true,
});
fastify.register(cookie);
fastify.register(jwt, {
secret: env.JWT_SECRET,
cookie: {
cookieName: 'token',
signed: false,
},
});
fastify.register(multipart, {
limits: {
fileSize: env.MAX_FILE_SIZE,
},
});
// Serve static files (uploaded images, etc.) from persistent volume
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
fastify.register(fastifyStatic, {
root: dataDir,
prefix: '/static/',
decorateReply: false
});
// Decorate fastify with authenticate method
fastify.decorate('authenticate', authenticate);
// Register routes
fastify.register(authRoute, { prefix: '/api' });
fastify.register(eventsRoute, { prefix: '/api' });
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' });
// Health check
fastify.get('/health', async () => {
return {
status: 'ok',
timestamp: new Date().toISOString(),
environment: env.NODE_ENV,
};
});
// Root endpoint
fastify.get('/', async () => {
return {
name: 'Gallus Pub CMS Backend',
version: '1.0.0',
status: 'running',
};
});
// Error handler
fastify.setErrorHandler((error, request, reply) => {
fastify.log.error(error);
reply.status(error.statusCode || 500).send({
error: error.message || 'Internal Server Error',
statusCode: error.statusCode || 500,
});
});
// 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}`);
console.log(`🔐 CORS Origin: ${env.CORS_ORIGIN}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

View File

@ -0,0 +1,12 @@
import { FastifyRequest, FastifyReply } from 'fastify';
export async function authenticate(
request: FastifyRequest,
reply: FastifyReply
) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}

187
backend/src/routes/auth.ts Normal file
View File

@ -0,0 +1,187 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { GiteaService } from '../services/gitea.service.js';
import { env } from '../config/env.js';
const callbackQueryJsonSchema = {
type: 'object',
required: ['code', 'state'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
},
} as const;
const authRoute: FastifyPluginAsync = async (fastify) => {
const giteaService = new GiteaService();
/**
* GET /auth/gitea
* Initiate OAuth flow
*/
fastify.get('/auth/gitea', async (request, reply) => {
// Generate CSRF state token
const state = giteaService.generateState();
// Store state in a short-lived cookie
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
sameSite: 'none',
secure: true,
maxAge: 10 * 60, // 10 minutes
});
// Generate authorization URL
const authUrl = giteaService.getAuthorizationUrl(state);
// Redirect to Gitea
return reply.redirect(authUrl);
});
/**
* GET /auth/callback
* OAuth callback endpoint
*/
fastify.get('/auth/callback', {
schema: {
querystring: callbackQueryJsonSchema,
},
}, async (request, reply) => {
try {
const { code, state } = request.query as { code: string; state: string };
// Verify CSRF state from cookie
const expectedState = request.cookies?.oauth_state as string | undefined;
if (!expectedState || state !== expectedState) {
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state cookie
reply.clearCookie('oauth_state', { path: '/' });
// Exchange code for access token
const tokenResponse = await giteaService.exchangeCodeForToken(code);
// Fetch user info from Gitea
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
// Check if user is allowed
if (!giteaService.isUserAllowed(giteaUser.login)) {
return reply.code(403).send({
error: 'Access denied. You are not authorized to access this CMS.'
});
}
// Find or create user in database
let [user] = await db
.select()
.from(users)
.where(eq(users.giteaId, giteaUser.id.toString()))
.limit(1);
if (!user) {
// Create new user
[user] = await db.insert(users).values({
giteaId: giteaUser.id.toString(),
giteaUsername: giteaUser.login,
giteaEmail: giteaUser.email,
displayName: giteaUser.full_name,
avatarUrl: giteaUser.avatar_url,
lastLogin: new Date(),
}).returning();
} else {
// Update existing user
[user] = await db
.update(users)
.set({
giteaUsername: giteaUser.login,
giteaEmail: giteaUser.email,
displayName: giteaUser.full_name,
avatarUrl: giteaUser.avatar_url,
lastLogin: new Date(),
})
.where(eq(users.id, user.id))
.returning();
}
// Generate JWT for session management
const token = fastify.jwt.sign(
{
id: user.id,
giteaId: user.giteaId,
username: user.giteaUsername || '',
role: user.role ?? 'admin',
},
{ expiresIn: '24h' }
);
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
reply.setCookie('token', token, {
path: '/',
httpOnly: true,
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
maxAge: 60 * 60 * 24, // 24h
});
// Redirect to admin dashboard
const frontendUrl = env.FRONTEND_URL;
return reply.redirect(`${frontendUrl}/admin`);
} catch (error) {
fastify.log.error({ err: error }, 'OAuth callback error');
return reply.code(500).send({ error: 'Authentication failed' });
}
});
/**
* GET /auth/me
* Get current user info
*/
fastify.get('/auth/me', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const userId = request.user.id;
const [user] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return {
user: {
id: user.id,
giteaUsername: user.giteaUsername,
giteaEmail: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
},
};
});
/**
* POST /auth/logout
* Logout (client-side token deletion)
*/
fastify.post('/auth/logout', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
// For JWT, logout is primarily client-side (delete token)
// You could maintain a token blacklist in Redis for production
reply.clearCookie('token', { path: '/' });
return { message: 'Logged out successfully' };
});
};
export default authRoute;

View File

@ -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;

View File

@ -0,0 +1,104 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
type: 'object',
required: ['contentJson'],
properties: {
contentJson: {}, // allow any JSON
},
} as const;
const contentRoute: FastifyPluginAsync = async (fastify) => {
// Get content section
fastify.get('/content/:section', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const [content] = await db
.select()
.from(contentSections)
.where(eq(contentSections.sectionName, section))
.limit(1);
if (!content) {
return reply.code(404).send({ error: 'Content section not found' });
}
return {
section: content.sectionName,
content: content.contentJson,
updatedAt: content.updatedAt,
};
});
// Update content section
fastify.put('/content/:section', {
schema: {
body: contentBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const { contentJson } = request.body as any;
// Check if section exists
const [existing] = await db
.select()
.from(contentSections)
.where(eq(contentSections.sectionName, section))
.limit(1);
let result;
if (existing) {
// Update existing
[result] = await db
.update(contentSections)
.set({
contentJson,
updatedAt: new Date(),
})
.where(eq(contentSections.sectionName, section))
.returning();
} else {
// Create new
[result] = await db
.insert(contentSections)
.values({
sectionName: section,
contentJson,
})
.returning();
}
return {
section: result.sectionName,
content: result.contentJson,
updatedAt: result.updatedAt,
};
});
// List all content sections
fastify.get('/content', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const sections = await db.select().from(contentSections);
return {
sections: (sections as any[]).map((s: any) => ({
section: s.sectionName,
content: s.contentJson,
updatedAt: s.updatedAt,
})),
};
});
};
export default contentRoute;

View File

@ -0,0 +1,166 @@
import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js';
import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
// Fastify JSON schema for event body
const eventBodyJsonSchema = {
type: 'object',
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
properties: {
title: { type: 'string', minLength: 1, maxLength: 200 },
date: { type: 'string', minLength: 1, maxLength: 100 },
description: { type: 'string', minLength: 1 },
imageUrl: { type: 'string', minLength: 1 },
displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' },
},
} as const;
const reorderBodyJsonSchema = {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
} as const;
const eventsRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published events (no auth required)
fastify.get('/events/public', async () => {
const all = await db.select().from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
return { events: all };
});
// 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 };
});
// Get single event
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
return { event: rows[0] };
});
// Create event
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const data = request.body as any;
const [row] = await db.insert(events).values(data).returning();
return reply.code(201).send({ event: row });
});
// Update event
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as any;
const [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' });
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' });
}
// Prepare directories - use persistent volume for Fly.io
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const uploadDir = path.join(dataDir, 'public', 'images', 'events');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
// Read uploaded stream into buffer
const chunks: Buffer[] = [];
for await (const chunk of file.file) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const inputBuffer = Buffer.concat(chunks);
// Generate filename
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
const baseName = `${stamp}-${rand}`;
// Try to convert to webp and limit size; fallback to original
let outBuffer: Buffer | null = null;
let outExt = '.webp';
try {
// Lazy load sharp only when needed
const sharp = (await import('sharp')).default;
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
} catch (err) {
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
outBuffer = inputBuffer;
// naive extension from mimetype
const extFromMime = mime.split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
const filename = baseName + outExt;
const destPath = path.join(uploadDir, filename);
fs.writeFileSync(destPath, outBuffer);
// Public URL (served via /static)
const publicUrl = `/images/events/${filename}`;
return reply.code(201).send({ imageUrl: publicUrl });
} catch (err) {
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// 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' });
return { message: 'Event deleted successfully' };
});
// Reorder events (synchronous transaction for better-sqlite3)
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
}
});
return { message: 'Events reordered successfully' };
});
};
export default eventsRoute;

View File

@ -0,0 +1,223 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
// Fastify JSON schema for gallery image body
const galleryBodyJsonSchema = {
type: 'object',
required: ['imageUrl', 'altText', 'displayOrder'],
properties: {
imageUrl: { type: 'string', minLength: 1 },
altText: { type: 'string', minLength: 1, maxLength: 200 },
displayOrder: { type: 'integer', minimum: 0 },
isPublished: { type: 'boolean' },
},
} as const;
const galleryRoute: FastifyPluginAsync = async (fastify) => {
// PUBLIC: List published gallery images (no auth required)
fastify.get('/gallery/public', async () => {
const images = await db.select().from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
return { images };
});
// List all gallery images - admin only
fastify.get('/gallery', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
return { images };
});
// Get single gallery image
fastify.get('/gallery/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
if (image.length === 0) {
return reply.code(404).send({ error: 'Image not found' });
}
return { image: image[0] };
});
// Create gallery image
fastify.post('/gallery', {
schema: {
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as any;
const [newImage] = await db.insert(galleryImages).values(data).returning();
return reply.code(201).send({ image: newImage });
});
// Upload image file (multipart)
fastify.post('/gallery/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
const file = await (request as any).file();
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' });
}
// Prepare directories - use persistent volume for Fly.io
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const uploadDir = path.join(dataDir, 'public', 'images', 'gallery');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
// Read uploaded stream into buffer
const chunks: Buffer[] = [];
for await (const chunk of file.file) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const inputBuffer = Buffer.concat(chunks);
// Generate filename
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
const baseName = `${stamp}-${rand}`;
// Try to convert to webp and limit size; fallback to original
let outBuffer: Buffer | null = null;
let outExt = '.webp';
try {
// Lazy load sharp only when needed
const sharp = (await import('sharp')).default;
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
} catch (err) {
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
outBuffer = inputBuffer;
// naive extension from mimetype
const extFromMime = mime.split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
const filename = baseName + outExt;
const destPath = path.join(uploadDir, filename);
fs.writeFileSync(destPath, outBuffer);
// Public URL (served via /static)
const publicUrl = `/images/gallery/${filename}`;
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({
imageUrl: publicUrl,
altText: altText || filename,
displayOrder,
isPublished: true,
}).returning();
return reply.code(201).send({ image: row });
} catch (err) {
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// Update gallery image
fastify.put('/gallery/:id', {
schema: {
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as any;
const [updated] = await db
.update(galleryImages)
.set(data)
.where(eq(galleryImages.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Image not found' });
}
return { image: updated };
});
// Delete gallery image
fastify.delete('/gallery/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const [deleted] = await db
.delete(galleryImages)
.where(eq(galleryImages.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Image not found' });
}
return { message: 'Image deleted successfully' };
});
// Reorder gallery images
fastify.put('/gallery/reorder', {
schema: {
body: {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
// Update all in synchronous transaction (better-sqlite3 requirement)
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
}
});
return { message: 'Gallery images reordered successfully' };
});
};
export default galleryRoute;

View File

@ -0,0 +1,127 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { GitService } from '../services/git.service.js';
import { FileGeneratorService } from '../services/file-generator.service.js';
import { db } from '../config/database.js';
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for publish body
const publishBodyJsonSchema = {
type: 'object',
required: ['commitMessage'],
properties: {
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
},
} as const;
const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.post('/publish', {
schema: {
body: publishBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const { commitMessage } = request.body as any;
const userId = request.user.id;
fastify.log.info('Starting publish process...');
// Initialize git service
const gitService = new GitService();
await gitService.initialize();
fastify.log.info('Git repository initialized');
// Fetch all content from database
const eventsData = await db
.select()
.from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
const galleryData = await db
.select()
.from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
const sectionsData = await db.select().from(contentSections);
const sectionsMap = new Map<string, any>(
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
);
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
// Generate and write files
const fileGenerator = new FileGeneratorService();
await fileGenerator.writeFiles(
gitService.getWorkspacePath(''),
(eventsData as any[]).map((e: any) => ({
title: e.title,
date: e.date,
description: e.description,
imageUrl: e.imageUrl,
})),
(galleryData as any[]).map((g: any) => ({
imageUrl: g.imageUrl,
altText: g.altText,
})),
sectionsMap
);
fastify.log.info('Files generated successfully');
// Commit and push
const commitHash = await gitService.commitAndPush(commitMessage);
fastify.log.info(`Changes committed: ${commitHash}`);
// Record in history
await db.insert(publishHistory).values({
userId,
commitHash,
commitMessage,
});
return {
success: true,
commitHash,
message: 'Changes published successfully',
};
} catch (error) {
fastify.log.error({ err: error }, 'Publish error');
// Attempt to reset git state on error
try {
const gitService = new GitService();
await gitService.reset();
} catch (resetError) {
fastify.log.error({ err: resetError }, 'Failed to reset git state');
}
return reply.code(500).send({
success: false,
error: 'Failed to publish changes',
details: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// Get publish history
fastify.get('/publish/history', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const history = await db
.select()
.from(publishHistory)
.orderBy(publishHistory.publishedAt)
.limit(20);
return { history };
});
};
export default publishRoute;

View File

@ -0,0 +1,121 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { siteSettings } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for settings body
const settingBodyJsonSchema = {
type: 'object',
required: ['value'],
properties: {
value: { type: 'string' },
},
} as const;
const settingsRoute: FastifyPluginAsync = async (fastify) => {
// Get all settings
fastify.get('/settings', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const settings = await db.select().from(siteSettings);
return {
settings: settings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string>),
};
});
// Get single setting
fastify.get('/settings/:key', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const [setting] = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.key, key))
.limit(1);
if (!setting) {
return reply.code(404).send({ error: 'Setting not found' });
}
return {
key: setting.key,
value: setting.value,
updatedAt: setting.updatedAt,
};
});
// Update setting
fastify.put('/settings/:key', {
schema: {
body: settingBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const { value } = request.body as any;
// Check if setting exists
const [existing] = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.key, key))
.limit(1);
let result;
if (existing) {
// Update existing
[result] = await db
.update(siteSettings)
.set({
value,
updatedAt: new Date(),
})
.where(eq(siteSettings.key, key))
.returning();
} else {
// Create new
[result] = await db
.insert(siteSettings)
.values({
key,
value,
})
.returning();
}
return {
key: result.key,
value: result.value,
updatedAt: result.updatedAt,
};
});
// Delete setting
fastify.delete('/settings/:key', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const [deleted] = await db
.delete(siteSettings)
.where(eq(siteSettings.key, key))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Setting not found' });
}
return { message: 'Setting deleted successfully' };
});
};
export default settingsRoute;

View File

@ -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();

View File

@ -0,0 +1,241 @@
import { writeFile } from 'fs/promises';
import path from 'path';
interface Event {
title: string;
date: string;
description: string;
imageUrl: string;
}
interface GalleryImage {
imageUrl: string;
altText: string;
}
interface ContentSection {
[key: string]: any;
}
export class FileGeneratorService {
escapeQuotes(str: string): string {
return str.replace(/"/g, '\\"');
}
escapeBackticks(str: string): string {
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
}
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
const eventsCode = events.map(e => `\t{
\t\timage: "${e.imageUrl}",
\t\ttitle: "${this.escapeQuotes(e.title)}",
\t\tdate: "${e.date}",
\t\tdescription: \`
\t\t\t${this.escapeBackticks(e.description)}
\t\t\`,
\t}`).join(',\n');
const imagesCode = images.map(g =>
`\t{ src: "${g.imageUrl}", alt: "${this.escapeQuotes(g.altText)}" }`
).join(',\n');
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";
import Drinks from "../components/Drinks.astro";
import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
const events = [
${eventsCode}
];
const images = [
${imagesCode}
];
---
<Layout>
\t<Hero id="hero" />
\t<Banner />
\t<Welcome id="welcome" />
\t<EventsGrid id="events" events={events} />
\t<ImageCarousel id="gallery" images={images} />
\t<Drinks id="drinks" />
</Layout>
`;
}
generateHeroComponent(content: ContentSection): string {
return `---
// src/components/Hero.astro
import "../styles/components/Hero.css"
const { id } = Astro.props;
---
<section id={id} class="hero container">
\t<div class="hero-overlay">
\t\t<div class="hero-content">
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
\t\t</div>
\t</div>
</section>
<style>
</style>
`;
}
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>`
).join('\n\n');
return `---
// src/components/Welcome.astro
import "../styles/components/Welcome.css"
const { id } = Astro.props;
---
<section id={id} class="welcome container">
\t<div class="welcome-text">
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
\t\t<p>
\t\t\t${content.introText || ''}
\t\t</p>
\t\t<p><b>Unsere Highlights:</b></p>
\t\t<ul>
${highlightsList}
\t\t</ul>
\t\t<p>
\t\t\t${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</div>
</section>
`;
}
generateDrinksComponent(content: ContentSection): string {
return `---
import "../styles/components/Drinks.css"
const { id } = Astro.props;
---
<section id={id} class="Drinks">
<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.'}
</p>
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka">
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>${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.'}
</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" />
<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" />
<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" />
<span class="circle-label"></span>
</div>
</div>
</section>
`;
}
async writeFiles(
workspaceDir: string,
events: Event[],
images: GalleryImage[],
sections: Map<string, ContentSection>
) {
// Write index.astro
const indexContent = this.generateIndexAstro(events, images);
await writeFile(
path.join(workspaceDir, 'src/pages/index.astro'),
indexContent,
'utf-8'
);
// Write Hero component
if (sections.has('hero')) {
const heroContent = this.generateHeroComponent(sections.get('hero')!);
await writeFile(
path.join(workspaceDir, 'src/components/Hero.astro'),
heroContent,
'utf-8'
);
}
// Write Welcome component
if (sections.has('welcome')) {
const welcomeContent = this.generateWelcomeComponent(sections.get('welcome')!);
await writeFile(
path.join(workspaceDir, 'src/components/Welcome.astro'),
welcomeContent,
'utf-8'
);
}
// Write Drinks component
if (sections.has('drinks')) {
const drinksContent = this.generateDrinksComponent(sections.get('drinks')!);
await writeFile(
path.join(workspaceDir, 'src/components/Drinks.astro'),
drinksContent,
'utf-8'
);
}
}
}

View File

@ -0,0 +1,65 @@
import simpleGit, { SimpleGit } from 'simple-git';
import { mkdir, rm } from 'fs/promises';
import path from 'path';
import { env } from '../config/env.js';
export class GitService {
private git: SimpleGit;
private workspaceDir: string;
private repoUrl: string;
private token: string;
constructor() {
this.workspaceDir = env.GIT_WORKSPACE_DIR;
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 });
// Add token to repo URL for authentication
const authenticatedUrl = this.repoUrl.replace(
'https://',
`https://oauth2:${this.token}@`
);
try {
// Check if repo already exists
await this.git.cwd(this.workspaceDir);
await this.git.status();
console.log('Repository already exists, pulling latest...');
await this.git.pull();
} catch {
// Clone if doesn't exist
console.log('Cloning repository...');
await rm(this.workspaceDir, { recursive: true, force: true });
await this.git.clone(authenticatedUrl, this.workspaceDir);
await this.git.cwd(this.workspaceDir);
}
// Configure git user
await this.git.addConfig('user.name', env.GIT_USER_NAME);
await this.git.addConfig('user.email', env.GIT_USER_EMAIL);
}
async commitAndPush(message: string): Promise<string> {
await this.git.add('.');
await this.git.commit(message);
await this.git.push('origin', 'main');
const log = await this.git.log({ maxCount: 1 });
return log.latest?.hash || '';
}
getWorkspacePath(relativePath: string): string {
return path.join(this.workspaceDir, relativePath);
}
async reset() {
await this.git.reset(['--hard', 'HEAD']);
await this.git.clean('f', ['-d']);
}
}

View File

@ -0,0 +1,112 @@
import crypto from 'crypto';
import { env } from '../config/env.js';
interface GiteaUser {
id: number;
login: string;
email: string;
full_name: string;
avatar_url: string;
}
interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
}
export class GiteaService {
private giteaUrl: string;
private clientId: string;
private clientSecret: string;
private redirectUri: string;
private allowedUsers: Set<string>;
constructor() {
this.giteaUrl = env.GITEA_URL;
this.clientId = env.GITEA_CLIENT_ID;
this.clientSecret = env.GITEA_CLIENT_SECRET;
this.redirectUri = env.GITEA_REDIRECT_URI;
const allowed = env.GITEA_ALLOWED_USERS;
this.allowedUsers = new Set(allowed.split(',').map(u => u.trim()).filter(Boolean));
}
/**
* Generate OAuth authorization URL
*/
getAuthorizationUrl(state: string): string {
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
state,
scope: 'read:user',
});
return `${this.giteaUrl}/login/oauth/authorize?${params.toString()}`;
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(code: string): Promise<OAuthTokenResponse> {
const response = await fetch(`${this.giteaUrl}/login/oauth/access_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
code,
redirect_uri: this.redirectUri,
grant_type: 'authorization_code',
}),
});
if (!response.ok) {
throw new Error(`Failed to exchange code: ${response.statusText}`);
}
return await response.json();
}
/**
* Fetch user info from Gitea using access token
*/
async getUserInfo(accessToken: string): Promise<GiteaUser> {
const response = await fetch(`${this.giteaUrl}/api/v1/user`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
return await response.json();
}
/**
* Check if user is allowed to access the CMS
*/
isUserAllowed(username: string): boolean {
// If no allowed users specified, allow all
if (this.allowedUsers.size === 0) {
return true;
}
return this.allowedUsers.has(username);
}
/**
* Generate random state for CSRF protection
*/
generateState(): string {
return crypto.randomBytes(32).toString('hex');
}
}

View File

@ -0,0 +1,87 @@
import sharp from 'sharp';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { env } from '../config/env.js';
export class MediaService {
private allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
private maxFileSize: number;
constructor() {
this.maxFileSize = env.MAX_FILE_SIZE;
}
/**
* Validate file type and size
*/
async validateFile(file: any): Promise<void> {
if (!this.allowedMimeTypes.includes(file.mimetype)) {
throw new Error(`Invalid file type. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
}
// Check file size
const buffer = await file.toBuffer();
if (buffer.length > this.maxFileSize) {
throw new Error(`File too large. Maximum size: ${this.maxFileSize / 1024 / 1024}MB`);
}
}
/**
* Generate safe filename
*/
generateFilename(originalName: string): string {
const ext = path.extname(originalName);
const hash = crypto.randomBytes(8).toString('hex');
const timestamp = Date.now();
return `${timestamp}-${hash}${ext}`;
}
/**
* Optimize and save image
*/
async processAndSaveImage(
file: any,
destinationDir: string
): Promise<{ filename: string; url: string }> {
await this.validateFile(file);
// Ensure destination directory exists
await mkdir(destinationDir, { recursive: true });
// Generate filename
const filename = this.generateFilename(file.filename);
const filepath = path.join(destinationDir, filename);
// Get file buffer
const buffer = await file.toBuffer();
// Process image with sharp (optimize and resize if needed)
await sharp(buffer)
.resize(2000, 2000, {
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({ quality: 85 })
.png({ quality: 85 })
.webp({ quality: 85 })
.toFile(filepath);
// Return filename and URL path
return {
filename,
url: `/images/${filename}`,
};
}
/**
* Save image to git workspace
*/
async saveToGitWorkspace(
file: any,
workspaceDir: string
): Promise<{ filename: string; url: string }> {
const imagesDir = path.join(workspaceDir, 'public', 'images');
return this.processAndSaveImage(file, imagesDir);
}
}

View File

@ -0,0 +1,25 @@
import { FastifyRequest } from 'fastify';
export interface JWTPayload {
id: string;
giteaId: string;
username: string;
role: string;
}
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
user: JWTPayload;
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: JWTPayload;
user: JWTPayload;
}
}

19
backend/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

38
docker-compose.yml Normal file
View File

@ -0,0 +1,38 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
environment:
- BACKEND_URL=http://proxy:4321
depends_on:
- backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- ./backend/.env.local
environment:
- NODE_ENV=production
- PORT=8080
- DATABASE_PATH=/app/data/gallus_cms.db
- GIT_WORKSPACE_DIR=/app/workspace
volumes:
- backend_data:/app/data
- backend_workspace:/app/workspace
proxy:
build:
context: .
dockerfile: Dockerfile.caddy
depends_on:
- frontend
- backend
ports:
- "4321:80"
volumes:
backend_data:
backend_workspace:

View File

@ -9,6 +9,9 @@ kill_timeout = 5
[env]
PORT = "3000"
NODE_ENV = "production"
BACKEND_PORT = "8080"
DATABASE_PATH = "/app/data/db/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
[http_service]
internal_port = 3000
@ -39,4 +42,8 @@ kill_timeout = 5
[[vm]]
memory = "512MB"
cpu_kind = "shared"
cpus = 1
cpus = 1
[[mounts]]
source = "gallus_data"
destination = "/app/data"

1092
package-lock.json generated

File diff suppressed because it is too large Load Diff

3114
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- sharp

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

View File

@ -0,0 +1,40 @@
---
// src/components/Banner.astro
import "../styles/components/Banner.css"
---
<div id="banner-container"></div>
<script>
const API_BASE = 'https://cms.gallus-pub.ch';
async function loadBanner() {
try {
const response = await fetch(`${API_BASE}/api/banners/active`);
if (response.ok) {
const data = await response.json();
if (data.banner) {
const container = document.getElementById('banner-container');
if (container) {
container.innerHTML = `
<div class="banner-wrapper">
<div class="banner container">
<p>${data.banner.text}</p>
</div>
</div>
`;
}
}
}
} catch (error) {
console.error('Failed to fetch banner:', error);
}
}
// Load banner when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadBanner);
} else {
loadBanner();
}
</script>

View File

@ -1,9 +1,11 @@
---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
---
<Layout>
<Banner />
<h1>Gallery</h1>
<p>Hier findest du alle aktuellen und kommenden Gallery im Gallus Pub.</p>

View File

@ -1,8 +1,10 @@
---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
---
<Layout>
<Banner />
<h1>Openings</h1>

481
src/pages/admin.astro Normal file
View File

@ -0,0 +1,481 @@
---
const title = 'Admin';
---
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
h1, h2 { margin: 0.5rem 0; }
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
.muted { color: #666; }
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
.btn:hover { background: #444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 0.75rem; }
.card { border: 1px solid #eee; padding: 0.75rem; border-radius: 6px; }
label { display:block; margin-top: 0.5rem; }
input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; }
img.thumb { max-width: 100%; height: auto; display: block; }
.toolbar { display:flex; gap:.5rem; align-items:center; margin:.5rem 0; }
.pill { font-size:.85rem; padding:.25rem .5rem; border:1px solid #ddd; border-radius:999px; background:#f7f7f7; }
.dragging { opacity:.5; }
.drag-handle { cursor:grab; padding:.25rem .5rem; border:1px dashed #bbb; border-radius:6px; font-size:.85rem; }
.row-buttons { display:flex; gap:.5rem; margin-top:.5rem; }
</style>
</head>
<body>
<h1>Admin</h1>
<section>
<h2>Authentifizierung</h2>
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
<div class="row">
<a id="login-link" class="btn" href="https://cms.gallus-pub.ch/api/auth/gitea">Mit Gitea anmelden</a>
<button id="btn-relogin">Neu anmelden</button>
<button id="btn-logout">Abmelden</button>
</div>
</section>
<section id="sec-events" style="display:none">
<h2>Events verwalten</h2>
<div class="events-row">
<div class="card">
<h3>Neues Event</h3>
<label>Titel<input id="ev-title" /></label>
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
<button id="btn-create-ev">Event anlegen</button>
<div id="ev-create-msg" class="muted"></div>
</div>
<div class="card" style="min-width:380px;">
<h3>Liste</h3>
<div class="toolbar">
<span class="pill" id="lbl-order">Anzeige: automatisch nach Datum (neueste zuerst)</span>
<button id="btn-toggle-reorder">Reihenfolge bearbeiten</button>
<button id="btn-save-order" style="display:none">Reihenfolge speichern</button>
<span id="order-msg" class="muted"></span>
</div>
<div id="events-list" class="grid"></div>
</div>
</div>
</section>
<section id="sec-gallery" style="display:none">
<h2>Gallery verwalten</h2>
<div class="events-row">
<div class="card">
<h3>Neues Gallery-Bild</h3>
<label>Bild-Datei<input id="gal-file" type="file" accept="image/*" /></label>
<label>Alt-Text<input id="gal-alt" placeholder="Bildbeschreibung" /></label>
<button id="btn-create-gal">Bild hochladen</button>
<div id="gal-create-msg" class="muted"></div>
</div>
<div class="card" style="min-width:380px;">
<h3>Gallery-Liste</h3>
<div id="gallery-list" class="grid"></div>
</div>
</div>
</section>
<section id="sec-banner" style="display:none">
<h2>Banner verwalten</h2>
<div class="events-row">
<div class="card">
<h3>Neuen Banner erstellen</h3>
<label>Text<textarea id="banner-text" rows="3" placeholder="z.B. Wir sind vom 24.12. bis 02.01. geschlossen"></textarea></label>
<label>Von (Datum)<input id="banner-start" type="date" /></label>
<label>Bis (Datum)<input id="banner-end" type="date" /></label>
<button id="btn-create-banner">Banner erstellen</button>
<div id="banner-create-msg" class="muted"></div>
</div>
<div class="card" style="min-width:380px;">
<h3>Banner-Liste</h3>
<div id="banner-list" class="grid"></div>
</div>
</div>
</section>
<section id="sec-publish" style="display:none">
<h2>Veröffentlichen</h2>
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
<button id="btn-publish">Publish</button>
<div id="pub-status" class="muted"></div>
</section>
<script>
// Base-URL des Backends (separate Subdomain)
const API_BASE = 'https://cms.gallus-pub.ch';
const api = async (path, opts = {}) => {
const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get('content-type') || '';
return ct.includes('application/json') ? res.json() : res.text();
};
async function refreshAuth() {
try {
const me = await api('/api/auth/me');
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
// UI-Bereiche für eingeloggte Nutzer einblenden
document.getElementById('sec-events').style.display = '';
document.getElementById('sec-gallery').style.display = '';
document.getElementById('sec-banner').style.display = '';
document.getElementById('sec-publish').style.display = '';
// Direkt Events laden und auf Sektion fokussieren
await loadEvents();
await loadGallery();
await loadBanners();
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
const el = document.getElementById('auth-status');
el.textContent = 'Nicht angemeldet';
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
document.getElementById('sec-events').style.display = 'none';
document.getElementById('sec-gallery').style.display = 'none';
document.getElementById('sec-banner').style.display = 'none';
document.getElementById('sec-publish').style.display = 'none';
}
}
// Fallback: falls der Link von Browser/Extensions blockiert wäre
const loginLink = document.getElementById('login-link');
loginLink.addEventListener('click', (e) => {
try {
// Stelle sicher, dass Navigieren erzwungen wird
window.location.assign(API_BASE + '/api/auth/gitea');
} catch {}
});
document.getElementById('btn-relogin').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
document.cookie = 'token=; Path=/; Max-Age=0;';
window.location.assign(API_BASE + '/api/auth/gitea');
});
document.getElementById('btn-logout').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
document.cookie = 'token=; Path=/; Max-Age=0;';
await refreshAuth();
});
// ========== Events & Publish ==========
async function uploadEventImage(file) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function uploadGalleryImage(file, altText) {
const fd = new FormData();
fd.append('file', file);
if (altText) fd.append('altText', altText);
fd.append('displayOrder', '0');
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
let reorderMode = false;
let lastEvents = [];
function parseDateSafe(s){
const d = new Date(s);
return isNaN(+d) ? new Date(0) : d;
}
async function loadEvents() {
const listEl = document.getElementById('events-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/events');
listEl.innerHTML = '';
// Merken, globale Liste aktualisieren
lastEvents = (data.events || []).slice();
let renderList = lastEvents.slice();
if (!reorderMode) {
// Automatisch nach Datum sortieren (neueste zuerst)
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
} else {
// Nach displayOrder aufsteigend
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
}
renderList.forEach((ev, idx) => {
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('draggable', String(reorderMode));
card.dataset.id = ev.id;
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
card.innerHTML = `
<div class="row" style="justify-content:space-between;align-items:center">
<div><strong>${ev.title}</strong></div>
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
</div>
<div class="muted">${ev.date}</div>
<div>${ev.description || ''}</div>
${ev.imageUrl ? `<img src="${API_BASE}${ev.imageUrl}" alt="${ev.title}" class="thumb" style="margin-top:0.5rem;" />` : '<div class="muted">Kein Bild</div>'}
<div class="row-buttons">
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
</div>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Event wirklich löschen?')) return;
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
})
})
if (reorderMode) {
enableDragAndDrop(listEl);
}
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
// Drag & Drop Reorder
function enableDragAndDrop(container){
let draggingEl = null;
container.querySelectorAll('.card').forEach(card => {
card.addEventListener('dragstart', (e) => {
draggingEl = card; card.classList.add('dragging');
e.dataTransfer.setData('text/plain', card.dataset.id || '');
});
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
card.addEventListener('dragover', (e) => { e.preventDefault(); });
card.addEventListener('drop', (e) => {
e.preventDefault();
const target = card;
if (!draggingEl || draggingEl === target) return;
const cards = Array.from(container.querySelectorAll('.card'));
const draggingIdx = cards.indexOf(draggingEl);
const targetIdx = cards.indexOf(target);
if (draggingIdx < targetIdx) {
target.after(draggingEl);
} else {
target.before(draggingEl);
}
});
});
}
document.getElementById('btn-create-ev').addEventListener('click', async () => {
const title = (document.getElementById('ev-title')).value.trim();
const date = (document.getElementById('ev-date')).value.trim();
const desc = (document.getElementById('ev-desc')).value.trim();
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
const msg = document.getElementById('ev-create-msg');
msg.textContent = 'Lade Bild hoch...';
try {
let imageUrl = '';
if (file) {
const up = await uploadEventImage(file);
imageUrl = up?.imageUrl || '';
}
msg.textContent = 'Lege Event an...';
await api('/api/events', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, date, description: desc, imageUrl, displayOrder: 0, isPublished: true })
});
msg.textContent = 'Event erstellt';
(document.getElementById('ev-title')).value = '';
(document.getElementById('ev-date')).value = '';
(document.getElementById('ev-desc')).value = '';
(document.getElementById('ev-file')).value = '';
await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
document.getElementById('btn-publish').addEventListener('click', async () => {
const s = document.getElementById('pub-status');
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
s.textContent = 'Veröffentliche...';
try {
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
s.textContent = res?.message || 'Veröffentlicht';
} catch(e){ s.textContent = 'Fehler: '+e.message }
});
// Toggle Reorder
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
reorderMode = !reorderMode;
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
await loadEvents();
});
// Save Order
document.getElementById('btn-save-order').addEventListener('click', async () => {
const container = document.getElementById('events-list');
const cards = Array.from(container.querySelectorAll('.card'));
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
const msg = document.getElementById('order-msg');
msg.textContent = 'Speichere Reihenfolge...';
try {
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
msg.textContent = 'Reihenfolge gespeichert';
// Bleibe in Reorder-Mode oder verlasse ihn? Hier: verlassen
reorderMode = false;
document.getElementById('btn-save-order').style.display = 'none';
await loadEvents();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
// ========== Gallery ==========
async function loadGallery() {
const listEl = document.getElementById('gallery-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/gallery');
listEl.innerHTML = '';
const galleryImages = (data.images || []).slice();
galleryImages.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
galleryImages.forEach((img) => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" class="thumb" />
<div class="muted">${img.altText || ''}</div>
<div class="row-buttons">
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
</div>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Bild wirklich löschen?')) return;
try { await api(`/api/gallery/${id}`, { method: 'DELETE' }); await loadGallery(); } catch(e){ alert('Fehler: '+e.message); }
})
})
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
document.getElementById('btn-create-gal').addEventListener('click', async () => {
const file = /** @type {HTMLInputElement} */ (document.getElementById('gal-file')).files[0];
const alt = (document.getElementById('gal-alt')).value.trim();
const msg = document.getElementById('gal-create-msg');
if (!file) {
msg.textContent = 'Bitte Datei auswählen';
return;
}
msg.textContent = 'Lade Bild hoch...';
try {
await uploadGalleryImage(file, alt);
msg.textContent = 'Bild hochgeladen';
(document.getElementById('gal-file')).value = '';
(document.getElementById('gal-alt')).value = '';
await loadGallery();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
// ========== Banner ==========
async function loadBanners() {
const listEl = document.getElementById('banner-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/banners');
listEl.innerHTML = '';
const bannersList = (data.banners || []).slice();
bannersList.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
bannersList.forEach((banner) => {
const card = document.createElement('div');
card.className = 'card';
const statusText = banner.isActive ? '✓ Aktiv' : '✗ Inaktiv';
card.innerHTML = `
<div><strong>${banner.text.substring(0, 60)}${banner.text.length > 60 ? '...' : ''}</strong></div>
<div class="muted">Von: ${banner.startDate}</div>
<div class="muted">Bis: ${banner.endDate}</div>
<div class="pill">${statusText}</div>
<div class="row-buttons">
<button data-id="${banner.id}" class="btn-toggle-banner">${banner.isActive ? 'Deaktivieren' : 'Aktivieren'}</button>
<button data-id="${banner.id}" class="btn-del-banner">Löschen</button>
</div>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-toggle-banner').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
const banner = bannersList.find(b => b.id === id);
if (!banner) return;
try {
await api(`/api/banners/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: banner.text,
startDate: banner.startDate,
endDate: banner.endDate,
isActive: !banner.isActive
})
});
await loadBanners();
} catch(e){ alert('Fehler: '+e.message); }
})
});
listEl.querySelectorAll('.btn-del-banner').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Banner wirklich löschen?')) return;
try { await api(`/api/banners/${id}`, { method: 'DELETE' }); await loadBanners(); } catch(e){ alert('Fehler: '+e.message); }
})
})
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
document.getElementById('btn-create-banner').addEventListener('click', async () => {
const text = (document.getElementById('banner-text')).value.trim();
const startDate = (document.getElementById('banner-start')).value.trim();
const endDate = (document.getElementById('banner-end')).value.trim();
const msg = document.getElementById('banner-create-msg');
if (!text || !startDate || !endDate) {
msg.textContent = 'Bitte alle Felder ausfüllen';
return;
}
msg.textContent = 'Erstelle Banner...';
try {
await api('/api/banners', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, startDate, endDate, isActive: true })
});
msg.textContent = 'Banner erstellt';
(document.getElementById('banner-text')).value = '';
(document.getElementById('banner-start')).value = '';
(document.getElementById('banner-end')).value = '';
await loadBanners();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
refreshAuth();
</script>
</body>
</html>

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