Author SHA1 Message Date
KenzoandClaude Opus 5 88ad16c86f fix(backend): Publish repariert - spawn git ENOENT und kaputter Image-Build
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
Publish schlug bei jedem Versuch mit "spawn git ENOENT" fehl, obwohl git im
Container installiert ist. Node meldet ENOENT auch dann, wenn das cwd des
Kindprozesses nicht existiert: initialize() setzte simple-git auf den
Workspace, loeschte diesen per rm -rf und startete den Clone anschliessend
aus dem geloeschten Verzeichnis heraus.

Nebeneffekt davon: da Uploads unter GIT_WORKSPACE_DIR/public/images liegen,
hat jeder fehlgeschlagene Publish die hochgeladenen Bilder mitgeloescht.

git.service.ts:
- Clone laeuft aus dem Elternverzeichnis statt aus dem geloeschten Ziel
- Re-Clone nur noch wenn kein brauchbares Repo vorhanden ist
- Uploads werden ueber den Re-Clone hinweg gesichert (Repo-Stand gewinnt)
- commitAndPush scheitert nicht mehr, wenn es nichts zu committen gibt
- reset() nur bei echtem Repo, mit Ausnahme fuer public/images
- Token wird in Fehlermeldungen maskiert

Dockerfile:
- COPY von src/db/migrations entfernt. Das Verzeichnis war nie im Git und
  liess den Image-Build scheitern. Zur Laufzeit wird es nicht gebraucht,
  das Schema legt initDatabase() selbst an.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 09:40:29 +02:00
Gallus-maintanance 45ce268db6 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-16 16:21:46 +00:00
Gallus-maintanance c1ab18fd9f Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-16 12:05:23 +00:00
Gallus-maintanance 52d6457797 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-05 17:26:42 +00:00
Gallus-maintanance 4c9be79282 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-01 11:57:50 +00:00
Gallus-maintanance 324f0f706f Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-01 13:50:34 +00:00
Gallus-maintanance f7a8c1816f Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-12 14:43:01 +00:00
Kenzo d14914a453 chore(pdf): update Getraenke_Gallus_2025 document
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-22 14:30:15 +02:00
Gallus-maintanance 2a1d56d2fc Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-22 12:19:12 +00:00
Gallus-maintanance c8ed065486 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-22 11:52:56 +00:00
Gallus-maintanance 8e6c1924b8 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-20 12:48:48 +00:00
Gallus-maintanance 04dbd0bb0c Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-30 18:21:01 +00:00
Gallus-maintanance 9b697fbb05 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 17:17:36 +00:00
Kenzo 48ae3d8166 Merge remote-tracking branch 'origin/main'
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-21 14:23:06 +01:00
Kenzo 4643ab9b59 feat(Layout): add favicon and logo asset
- Linked a new SVG favicon in the Layout component for better branding.
- Added the corresponding logo.svg file to the public directory.
2026-03-21 14:20:43 +01:00
Gallus-maintanance 7f744de577 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-12 15:19:45 +00:00
Gallus-maintanance 9623abb44a Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-12 15:14:45 +00:00
Kenzo d34c55a40b Refine Drinks component text and update multiple packages to latest versions.
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-11 16:29:17 +01:00
Kenzo 9064d58796 Merge remote-tracking branch 'origin/main'
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-11 16:20:50 +01:00
Kenzo 2ccf195769 Update Welcome and Drinks components with refined text and improved clarity. 2026-03-11 16:20:37 +01:00
Gallus-maintanance 37495cfca8 Update events
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-11 10:27:58 +00:00
Gallus-maintanance 7097aa7336 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-01 02:18:26 +00:00
Gallus-maintanance 0b7d3a45b6 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-25 13:52:30 +00:00
Gallus-maintanance 43f529ad7c Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-30 11:12:24 +00:00
Gallus-maintanance c026a98d1e Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-30 11:08:26 +00:00
Gallus-maintanance 090da72e6b Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-27 11:06:35 +00:00
Gallus-maintanance 94b1d2c3b4 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-16 10:56:42 +00:00
Gallus-maintanance dbe18e6704 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-16 10:53:49 +00:00
Gallus-maintanance 387ef209ab Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-09 13:14:23 +00:00
Kenzo 3b27cbd194 chore(woodpecker): simplify audit file handling
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
Kenzo 61842ebc70 refactor(woodpecker): improve commit message handling for payload preparation
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
Kenzo 4e2418116f feat(woodpecker): enhance npm audit and Discord notification steps
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
Kenzo c1fd535549 refactor(woodpecker): streamline Discord payload handling with temporary file usage
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
Kenzo 78f5da9cff feat(woodpecker): add Discord notifications for build status
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
Kenzo b283816713 refactor(schema): remove redundant comment from users table definition
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-07 16:27:58 +01:00
Gallus-maintanance c77bf3e757 Update events 2026-01-07 15:03:52 +00:00
Kenzo 36b2053642 Add dependency audit step to CI and update package dependencies. 2026-01-07 12:35:54 +01:00
Gallus-maintanance c3898170fd Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-02 11:46:37 +00:00
Kenzo 4cc1b21c05 Reorder components in file-generator.service.ts to display Hero above Banner
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-24 14:27:47 +01:00
Kenzo e41334a7cc Reorder components in index.astro to display Hero above Banner 2025-12-24 14:27:16 +01:00
Gallus-maintanance 47743e9239 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-24 12:55:28 +00:00
Kenzo d271378912 feat: Add Banner component to file generator output
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
Kenzo 4cc6c4f210 refactor(Banner): remove redundant debug logs in loadBanner function
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-22 23:01:25 +01:00
Kenzo fde4adfad5 feat: Add Banner component across Gallery, Openings, and Homepage layouts
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
Kenzo 3e530e0ac5 Merge remote-tracking branch 'origin/main'
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-22 22:44:39 +01:00
Kenzo 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
Gallus-maintanance 3df25da009 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 13:21:25 +00:00
Gallus-maintanance a181993ed5 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 12:59:40 +00:00
Kenzo c289541cd5 refactor(cors): simplify origin validation logic in index.ts
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
Kenzo c9d067b1e3 feat: Support multiple CORS origins and enhance origin validation
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
Kenzo 4533f6cc3d Reorder components in index.astro to display Hero before Banner
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 13:30:01 +01:00
Kenzo 4f12ebaa9a Refactor: Update banner sorting logic to prioritize relevance
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-18 13:24:22 +01:00
Kenzo a7d53ffe21 feat: Improve banner fetching logic and integrate Banner component
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
Kenzo c723e4919d chore: Remove outdated comment in auth route JSON schema definition
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-17 21:51:22 +01:00
Gallus-maintanance f8cbc60a60 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-17 20:10:35 +00:00
Kenzo feec8ed314 feat: Add backend routes and styles for banner management
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
Kenzo 2b64a21f16 feat: Add Banner component for fetching and displaying active banners
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
Kenzo 20feee84a6 Jetzt wird in der Event-Liste das Bild als Vorschau angezeigt mit dem Event-Titel als Alt-Text
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-17 20:54:20 +01:00
Kenzo 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
Gallus-maintanance d0101b2974 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-15 16:38:55 +00:00
Gallus-maintanance 75f0c41e5c Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-15 16:34:32 +00:00
Gallus-maintanance ffadf378f9 Update events
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 16:32:41 +00:00
Gallus-maintanance 5e7425cadf Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 21:06:33 +00:00
Gallus-maintanance 7a067f60ff Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 21:03:20 +00:00
Gallus-maintanance 980200f963 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:50:25 +00:00
Gallus-maintanance de278fab29 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:45:37 +00:00
Gallus-maintanance 160d384143 Update events
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-14 20:45:04 +00:00
Gallus-maintanance f440cbb7f3 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:40:57 +00:00
Gallus-maintanance 72faefc88d Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:36:20 +00:00
Gallus-maintanance e4ada94390 Update events
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-14 20:34:31 +00:00
Gallus-maintanance 4eab0e6dd2 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-14 20:31:47 +00:00
Kenzo 6222d5f19c Revert "images fixing with database saves"
ci/woodpecker/push/woodpecker Pipeline was successful
This reverts commit c45e054787.
2025-12-09 20:26:55 +01:00
Kenzo c45e054787 images fixing with database saves
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 20:12:08 +01:00
Gallus-maintanance 8eb2be8628 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:42:28 +00:00
Gallus-maintanance 3aafda5f70 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:35:29 +00:00
Gallus-maintanance f27e9a0027 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:27:38 +00:00
Kenzo 921d2527e0 Merge remote-tracking branch 'origin/main'
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:25:24 +01:00
Kenzo 901223fcd9 events and gallery backend fix 2025-12-09 19:25:17 +01:00
Gallus-maintanance 357d5ba077 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:22:09 +00:00
Gallus-maintanance 5d0c0a0b17 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 18:15:34 +00:00
Kenzo cb483d8715 picture test
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:08:55 +01:00
Kenzo 3fd5dcf6dd picture test
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:05:37 +01:00
Kenzo 86c2e4e306 picture test
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 19:02:06 +01:00
Kenzo 0f16b944bc picture test 2025-12-09 19:02:06 +01:00
Gallus-maintanance 10752a7337 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:57:06 +00:00
Gallus-maintanance 901b6a11db Update events 2025-12-09 17:54:50 +00:00
Kenzo 10192e2627 feat: Add vips dependencies for sharp in backend Dockerfile
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
Kenzo b1d2f8b441 feat: Add vips dependencies for sharp in backend Dockerfile
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
Gallus-maintanance 5215765588 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:31:37 +00:00
Gallus-maintanance c368be5a27 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:27:33 +00:00
Gallus-maintanance 54de8e36e2 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:25:00 +00:00
Gallus-maintanance 57d7d48d5d Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:16:54 +00:00
Gallus-maintanance b2f04dc726 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 17:13:20 +00:00
Kenzo 6ea6a58532 feat: Add vips dependencies for sharp in backend Dockerfile
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
Kenzo f00a2ef934 feat: Add vips dependencies for sharp in backend Dockerfile
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
Kenzo ccc5c028ba feat: Add vips dependencies for sharp in backend Dockerfile
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
Kenzo 7c96a15c2e feat: Add vips dependencies for sharp in backend Dockerfile
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
Kenzo 7bfb777a74 feat: Add gallery management and dynamic API-based data loading
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
Kenzo 9c3b4be79d Add event image upload endpoint and refactor image upload handling
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
Gallus-maintanance 89640a3372 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 16:29:43 +00:00
Gallus-maintanance 4ed0016be9 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 15:55:21 +00:00
Kenzo 745888d01b Merge remote-tracking branch 'origin/main'
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 16:51:32 +01:00
Kenzo 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
Gallus-maintanance 25305c4aad Update events
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:50:36 +00:00
Kenzo 0597c73690 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 16:46:10 +01:00
Kenzo 0a2aa84a8c Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth. 2025-12-09 16:45:28 +01:00
Kenzo 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
Kenzo 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
Kenzo 4f8feb8652 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
ci/woodpecker/push/woodpecker Pipeline is running
2025-12-09 15:56:55 +01:00
Kenzo 0c291079ff Test: Trigger Woodpecker CI 2025-12-09 15:56:10 +01:00
Kenzo fe2f61cdc2 Simplify Woodpecker pipeline by consolidating when conditions and using secrets for Fly.io authentication 2025-12-09 15:55:51 +01:00
Kenzo af4877300f Add public endpoints and refactor deployments
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
Kenzo 4a103cf7d6 Merge remote-tracking branch 'origin/main'
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:17:25 +01:00
Kenzo 97e7f88906 Revert "ned soll endlich pushen"
This reverts commit 8ca30ae5f3.
2025-12-09 15:16:45 +01:00
Gallus-maintanance 807c56de5a Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:09:39 +00:00
Kenzo 8ca30ae5f3 ned soll endlich pushen
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:45:46 +01:00
Kenzo 8f1254840c anpassungen am woodpecker und fly .toml
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:36:31 +01:00
Kenzo 8b2d00385a test
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:03:36 +01:00
Kenzo c55e274718 woodpecker soll nun auch das backend deployen
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 13:58:39 +01:00
Kenzo e9a95ccf8d Implement cross-domain support for OAuth and API requests
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
Kenzo b16ac76620 Update fly.toml to adjust [env] paths and simplify volume mounts configuration
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-08 18:34:18 +01:00
Kenzo 0e03b9dea9 Fix typo in deployment guide section header
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:27:27 +01:00
Kenzo da3a950a1a Update fly.toml to reference Dockerfile instead of Dockerfile.fly
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:19:01 +01:00
Kenzo fb7eaa6bb2 Merge remote-tracking branch 'origin/feat/cms' into feat/cms
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:10:02 +01:00
Kenzo 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
Kenzo 3b6cb0a3fb Remove CLAUDE.md as it is no longer relevant
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 17:56:22 +01:00
Kenzo 6a3c77d7c5 Merge remote-tracking branch 'origin/feat/cms' into feat/cms 2025-12-08 17:53:06 +01:00
Kenzo 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
Kenzo 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
Kenzo 22494084ce Merge pull request 'feat(backend): initial setup for cms backend service' (#1) from feat/cms into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2025-12-08 09:03:00 +01:00
Kenzo bc6c1e95d3 Merge branch 'main' into feat/cms 2025-12-08 09:02:52 +01:00
Fx64b f2a0422f3b feat(events): add santa karaoke event
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-21 13:57:29 +01:00
Fx64b 9adec32839 build(deps): add missing better-sqlite3 dependency 2025-11-15 17:25:45 +01:00
Fx64b 688b4de945 feat(backend): initial setup for cms backend service 2025-11-15 14:56:43 +01:00
139 changed files with 10992 additions and 633 deletions
+1
View File
@@ -22,3 +22,4 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
/ai/
+167 -7
View File
@@ -1,5 +1,86 @@
steps: 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 image: node:20
environment: environment:
FLY_API_TOKEN: FLY_API_TOKEN:
@@ -7,10 +88,89 @@ steps:
commands: commands:
- curl -L https://fly.io/install.sh | sh - curl -L https://fly.io/install.sh | sh
- export PATH="$HOME/.fly/bin:$PATH" - 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: notify_success:
branch: image: alpine:latest
- main environment:
event: DISCORD_WEBHOOK:
- push 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
+4 -2
View File
@@ -2,7 +2,8 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci # Fallback to npm install if no lockfile is present
RUN npm ci || npm install
COPY . . COPY . .
# Ensure CSS variables are present # Ensure CSS variables are present
RUN mkdir -p public/styles RUN mkdir -p public/styles
@@ -16,7 +17,8 @@ RUN npm install -g serve
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
EXPOSE 3000 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 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1 CMD wget -qO- http://localhost:3000/ || exit 1
+9
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
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
+1
View File
@@ -45,3 +45,4 @@ All commands are run from the root of the project, from a terminal:
## 👀 Want to learn more? ## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). 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
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
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=[email protected]
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
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=[email protected]
GIT_WORKSPACE_DIR=./data/workspace
# JWT & Session secrets (use strong random strings in real deployments)
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
# Server & CORS
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:4321
CORS_ORIGIN=http://localhost:4321
# Upload limits
MAX_FILE_SIZE=5242880
+12
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
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="[email protected]" \
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
+72
View File
@@ -0,0 +1,72 @@
# 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
# Hinweis: Drizzle-Migrationen werden nicht kopiert - das Schema wird zur
# Laufzeit von initDatabase() angelegt, src/db/migrations existiert nicht.
COPY --from=builder /app/dist ./dist
# Copy 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
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
+216
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
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
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
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"
+183
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
View File
File diff suppressed because it is too large Load Diff
+37
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"
}
}
+104
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
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 || '[email protected]',
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
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
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();
+12
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
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;
+152
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;
+104
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;
+166
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;
+223
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;
+127
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;
+121
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;
+190
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();
@@ -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'
);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import simpleGit, { SimpleGit } from 'simple-git';
import { mkdir, rm, cp } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { env } from '../config/env.js';
export class GitService {
private git: SimpleGit;
private workspaceDir: string;
private parentDir: string;
private repoUrl: string;
private token: string;
constructor() {
this.workspaceDir = env.GIT_WORKSPACE_DIR;
this.parentDir = path.dirname(this.workspaceDir);
this.repoUrl = env.GIT_REPO_URL;
this.token = env.GIT_TOKEN;
this.git = simpleGit();
}
async initialize() {
// Elternverzeichnis muss existieren - simple-git startet git von dort aus
await mkdir(this.parentDir, { recursive: true });
// Add token to repo URL for authentication
const authenticatedUrl = this.repoUrl.replace(
'https://',
`https://oauth2:${encodeURIComponent(this.token)}@`
);
let usable = false;
if (existsSync(path.join(this.workspaceDir, '.git'))) {
try {
this.git = simpleGit(this.workspaceDir);
await this.git.status();
console.log('Repository already exists, pulling latest...');
await this.git.pull();
usable = true;
} catch (error) {
// Token aus der Meldung entfernen - git gibt die Remote-URL mit aus
const msg = error instanceof Error ? error.message : String(error);
console.warn(
'Existing workspace unusable, re-cloning:',
msg.replace(/\/\/[^@\s/]*@/g, '//***@')
);
}
}
if (!usable) {
console.log('Cloning repository...');
// Hochgeladene Bilder liegen im Workspace und wuerden beim Loeschen
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
const imagesDir = path.join(this.workspaceDir, 'public', 'images');
const backupDir = path.join(this.parentDir, '.workspace-images-backup');
const hasImages = existsSync(imagesDir);
await rm(backupDir, { recursive: true, force: true });
if (hasImages) {
await cp(imagesDir, backupDir, { recursive: true });
}
await rm(this.workspaceDir, { recursive: true, force: true });
// Aus dem existierenden Elternverzeichnis klonen, nicht aus dem
// soeben geloeschten Zielverzeichnis (sonst: spawn git ENOENT)
this.git = simpleGit(this.parentDir);
await this.git.clone(authenticatedUrl, this.workspaceDir);
this.git = simpleGit(this.workspaceDir);
if (hasImages) {
// force: false -> was schon im Repo liegt, bleibt unangetastet
await cp(backupDir, imagesDir, {
recursive: true,
force: false,
errorOnExist: false,
});
await rm(backupDir, { recursive: true, force: true });
}
}
// 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('.');
const status = await this.git.status();
if (status.isClean()) {
// Nichts zu committen - aktuellen Stand zurueckgeben statt zu scheitern
const current = await this.git.log({ maxCount: 1 });
return current.latest?.hash || '';
}
await this.git.commit(message);
await this.git.push('origin', 'main');
const log = await this.git.log({ maxCount: 1 });
return log.latest?.hash || '';
}
getWorkspacePath(relativePath: string): string {
return path.join(this.workspaceDir, relativePath);
}
async reset() {
// Nur zuruecksetzen wenn wirklich ein Repo da ist - sonst laeuft git
// im Prozess-Arbeitsverzeichnis und raeumt dort auf
if (!existsSync(path.join(this.workspaceDir, '.git'))) {
return;
}
const git = simpleGit(this.workspaceDir);
await git.reset(['--hard', 'HEAD']);
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
await git.clean('f', ['-d', '-e', 'public/images']);
}
}
+112
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');
}
}
+87
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);
}
}
+25
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
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
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:
+7
View File
@@ -9,6 +9,9 @@ kill_timeout = 5
[env] [env]
PORT = "3000" PORT = "3000"
NODE_ENV = "production" NODE_ENV = "production"
BACKEND_PORT = "8080"
DATABASE_PATH = "/app/data/db/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
[http_service] [http_service]
internal_port = 3000 internal_port = 3000
@@ -40,3 +43,7 @@ kill_timeout = 5
memory = "512MB" memory = "512MB"
cpu_kind = "shared" cpu_kind = "shared"
cpus = 1 cpus = 1
[[mounts]]
source = "gallus_data"
destination = "/app/data"
+788 -555
View File
File diff suppressed because it is too large Load Diff
+3114
View File
File diff suppressed because it is too large Load Diff
+3
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: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

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