190 Commits

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

Images are stored in backend/data/images/ which maps to
the persistent Fly.io volume at /app/data/
2025-12-09 16:41:57 +01:00
db3a38ed45 Revert "Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth."
This reverts commit 4f8feb8652.
2025-12-09 16:36:16 +01:00
4f8feb8652 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is running
2025-12-09 15:56:55 +01:00
0c291079ff Test: Trigger Woodpecker CI 2025-12-09 15:56:10 +01:00
fe2f61cdc2 Simplify Woodpecker pipeline by consolidating when conditions and using secrets for Fly.io authentication 2025-12-09 15:55:51 +01:00
af4877300f Add public endpoints and refactor deployments
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Implemented public `/gallery/public` and `/events/public` endpoints for fetching published data without authentication.
- Updated persistent volume configuration for Fly.io across backend and static file serving.
- Adjusted frontend to dynamically fetch events and gallery images from backend API.
- Refined Woodpecker pipeline for clearer separation of backend and frontend deployments.
2025-12-09 15:53:39 +01:00
4a103cf7d6 Merge remote-tracking branch 'origin/main'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:17:25 +01:00
97e7f88906 Revert "ned soll endlich pushen"
This reverts commit 8ca30ae5f3.
2025-12-09 15:16:45 +01:00
807c56de5a Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:09:39 +00:00
8ca30ae5f3 ned soll endlich pushen
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:45:46 +01:00
8f1254840c anpassungen am woodpecker und fly .toml
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:36:31 +01:00
8b2d00385a test
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:03:36 +01:00
c55e274718 woodpecker soll nun auch das backend deployen
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 13:58:39 +01:00
e9a95ccf8d Implement cross-domain support for OAuth and API requests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Updated frontend to use `https://cms.gallus-pub.ch` as the API base URL.
- Configured cookies with `SameSite=None` and `Secure` for production in `auth.ts`.
- Enhanced `fly.toml` to include `FRONTEND_URL`, `CORS_ORIGIN`, and `GITEA_REDIRECT_URI`.
- Adjusted `.gitignore` to ignore `/ai/` directory.
2025-12-09 12:01:49 +01:00
b16ac76620 Update fly.toml to adjust [env] paths and simplify volume mounts configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-08 18:34:18 +01:00
0e03b9dea9 Fix typo in deployment guide section header
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:27:27 +01:00
da3a950a1a Update fly.toml to reference Dockerfile instead of Dockerfile.fly
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:19:01 +01:00
fb7eaa6bb2 Merge remote-tracking branch 'origin/feat/cms' into feat/cms
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:10:02 +01:00
daccc43677 Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 18:09:29 +01:00
3b6cb0a3fb Remove CLAUDE.md as it is no longer relevant
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 17:56:22 +01:00
6a3c77d7c5 Merge remote-tracking branch 'origin/feat/cms' into feat/cms 2025-12-08 17:53:06 +01:00
a28d43db45 Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 17:51:46 +01:00
af930f345c Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 16:00:40 +01:00
22494084ce Merge pull request 'feat(backend): initial setup for cms backend service' (#1) from feat/cms into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2025-12-08 09:03:00 +01:00
bc6c1e95d3 Merge branch 'main' into feat/cms 2025-12-08 09:02:52 +01:00
f2a0422f3b feat(events): add santa karaoke event
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-21 13:57:29 +01:00
2cae2e86ed Merge pull request 'feat(events): update event details and add new events for Karaoke and Pub Quiz' (#3) from feat/events-december-25 into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #3
2025-11-19 14:12:50 +01:00
636c7fc03a feat(events): update event details and add new events for Karaoke and Pub Quiz 2025-11-19 14:11:44 +01:00
5fdea37a90 Merge pull request 'Neue Events für Dezember 25' (#2) from feat/events-december-25 into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #2
2025-11-18 23:09:49 +01:00
11932d51ec feat(events): add new events for Adventskalender, Weihnachtsferien, and Neujahrs-Apero with corresponding images 2025-11-18 20:14:53 +01:00
803c7907f1 fix(HoverCard): add type assertion for event target in click handler 2025-11-18 20:05:17 +01:00
3d4bbf77bc feat(events): add new event schlager karaoke 2025-11-18 20:05:01 +01:00
71a586280e refactor(images): add new folders to categorize events, gallery, whiskey 2025-11-18 19:12:56 +01:00
1f4cea0c35 fix(hero): add correct id in Hero to fix scroll button 2025-11-18 18:42:21 +01:00
9adec32839 build(deps): add missing better-sqlite3 dependency 2025-11-15 17:25:45 +01:00
688b4de945 feat(backend): initial setup for cms backend service 2025-11-15 14:56:43 +01:00
193f3ff0bb Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-10 15:12:26 +01:00
292747d197 Remove outdated event entries from index.astro 2025-11-10 15:12:12 +01:00
18f7ea5da5 Remove outdated event entries from index.astro
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is running
2025-11-10 11:09:16 +01:00
1f94bbca15 Remove redundant event timing and reservation details from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:58:43 +02:00
5ef15f0b5c Update event time and pricing details in index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:57:29 +02:00
020bfca731 Remove commented file reference from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:47:59 +02:00
ac864ba054 Update event details and assets in index.astro and public/images
- Swapped event titles, dates, and images for better accuracy.
- Replaced `kevin_mcflannigan.png` with `Event4.png`.
- Updated `Menu.pdf`.
2025-10-25 16:29:39 +02:00
e93ba5d29b Remove outdated event details from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-23 11:06:37 +02:00
k
feb137471d Update event details and images for improved content clarity and presentation.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-21 15:31:37 +02:00
k
0622d190d1 Remove comment.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-17 12:14:05 +02:00
k
2867678223 Remove tappable hint text from HoverCard styles. 2025-09-17 12:06:25 +02:00
k
096ac9f789 Update Getränkekarte PDF link to point to Getraenke_Gallus_2025.pdf.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-06 14:29:18 +02:00
k
3006ccd5a0 Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 15:12:38 +02:00
k
8a8bcc304a Enhance HoverCard behavior and styles for better mobile interactivity and accessibility:
- **Hover support refinement**: Limited hover effects to devices with pointer precision and hover capability.
- **Active state improvements**: Added visual feedback for tap and ensured consistent card toggling on mobile, including outside-click handling.
- **Styling additions**: Introduced a tappable hint for better user guidance and refined cursor styles.
- **Script update**: Prevented multiple active cards and ensured seamless closing on external clicks.
2025-08-05 15:12:26 +02:00
k
54c6f205e0 Rename Menu.pdf to Getraenke_Gallus_2025.pdf in the public/pdf directory.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:18:10 +02:00
k
48fddf7b15 Rename Menu.pdf to Getraenke_Gallus_2025.pdf in the public/pdf directory.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:04:09 +02:00
k
2733c2e7f4 Remove redundant whiskey circle styles and update menu PDF.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:01:31 +02:00
k
9502123b89 Remove Kevin McFlannigan details from events and update circle dimensions to use vh for better responsiveness.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 13:47:09 +02:00
ca2d724bd8 Update Drinks.css and index.astro for style adjustments and event details update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Replaced `em` with `rem` in `.circle` dimensions for consistent scaling.
- Revised event descriptions and titles in `index.astro` for clarity.
- Updated `Menu.pdf` file.
2025-08-04 14:17:28 +02:00
k
38229ac5e9 Refine Drinks section text and styling:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Content updates**: Adjusted description text for a more engaging and detailed presentation.
- **Styling changes**: Reduced title margin, enlarged circle dimensions for better visual balance, and added spacing to card links.
- **Layout improvements**: Removed redundant whiskey circle styles for cleaner CSS.
2025-08-02 15:54:09 +02:00
k
a11c838d2a Update gallery images and refine Drinks section:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Gallery updates**: Added `Gallery9.png` and reordered images for better organization.
- **Drinks section tweak**: Removed redundant label text in "Mate Vodka" circle.
2025-08-02 15:43:46 +02:00
k
f9fe914c32 Update images, enhance Drinks section, and adjust styles:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Image updates**: Replaced placeholder images with new gallery images (`Gallery1.png` to `Gallery8.png`) and added whiskey images (`Whiskey1.png` to `Whiskey3.png`).
- **Drinks section**: Added description, updated drink types, and included new images for Whiskey options and Monthly Special.
- **Circular design refinements**: Enlarged circle dimensions, ensured image fit with `object-fit: cover`, and added responsive adjustments.
- **Style improvements**: Introduced overflow handling, z-index management, and tailored sizes for Whiskey circles.
2025-08-02 15:36:14 +02:00
k
21e09f7155 Update images, enhance Drinks section, and adjust styles:
- **Image updates**: Replaced placeholder images with new gallery images (`Gallery1.png` to `Gallery8.png`) and added whiskey images (`Whiskey1.png` to `Whiskey3.png`).
- **Drinks section**: Added description, updated drink types, and included new images for Whiskey options and Monthly Special.
- **Circular design refinements**: Enlarged circle dimensions, ensured image fit with `object-fit: cover`, and added responsive adjustments.
- **Style improvements**: Introduced overflow handling, z-index management, and tailored sizes for Whiskey circles.
2025-08-02 15:32:51 +02:00
k
0b37f73634 Enhance Header interactivity and clean up HoverCard structure:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Mobile menu improvements**: Added functionality to close the menu when navigation links are clicked.
- **HoverCard cleanup**: Removed unused title and date markup for a leaner structure.
2025-08-02 14:49:04 +02:00
k
c764f892a1 Add active state for mobile HoverCard
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **New functionality**: Introduced an active state for `HoverCard` on mobile devices, triggered via click.
- **Style updates**: Added styles to handle `active` class for text and image opacity.
- **Responsive aspect ratio**: Ensured square aspect ratio with the addition of `aspect-ratio: 1 / 1` in CSS.
- **Script enhancement**: Implemented JavaScript for toggling the active state on mobile.
2025-08-02 14:41:16 +02:00
k
78f367530a Refactor Header and HoverCard styles for consistency and responsiveness:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Standardized dimensions**: Converted `px` values to `rem` in `HoverCard.css`.
- **Improved header layout**: Enhanced mobile-first responsiveness with distinct layouts for desktop and mobile.
- **New navigation styles**: Adjusted alignment, spacing, and transitions for the burger menu and mobile dropdown.
- **Optimized media queries**: Simplified and organized breakpoints for better maintainability.
2025-08-02 14:35:34 +02:00
k
b539329420 Add responsive mobile menu with burger icon and animations:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Mobile menu implementation**: Introduced a burger menu for smaller screens, enabling navigation toggle.
- **Responsive styles**: Added CSS adjustments for headers and navigation under `768px` and `480px` breakpoints.
- **Interactive behavior**: Implemented toggle functionality using JavaScript for mobile menu activation.
- **Styling updates**: Enhanced mobile and desktop navigation distinction with scoped CSS changes.
2025-08-02 14:30:49 +02:00
k
3e93e8ce3b Update events, images, and improve layout:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Events update**: Replaced existing events with new details and images.
- **Image updates**: Replaced outdated images with optimized ones (e.g., `Event1.png`, `Event2.png`, `Event3.png`).
- **Footer improvements**: Added `id` to enable smooth scrolling for "Contact".
- **Welcome section**: Adjusted heading structure for better readability.
- **Navigation fix**: Replaced "Openings" link with "Contact".
- **CSS cleanup**: Removed redundant comment in `Hero.css` and fixed typos in alt text.
2025-08-02 14:27:11 +02:00
k
2fab4bf70b Refactor styles and imports for consistency and maintainability:
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- **CSS restructuring**: Moved `styles` folder into `src` for better organization.
- **Updated imports**: Adjusted component CSS imports to reflect new paths.
- **Component tweaks**:
  - Increased HoverCard width from `350px` to `400px` for better visual balance.
  - Adjusted Footer layout: reorganized copyright and added email link.
  - Modified Drinks circle dimensions (from `6em` to `9em`) for improved design.
- **Footer styles**: Changed copyright section's layout with top spacing and border adjustments.
2025-08-02 13:50:56 +02:00
k
1a6be67af1 Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
# Conflicts:
#	styles/components/ContactForm.css
2025-08-02 13:35:59 +02:00
k
fea45fc4f8 rm command 2025-08-02 13:35:38 +02:00
761bd6be80 Update CSS imports and Dockerfile for consistent styles handling
- Enforced `!important` for `--color-background` in `variables.css`.
- Centralized CSS imports in `Layout.astro` for cleaner markup.
- Adjusted Dockerfile to ensure CSS variables are copied to `public/styles`.
- Refined fallback handling in `index.css` for `background-color`.
2025-08-02 12:57:17 +02:00
8e6bd12da5 Remove unused cache and name fields from .woodpecker.yml for cleaner pipeline configuration 2025-08-02 12:57:16 +02:00
548a2d6f53 Refactor .woodpecker.yml for streamlined deployment and simplify Dockerfile health check 2025-08-02 12:57:16 +02:00
01edb8d575 Update deployment configuration files for optimized CI/CD pipeline
- Simplified `fly.toml` by removing inline comments.
- Refactored `.woodpecker.yml` pipeline syntax for better clarity and flexibility.
- Adjusted `Dockerfile` to fix `dist` directory path and streamline `CMD` execution.
2025-08-02 12:57:16 +02:00
c498b19afb Update fly.toml, .woodpecker.yml, and Dockerfile for deployment improvements
- Increased health check grace period in `fly.toml` to 30s.
- Added `dist` directory to cache mounts in `.woodpecker.yml`.
- Fixed file copy path in `Dockerfile` for accurate builds.
2025-08-02 12:57:16 +02:00
74a8e7b393 make main a version off dev 2025-08-02 12:57:16 +02:00
9c4b6ec425 Text rearangment 2025-08-02 12:57:14 +02:00
dc3f0b53d7 Improve logo responsiveness and container padding in styles.css
- Updated `.logo-container` for full-width responsiveness.
- Adjusted `.logo-container img` to maintain aspect ratio and center align.
- Added padding to `.container` for better layout spacing.
2025-08-02 12:57:14 +02:00
b215592292 Update footer styles for improved readability
- Changed `background-color` to a darker shade for better contrast.
- Added white text color for enhanced visibility.
2025-08-02 12:57:14 +02:00
9c7ecc97df Fix typo in "Coming Soon" text in index.html 2025-08-02 12:57:14 +02:00
0fd4fbe61f Remove .dockerignore file to avoid redundant configuration 2025-08-02 12:57:14 +02:00
6e489ceac3 Remove .dockerignore file to avoid redundant configuration 2025-08-02 12:57:14 +02:00
21d51732e5 Add .dockerignore and update Fly.io configuration
- Created `.dockerignore` to exclude irrelevant files during Docker image build.
- Enhanced `fly.toml` with additional configuration such as VM specs and adjusted syntax.
- Fixed logo path in `index.html` and relocated the logo file for proper referencing.
2025-08-02 12:57:12 +02:00
f1c94ed438 Update Gallus Pub site design and content
- Changed background and text colors in `styles.css` for a refreshed look.
- Updated logo implementation in `index.html` to use an actual image.
- Adjusted social links and opening hours information.
- Added Font Awesome library to the project configuration.
- Included new logo image in the `public` directory.
2025-08-02 12:57:02 +02:00
493c2a94f0 Remove Gallus_Pub and Gallus_Pub_v1 directories along with associated configuration and files
This commit deletes the entire setup for both Gallus_Pub and Gallus_Pub_v1 projects, including `.gitignore`, configuration files, source code, styles, package files, and other related assets.
2025-08-02 12:56:52 +02:00
3a3a36e2ea Update CSS imports and Dockerfile for consistent styles handling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Enforced `!important` for `--color-background` in `variables.css`.
- Centralized CSS imports in `Layout.astro` for cleaner markup.
- Adjusted Dockerfile to ensure CSS variables are copied to `public/styles`.
- Refined fallback handling in `index.css` for `background-color`.
2025-07-23 21:57:33 +02:00
535c82bd81 Remove unused cache and name fields from .woodpecker.yml for cleaner pipeline configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-07-23 21:38:28 +02:00
64aa08c699 Refactor .woodpecker.yml for streamlined deployment and simplify Dockerfile health check
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-07-23 21:36:36 +02:00
6f3edc8977 Update deployment configuration files for optimized CI/CD pipeline
- Simplified `fly.toml` by removing inline comments.
- Refactored `.woodpecker.yml` pipeline syntax for better clarity and flexibility.
- Adjusted `Dockerfile` to fix `dist` directory path and streamline `CMD` execution.
2025-07-23 21:32:24 +02:00
9ac87b82e9 Update fly.toml, .woodpecker.yml, and Dockerfile for deployment improvements
- Increased health check grace period in `fly.toml` to 30s.
- Added `dist` directory to cache mounts in `.woodpecker.yml`.
- Fixed file copy path in `Dockerfile` for accurate builds.
2025-07-23 21:29:27 +02:00
74e4799ea9 make main a version off dev 2025-07-23 21:25:24 +02:00
0a939975c3 Remove About and Contact sections from navigation and index page
This commit comments out the About and Contact links in the navigation menu and removes the corresponding sections from the index page. Additionally, adjusted header styles for a full-width layout.
2025-07-23 21:08:38 +02:00
7e0f052ce7 Anpassungen damit das Navi ein OnePager wird, die verlinkung von About und Contact stimmt noch nicht. 2025-07-23 16:48:04 +02:00
77c5d5df82 event hinzugefügt und erste anpassungen am nav für onePager 2025-07-23 16:39:01 +02:00
f0afa677a0 Event Cards Update und alle links sind Orange, einheitlich auf der ganzen Seite 2025-07-23 14:56:34 +02:00
f356b37c9e Rredundantz in Header.css und Hero.css normalisieren 2025-07-23 12:49:23 +02:00
k
096883b0ee Add contact form and WhatsApp integration
- Created a new, styled contact form with input validation.
- Added WhatsApp Chat link for direct communication.
- Introduced `ContactForm.css` with responsive styles to improve usability.
2025-07-20 14:28:13 +02:00
k
749b3e5079 Event Header fix 2025-07-20 13:59:45 +02:00
k
3c1a6fae2c Update styles and layout for improved responsiveness and consistency:
- **Global fonts**: Adjusted `font-family` fallback to include `serif`.
- **Header improvements**: Refined structure with flexbox, adjusted heights, and fixed responsive issues (e.g., removed hardcoded margins).
- **New Layout styles**: Added scoped `Layout.css` for better layout control and modularity.
- **Links fix**: Corrected casing in `About` and `Contact` links for proper navigation.
2025-07-20 13:57:33 +02:00
k
f3952e7e81 Add section IDs and smooth scrolling for improved navigation:
- Updated components (`Hero`, `Welcome`, `EventsGrid`, `ImageCarousel`, `Drinks`) to accept optional `id` props and applied them to `<section>` elements.
- Enabled smooth scrolling by adding `scroll-behavior: smooth` to global styles.
- Enhanced dropdown menu: added internal links for section navigation and adjusted styling for improved responsiveness.
- Updated color handling in `ImageCarousel` to use CSS variables for better theme consistency.
2025-07-20 13:32:43 +02:00
k
00213204c4 Add ImageCarousel component with responsive design and functionality.
- Introduced a new `ImageCarousel` component to display image galleries with navigation and indicators.
- Included scoped CSS for custom styles and responsiveness.
- Integrated `ImageCarousel` into the homepage with sample image data.
2025-07-20 12:57:40 +02:00
15dedfabcf Remove Gallus_Pub project and all related files
This commit deletes the entire `Gallus_Pub` directory, including its configuration, source files, and dependencies.
2025-07-19 18:00:06 +02:00
5247bd9816 Remove comments from fly.toml for cleaner configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-07-16 23:11:28 +02:00
50c06b3a8a Text rearangment
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-07-15 21:42:50 +02:00
5ab62f2b3b Improve logo responsiveness and container padding in styles.css
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Updated `.logo-container` for full-width responsiveness.
- Adjusted `.logo-container img` to maintain aspect ratio and center align.
- Added padding to `.container` for better layout spacing.
2025-07-15 21:36:53 +02:00
6120f04c95 Update footer styles for improved readability
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Changed `background-color` to a darker shade for better contrast.
- Added white text color for enhanced visibility.
2025-07-15 21:32:57 +02:00
179de67386 Fix typo in "Coming Soon" text in index.html
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-07-15 21:25:12 +02:00
3da1b63a50 Remove .dockerignore file to avoid redundant configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-07-15 21:23:52 +02:00
6b79e08684 Remove .dockerignore file to avoid redundant configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-07-15 21:22:26 +02:00
7d5e77df76 Add .dockerignore and update Fly.io configuration
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Created `.dockerignore` to exclude irrelevant files during Docker image build.
- Enhanced `fly.toml` with additional configuration such as VM specs and adjusted syntax.
- Fixed logo path in `index.html` and relocated the logo file for proper referencing.
2025-07-15 21:20:55 +02:00
23b47a7e85 Add Fly.io secret to .woodpecker.yml pipeline
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Configured `FLY_API_TOKEN` environment variable from secrets for deployment pipeline.
2025-07-15 21:12:50 +02:00
f4c75ea941 Refactor .woodpecker.yml pipeline configuration
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Renamed pipeline to "deploy" for clarity.
- Adjusted `when` conditions structure for branch and event targeting.
2025-07-15 21:10:11 +02:00
58522f2ae0 Add deployment pipeline with Fly.io setup
- Introduced `.woodpecker.yml` for CI/CD pipeline configuration targeting `main` branch.
- Added `fly.toml` for Fly.io deployment settings, including region and deployment checks.
- Created a `Dockerfile` for serving static files via NGINX with SPA routing setup.
2025-07-15 21:08:24 +02:00
2a0aa7a6c8 Update Gallus Pub site design and content
- Changed background and text colors in `styles.css` for a refreshed look.
- Updated logo implementation in `index.html` to use an actual image.
- Adjusted social links and opening hours information.
- Added Font Awesome library to the project configuration.
- Included new logo image in the `public` directory.
2025-07-15 20:30:11 +02:00
bcd86c9c68 Remove Gallus_Pub and Gallus_Pub_v1 directories along with associated configuration and files
This commit deletes the entire setup for both Gallus_Pub and Gallus_Pub_v1 projects, including `.gitignore`, configuration files, source code, styles, package files, and other related assets.
2025-07-15 20:16:25 +02:00
204 changed files with 16485 additions and 14343 deletions

View File

@ -1,5 +1,6 @@
# build output # build output
dist/ dist/
# generated types # generated types
.astro/ .astro/
@ -12,7 +13,6 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# environment variables # environment variables
.env .env
.env.production .env.production
@ -22,3 +22,4 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
/ai/

8
.idea/.gitignore generated vendored
View File

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

13
.idea/Gallus_Pub.iml generated
View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="font-awesome" level="application" />
</component>
</module>

8
.idea/modules.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Gallus_Pub.iml" filepath="$PROJECT_DIR$/.idea/Gallus_Pub.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,3 +0,0 @@
{
"editor.formatOnSave": false
}

176
.woodpecker.yml Normal file
View File

@ -0,0 +1,176 @@
steps:
audit_dependencies:
image: node:20
commands:
- npm install --package-lock-only
- npm audit --audit-level=moderate --json > audit-result.json 2>&1 || echo "Audit completed"
- npm audit --audit-level=moderate > audit-output.txt 2>&1 || echo "Audit completed"
when:
- branch: main
event: push
discord_notify_audit:
image: alpine:latest
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
if [ -f audit-result.json ]; then
TOTAL=$(jq -r '.metadata.vulnerabilities.total // 0' audit-result.json 2>/dev/null || echo "0")
CRITICAL=$(jq -r '.metadata.vulnerabilities.critical // 0' audit-result.json 2>/dev/null || echo "0")
HIGH=$(jq -r '.metadata.vulnerabilities.high // 0' audit-result.json 2>/dev/null || echo "0")
MODERATE=$(jq -r '.metadata.vulnerabilities.moderate // 0' audit-result.json 2>/dev/null || echo "0")
LOW=$(jq -r '.metadata.vulnerabilities.low // 0' audit-result.json 2>/dev/null || echo "0")
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ] || [ "$MODERATE" -gt 0 ]; then
COLOR=16744448
STATUS="⚠️ Vulnerabilities Found"
else
COLOR=3066993
STATUS="✅ No Vulnerabilities"
fi
if [ -f audit-output.txt ]; then
VULNS=$(head -50 audit-output.txt | tail -40 || echo "No details")
else
VULNS="No audit output available"
fi
printf '%s' "$VULNS" > /tmp/vulns.txt
PAYLOAD=$(jq -n \
--arg title "🔒 Security Audit - Build #${CI_BUILD_NUMBER}" \
--arg status "$STATUS" \
--arg total "$TOTAL" \
--arg critical "$CRITICAL" \
--arg high "$HIGH" \
--arg moderate "$MODERATE" \
--arg low "$LOW" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--rawfile details /tmp/vulns.txt \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
--argjson color "$COLOR" \
'{
embeds: [{
title: $title,
description: $status,
color: $color,
fields: [
{ name: "Total", value: $total, inline: true },
{ name: "Critical", value: $critical, inline: true },
{ name: "High", value: $high, inline: true },
{ name: "Moderate", value: $moderate, inline: true },
{ name: "Low", value: $low, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Details", value: ("```\n" + ($details[:800]) + (if ($details | length) > 800 then "\n... (truncated)" else "" end) + "\n```"), inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
else
echo "No audit results found - listing workspace files:"
ls -la
fi
when:
- branch: main
event: push
deploy_frontend:
image: node:20
environment:
FLY_API_TOKEN:
from_secret: FLY_API_TOKEN
commands:
- curl -L https://fly.io/install.sh | sh
- export PATH="$HOME/.fly/bin:$PATH"
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
when:
- branch: main
event: push
notify_success:
image: alpine:latest
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
--arg title "✅ Build #${CI_BUILD_NUMBER} - Success" \
--arg repo "${CI_REPO}" \
--arg branch "${CI_COMMIT_BRANCH}" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--arg author "${CI_COMMIT_AUTHOR}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'. as $message | {
embeds: [{
title: $title,
description: "Build und Deployment erfolgreich abgeschlossen!",
color: 3066993,
fields: [
{ name: "Repository", value: $repo, inline: true },
{ name: "Branch", value: $branch, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Author", value: $author, inline: true },
{ name: "Commit Message", value: $message, inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
when:
- branch: main
event: push
status: success
notify_failure:
image: alpine:latest
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
--arg title "❌ Build #${CI_BUILD_NUMBER} - Failure" \
--arg repo "${CI_REPO}" \
--arg branch "${CI_COMMIT_BRANCH}" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--arg author "${CI_COMMIT_AUTHOR}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'. as $message | {
embeds: [{
title: $title,
description: "Build oder Deployment ist fehlgeschlagen!",
color: 15158332,
fields: [
{ name: "Repository", value: $repo, inline: true },
{ name: "Branch", value: $branch, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Author", value: $author, inline: true },
{ name: "Commit Message", value: $message, inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
when:
- branch: main
event: push
status: failure

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
# Fallback to npm install if no lockfile is present
RUN npm ci || npm install
COPY . .
# Ensure CSS variables are present
RUN mkdir -p public/styles
RUN cp -r styles/* public/styles/ || true
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
RUN npm install -g serve
COPY --from=build /app/dist ./dist
EXPOSE 3000
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
CMD ["serve", "-l", "3000", "dist"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1

9
Dockerfile.caddy Normal file
View File

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

37
Gallus_Pub/.gitignore vendored
View File

@ -1,37 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo

View File

@ -1,28 +0,0 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { View } from 'react-native';
import LoginScreen from './src/pages/LoginPage';
import AdminScreen from './src/pages/AdminPage';
import HomeScreen from './src/pages/HomePage';
import ProtectedRoute from './src/components/ProtectedRoute';
import { AuthProvider } from './src/AuthContext';
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<View style={{ flex: 1 }}>
<Routes>
<Route path="/" element={<HomeScreen />} />
<Route path="/admin/login" element={<LoginScreen />} />
<Route path="/admin" element={
<ProtectedRoute>
<AdminScreen />
</ProtectedRoute>
} />
</Routes>
</View>
</BrowserRouter>
</AuthProvider>
);
}

View File

@ -1,29 +0,0 @@
{
"expo": {
"name": "Gallus_Pub",
"slug": "Gallus_Pub",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,8 +0,0 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
{
"name": "gallus_pub",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~5.0.4",
"expo": "~53.0.9",
"expo-status-bar": "~2.2.3",
"react": "19.0.0",
"react-native": "0.79.2",
"react-native-web": "^0.20.0",
"react-router-dom": "^7.6.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~19.0.10",
"typescript": "~5.8.3"
},
"private": true
}

View File

@ -1,44 +0,0 @@
import React, { createContext, useState, useContext, ReactNode } from 'react';
type AuthContextType = {
isAdmin: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [isAdmin, setIsAdmin] = useState(false);
const login = async (username: string, password: string) => {
// Hier würden Sie normalerweise API-Anfragen machen, um die Anmeldedaten zu überprüfen
// Einfache Validierung für das Beispiel:
if (username === 'admin' && password === 'password') {
setIsAdmin(true);
// In einer echten Anwendung würden Sie hier einen Token im localStorage speichern
localStorage.setItem('isAdmin', 'true');
return;
}
throw new Error('Falsche Anmeldedaten');
};
const logout = () => {
setIsAdmin(false);
localStorage.removeItem('isAdmin');
};
return (
<AuthContext.Provider value={{ isAdmin, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth muss innerhalb eines AuthProviders verwendet werden');
}
return context;
};

View File

@ -1,20 +0,0 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
type ProtectedRouteProps = {
children: React.ReactNode;
};
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAdmin } = useAuth();
if (!isAdmin) {
// Wenn nicht angemeldet, zur Login-Seite umleiten
return <Navigate to="/admin/login" replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@ -1,35 +0,0 @@
import React from 'react';
import { useAuth } from '../AuthContext';
const AdminPage: React.FC = () => {
const { logout } = useAuth();
return (
<div style={styles.container}>
<h1>Admin</h1>
<p>Willkommen im Admin-Bereich! Hier können Sie Ihr Gallus Pub verwalten.</p>
<button onClick={logout} style={styles.button}>Abmelden</button>
</div>
);
};
const styles = {
container: {
maxWidth: '800px',
margin: '50px auto',
padding: '20px',
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
borderRadius: '5px',
},
button: {
padding: '10px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
marginTop: '20px',
}
};
export default AdminPage;

View File

@ -1,23 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
export default function HomeScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Willkommen bei Gallus Pub</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
});

View File

@ -1,93 +0,0 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
const LoginPage: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(username, password);
navigate('/admin');
} catch (err) {
setError('Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.');
}
};
return (
<div style={styles.container}>
<h2>Admin-Login</h2>
{error && <p style={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} style={styles.form}>
<div style={styles.inputGroup}>
<label htmlFor="username">Benutzername:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
style={styles.input}
required
/>
</div>
<div style={styles.inputGroup}>
<label htmlFor="password">Passwort:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
required
/>
</div>
<button type="submit" style={styles.button}>Anmelden</button>
</form>
</div>
);
};
const styles = {
container: {
maxWidth: '400px',
margin: '100px auto',
padding: '20px',
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
borderRadius: '5px',
},
form: {
display: 'flex',
flexDirection: 'column' as 'column',
},
inputGroup: {
marginBottom: '15px',
},
input: {
width: '100%',
padding: '10px',
borderRadius: '3px',
border: '1px solid #ddd',
marginTop: '5px',
},
button: {
padding: '10px',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
},
error: {
color: 'red',
marginBottom: '15px',
}
};
export default LoginPage;

View File

@ -1,6 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

View File

@ -1,26 +0,0 @@
pipeline:
build:
image: node:20-alpine
commands:
- npm ci
- npm run build
when:
branch: main
event: [push, pull_request]
deploy:
depends_on: [build]
image: flyio/flyctl:latest
secrets: [fly_api_token]
commands:
- flyctl deploy --remote-only
when:
branch: main
event: push
branches:
include: [main, dev]
cache:
mount:
- node_modules
- .npm

View File

@ -1,25 +0,0 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
RUN npm install -g serve
COPY --from=build /app/dist /app
EXPOSE 3000
CMD ["serve", "-s", ".", "-l", "3000"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1

View File

@ -1,47 +0,0 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,33 +0,0 @@
---
import "../../styles/components/Drinks.css"
---
<section class="Drinks">
<h2 class="title">Drinks</h2>
<a href="/pdf/Menu.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="Mate Vodka">
<span class="circle-label">Mate Vodka</span>
</div>
<div>Mate Vodka</div>
</div>
<div class="circle-row">
<div class="circle" title="Bier">
<span class="circle-label">Bier</span>
</div>
<div class="circle" title="Wein">
<span class="circle-label">Wein</span>
</div>
<div class="circle" title="Cocktails">
<span class="circle-label">Cocktails</span>
</div>
</div>
<p class="note">
Wir bieten eine Auswahl an erlesenen Getränken für jeden Geschmack. Besuche uns und entdecke unsere saisonalen Spezialitäten und Klassiker.
</p>
</section>

View File

@ -1,27 +0,0 @@
---
// src/components/EventsGrid.astro
import HoverCard from "./HoverCard.astro";
interface Event {
image: string;
title: string;
date: Date;
description: string;
}
const { events = [] }: { events?: Event[] } = Astro.props as { events?: Event[] };
import '../../styles/components/EventsGrid.css';
---
<section class="events-gird container">
{events.map((event: Event) => (
<HoverCard
title={event.title}
date={event.date}
description={event.description}
image={event.image}
/>
))}
</section>

View File

@ -1,39 +0,0 @@
---
// src/components/Header.astro
const { url } = Astro;
import "../../styles/components/Header.css"
---
<header class="header">
<div class="logo-container">
<a href="/">
<img src="/images/Logo.png" alt="Logo" class="logo">
</a>
</div>
<!-- Hauptnavigation: immer Home, About, Contact -->
<nav class="nav-main">
<div class="dropdown">
<a href="/" class="dropdbtn">Home</a>
<div class="dropdown-content">
<a href="/events">Events</a>
<a href="/gallery">Gallery</a>
<a href="/openings">Openings</a>
<a href="/drinks">Drinks</a>
</div>
</div>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>
<div class="header-spacer"></div>

View File

@ -1,21 +0,0 @@
---
// src/components/HoverCard.astro
import "../../styles/components/HoverCard.css"
const { title, description, image = "", date} = Astro.props;
---
<article class="hover-card">
<div class="image-container">
<img class="card-image" src={image} alt={title} />
</div>
<h3 class="card-title">{title}</h3>
<h4 class="card_date">{date}</h4>
<div class="hover-text">
<div>
<p>{description}</p>
</div>
</div>
</article>

View File

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

View File

@ -1,23 +0,0 @@
---
// src/pages/index.astro
import Layout from "../components/Layout.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";
const events = [
{image: '/images/Logo.png', title: 'Karaoke Night', date: 'Mi, 23. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua' },
{image: '/images/Logo.png', title: 'Pub Quiz', date: 'Fr, 25. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptuaLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua' },
{image: '/images/Logo.png', title: 'Live-Musik', date: 'Sa, 26. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod' },
{image: '/images/Logo.png', title: 'Cocktail-Abend', date: 'So, 27. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua' }
];
---
<Layout>
<Hero />
<Welcome />
<EventsGrid events={events} />
<Drinks />
</Layout>

View File

@ -1,94 +0,0 @@
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: rgba(26, 26, 26, 0.95);
z-index: 1000;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(5px);
}
.header-spacer {
height: 70px; /* Should match the header height */
}
.logo-container {
display: flex;
align-items: center;
}
.logo {
margin-left: 2em;
height: 4em;
width: auto;
transition: transform 0.3s ease;
}
.logo:hover {
transform: scale(1.05);
}
.nav-main {
display: flex;
align-items: center;
margin-right: 377px;
}
.nav-main a,
.dropdbtn {
margin: 0 1rem;
color: white;
text-decoration: none;
font-size: 1rem;
cursor: pointer;
}
.nav-main a:hover,
.dropdbtn:hover {
color: #ffa500;
}
/* Dropdown für Home */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #0e0c0c;
min-width: 160px;
z-index: 1;
border: 1px solid #444;
border-radius: 4px;
}
.dropdown-content a {
color: #f5f5f5;
padding: 0.5rem 1rem;
text-decoration: none;
display: block;
}
.dropdown-content a:hover {
background-color: #1f1a1a;
color: #ffa500;
}
.dropdown:hover .dropdown-content {
display: block;
}
@media (max-width: 768px) {
.nav-container {
padding: 1rem;
}
.nav-links {
gap: 1rem;
}
}

View File

@ -1,128 +0,0 @@
/* === Header & Nav === */
.header {
background-color: #0e0c0c;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid #444;
}
.hero-overlay {
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 1)), url('/images/Background.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
margin-top: 2em;
}
h1 {
padding-top: 2em;
}
.nav-main {
display: flex;
align-items: center;
margin-right: 377px;
}
.nav-main a,
.dropdbtn {
margin: 0 1rem;
color: white;
text-decoration: none;
font-size: 1rem;
cursor: pointer;
}
.nav-main a:hover,
.dropdbtn:hover {
color: #ffa500;
}
/* Dropdown für Home */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #0e0c0c;
min-width: 160px;
z-index: 1;
border: 1px solid #444;
border-radius: 4px;
}
.dropdown-content a {
color: #f5f5f5;
padding: 0.5rem 1rem;
text-decoration: none;
display: block;
}
.dropdown-content a:hover {
background-color: #1f1a1a;
color: #ffa500;
}
.dropdown:hover .dropdown-content {
display: block;
}
.hero {
position: relative;
height: 70vh;
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-bottom: 2rem;
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
/* Background is set in the component */
}
.hero-content {
position: relative;
text-align: center;
z-index: 1;
}
.hero-content h1 {
font-size: 4rem;
font-weight: bold;
}
.hero-content p {
margin: 0.5rem 0 1rem 0;
font-size: 1.2rem;
}
.button {
margin-top: 2rem;
padding: 0.8rem 2rem;
background: linear-gradient(45deg, #ffa500, #ff7f00);
color: white;
border: none;
border-radius: 30px;
font-size: 1rem;
font-weight: bold;
text-decoration: none;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
transition: transform 0.2s, box-shadow 0.2s;
}
.button:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}

View File

@ -1,112 +0,0 @@
.hover-card {
position: relative;
width: 350px;
height: 400px;
border-radius: var(--border-radius);
background-color: var(--color-accent-green);
box-shadow: var(--box-shadow);
transition: transform var(--transition-standard);
overflow: hidden;
margin: var(--margin-standard);
display: flex;
flex-direction: column;
}
.hover-card:hover {
transform: translateY(-5px);
}
.card-title {
padding: 15px 15px 5px 15px;
margin: 0;
color: var(--color-accent-beige);
font-size: var(--font-size-medium);
text-align: center;
order: -2;
}
.card_date {
padding: 0 15px 15px 15px;
margin: 0;
color: var(--color-accent-beige);
font-size: var(--font-size-small);
text-align: center;
font-style: italic;
order: -1;
}
.image-container {
flex-grow: 1;
position: relative;
overflow: hidden;
}
.card-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.hover-text {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--color-accent-green-transparent);
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
opacity: 0;
transition: opacity var(--transition-standard);
}
.hover-text div {
color: var(--color-accent-beige);
text-align: center;
max-height: 100%;
width: 100%;
overflow-y: auto;
padding-right: 5px;
}
.hover-text div::-webkit-scrollbar {
width: 5px;
}
.hover-text div::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 10px;
}
.hover-text div::-webkit-scrollbar-thumb {
background: var(--color-accent-beige);
border-radius: 10px;
}
.hover-text div {
scrollbar-width: thin;
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
}
.hover-card:hover .hover-text {
opacity: 1;
}
.hover-card:hover .card-image {
opacity: 0.1;
}
.hover-text p {
margin: 0;
padding: 0;
}
@media (max-width: 768px) {
.hover-card {
width: 100%;
max-width: 350px;
}
}

View File

@ -1,30 +0,0 @@
:root {
/* Colors */
--color-background: #000;
--color-text: #f5f5f5;
--color-accent-green: #213b28;
--color-accent-beige: #ceb39b;
--color-accent-green-transparent: rgba(33, 59, 40, 0.95);
--color-shadow: rgba(0, 0, 0, 0.2);
--font-family-primary: 'Georgia', serif;
--font-size-small: 1rem;
--font-size-medium: 1.5rem;
--font-size-large: 2rem;
--line-height: 1.6;
--container-width: 100%;
--container-max-width: 1600px;
--padding-vertical: 2rem;
--padding-horizontal: 0;
--margin-standard: 1rem;
--gap-standard: 30px;
--border-radius: 8px;
--box-shadow: 0 4px 8px var(--color-shadow);
--transition-standard: 0.3s ease;
--breakpoint-mobile: 768px;
--breakpoint-desktop: 1600px;
}

142
MIGRATION_README.md Normal file
View File

@ -0,0 +1,142 @@
# Migration der alten Events und Gallery-Bilder
## ✅ Was wurde migriert?
### Events (7 Stück):
- Karaoke (wiederkehrend)
- Pub Quiz (wiederkehrend)
- Schlager Hüttenzauber Karaoke
- Adventskalender
- Santa Karaoke-Party
- Weihnachtsferien
- Neujahrs-Apero
### Gallery-Bilder (9 Stück):
- Gallery1.webp bis Gallery9.webp
## 📁 Wo liegen die Bilder?
Alle Bilder wurden konvertiert und liegen jetzt in:
- **Events:** `backend/data/images/events/`
- **Gallery:** `backend/data/images/gallery/`
Die Bilder wurden automatisch:
- Von PNG/JPG/JPEG zu WebP konvertiert
- Auf max. 1600px Breite skaliert
- Mit 85% Qualität optimiert
## 🚀 Deployment-Schritte
### 1. Lokale Vorbereitung (bereits erledigt ✓)
- ✓ Migrations-Script erstellt
- ✓ Bilder konvertiert und in `backend/data/images/` kopiert
- ✓ Public API-Endpunkte erstellt (`/api/events/public`, `/api/gallery/public`)
- ✓ Frontend aktualisiert, um Events und Gallery dynamisch zu laden
### 2. Auf Fly.io deployen
Alle Änderungen committen und pushen:
```bash
git add .
git commit -m "feat: Migrate old events and gallery images to CMS"
git push origin main
```
Woodpecker CI wird automatisch beide Services deployen.
### 3. Nach dem ersten Deploy - Datenbank initialisieren
**Wichtig:** Die Bilder sind bereits im Repository in `backend/data/images/`, aber die Datenbank muss noch mit den Event- und Gallery-Einträgen befüllt werden.
#### Via fly ssh (Empfohlen):
```bash
# In das Backend einloggen
fly ssh console -a gallus-cms-backend
# Prüfen ob Bilder da sind
ls -la /app/data/images/events/
ls -la /app/data/images/gallery/
# Migrations-Script ausführen
cd /app
npm run migrate:old-data
```
#### Alternative: Manuell via Admin-Panel
1. Gehe zu https://gallus-pub.ch/admin
2. Melde dich an
3. Für jedes Event:
- Klicke auf "Neues Event"
- Gib Titel, Datum und Beschreibung ein
- Statt Bild hochzuladen, trage manuell die imageUrl ein:
- z.B. `/images/events/event_karaoke.webp`
- Speichere das Event
## 🔍 Verifikation
Nach dem Deployment prüfen:
1. **Frontend:** https://gallus-pub.ch/
- Events sollten angezeigt werden
- Gallery sollte Bilder zeigen
2. **Admin:** https://gallus-pub.ch/admin
- Events können bearbeitet werden
- Neue Events können hinzugefügt werden
3. **Backend Health:** https://cms.gallus-pub.ch/health
- Status sollte "ok" sein
## 📝 Event-Daten für manuelles Einfügen
Falls du die Events manuell via Admin-Panel einfügen möchtest:
### Karaoke
- **Titel:** Karaoke
- **Datum:** 2025-12-31
- **Beschreibung:** Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_karaoke.webp`
### Pub Quiz
- **Titel:** Pub Quiz
- **Datum:** 2025-12-31
- **Beschreibung:** Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
- **Bild-URL:** `/images/events/event_pub-quiz.webp`
### Schlager Hüttenzauber Karaoke
- **Titel:** Schlager Hüttenzauber Karaoke
- **Datum:** 2025-11-27
- **Beschreibung:** Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_schlager-karaoke.webp`
### Adventskalender
- **Titel:** Adventskalender
- **Datum:** 2025-12-20
- **Beschreibung:** Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
- **Bild-URL:** `/images/events/event_advents-kalender.webp`
### Santa Karaoke-Party
- **Titel:** Santa Karaoke-Party
- **Datum:** 2025-12-06
- **Beschreibung:** 🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻
- **Bild-URL:** `/images/events/event_santa_karaoke.webp`
### Weihnachtsferien
- **Titel:** Weihnachtsferien
- **Datum:** 2025-12-21
- **Beschreibung:** Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
- **Bild-URL:** `/images/events/event_ferien.webp`
### Neujahrs-Apero
- **Titel:** Neujahrs-Apero
- **Datum:** 2026-01-02
- **Beschreibung:** 18:00-20:00 Uhr
- **Bild-URL:** `/images/events/event_neujahrs-apero.webp`
## ⚠️ Wichtige Hinweise
1. **Bilder sind im Volume persistent:** Alle Bilder in `/app/data/` bleiben bei Restarts erhalten
2. **Datenbank ist persistent:** Die SQLite-DB in `/app/data/gallus_cms.db` bleibt erhalten
3. **Alte Bilder in `public/images/`:** Die alten Original-Bilder bleiben im Frontend-Repository, werden aber nicht mehr verwendet

View File

@ -1,6 +1,48 @@
# Gallus_Pub # Astro Starter Kit: Minimal
Reposetory für die Website vom Gallus Pub ```sh
npm create astro@latest -- --template minimal
```
Auftragsgeber: Sabrina Signer [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
Geschäfft: Gallus Pub [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
# Test commit to trigger Woodpecker

20
backend/.dockerignore Normal file
View File

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

29
backend/.env.example Normal file
View File

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

34
backend/.env.local Normal file
View File

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

12
backend/.gitignore vendored Normal file
View File

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

195
backend/DEPLOYMENT.md Normal file
View File

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

71
backend/Dockerfile Normal file
View File

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

55
backend/README.md Normal file
View File

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

View File

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

217
backend/SQLITE_MIGRATION.md Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

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

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

41
backend/fly.toml Normal file
View File

@ -0,0 +1,41 @@
# Fly.io configuration for Gallus CMS Backend
app = "gallus-cms-backend"
primary_region = "ams"
[build]
# Ensure Fly uses the Dockerfile in this backend directory
dockerfile = "Dockerfile"
[env]
PORT = "8080"
NODE_ENV = "production"
GITEA_URL = "https://git.bookageek.ch"
DATABASE_PATH = "/app/data/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
# Cross-site frontend and OAuth
FRONTEND_URL = "https://gallus-pub.ch"
CORS_ORIGIN = "https://gallus-pub.ch,https://www.gallus-pub.ch"
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "suspend"
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
method = "GET"
timeout = "5s"
path = "/health"
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
[mounts]
source = "gallus_data"
destination = "/app/data"

View File

@ -0,0 +1,183 @@
// Production migration script - can be run directly with node
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Database schema
const events = sqliteTable('events', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
date: text('date').notNull(),
description: text('description').notNull(),
imageUrl: text('image_url').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
const galleryImages = sqliteTable('gallery_images', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
imageUrl: text('image_url').notNull(),
altText: text('alt_text').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Old events data
const oldEvents = [
{
title: "Karaoke",
date: "2025-12-31",
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
imageUrl: "/images/events/event_karaoke.webp",
displayOrder: 0,
},
{
title: "Pub Quiz",
date: "2025-12-31",
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
imageUrl: "/images/events/event_schlager-karaoke.webp",
displayOrder: 2,
},
{
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
imageUrl: "/images/events/event_advents-kalender.webp",
displayOrder: 3,
},
{
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
imageUrl: "/images/events/event_santa_karaoke.webp",
displayOrder: 4,
},
{
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
imageUrl: "/images/events/event_ferien.webp",
displayOrder: 5,
},
{
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
imageUrl: "/images/events/event_neujahrs-apero.webp",
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ imageUrl: "/images/gallery/Gallery7.webp", alt: "Gallery 7", order: 0 },
{ imageUrl: "/images/gallery/Gallery8.webp", alt: "Gallery 8", order: 1 },
{ imageUrl: "/images/gallery/Gallery9.webp", alt: "Gallery 9", order: 2 },
{ imageUrl: "/images/gallery/Gallery6.webp", alt: "Gallery 6", order: 3 },
{ imageUrl: "/images/gallery/Gallery1.webp", alt: "Gallery 1", order: 4 },
{ imageUrl: "/images/gallery/Gallery2.webp", alt: "Gallery 2", order: 5 },
{ imageUrl: "/images/gallery/Gallery3.webp", alt: "Gallery 3", order: 6 },
{ imageUrl: "/images/gallery/Gallery4.webp", alt: "Gallery 4", order: 7 },
{ imageUrl: "/images/gallery/Gallery5.webp", alt: "Gallery 5", order: 8 },
];
async function main() {
console.log('=== Production Migration Script ===\n');
const dbPath = process.env.DATABASE_PATH || '/app/data/gallus_cms.db';
console.log('Database path:', dbPath);
// Check if database exists
if (!fs.existsSync(dbPath)) {
console.error('ERROR: Database not found at:', dbPath);
console.error('Please ensure the backend has been started at least once to create the database.');
process.exit(1);
}
// Check if images exist
const dataDir = process.env.GIT_WORKSPACE_DIR || '/app/data';
const eventsDir = path.join(dataDir, 'images', 'events');
const galleryDir = path.join(dataDir, 'images', 'gallery');
console.log('Events images directory:', eventsDir);
console.log('Gallery images directory:', galleryDir);
if (!fs.existsSync(eventsDir)) {
console.error('ERROR: Events images directory not found:', eventsDir);
process.exit(1);
}
if (!fs.existsSync(galleryDir)) {
console.error('ERROR: Gallery images directory not found:', galleryDir);
process.exit(1);
}
// List available images
console.log('\nAvailable event images:', fs.readdirSync(eventsDir));
console.log('Available gallery images:', fs.readdirSync(galleryDir));
// Connect to database
const sqlite = new Database(dbPath);
const db = drizzle(sqlite);
console.log('\n=== Migrating Events ===\n');
for (const event of oldEvents) {
try {
const [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: event.imageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error.message);
}
}
console.log('\n=== Migrating Gallery Images ===\n');
for (const img of oldGalleryImages) {
try {
const [newImage] = await db.insert(galleryImages).values({
imageUrl: img.imageUrl,
altText: img.alt,
displayOrder: img.order,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error.message);
}
}
sqlite.close();
console.log('\n✓ Migration completed successfully!');
console.log('\nYou can verify the migration by visiting:');
console.log('- Frontend: https://gallus-pub.ch/');
console.log('- Admin: https://gallus-pub.ch/admin');
}
main().catch(error => {
console.error('\n✗ Migration failed:', error);
process.exit(1);
});

2704
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
backend/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "gallus-cms-backend",
"version": "1.0.0",
"type": "module",
"description": "Headless CMS backend for Gallus Pub website",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.0",
"@fastify/static": "^6.12.0",
"@fastify/multipart": "^8.1.0",
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.33.0",
"fastify": "^4.26.0",
"sharp": "^0.33.2",
"simple-git": "^3.22.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/better-sqlite3": "^7.6.9",
"@types/node": "^20.11.16",
"drizzle-kit": "^0.24.0",
"tsx": "^4.20.6",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,104 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from '../db/schema.js';
import { env } from './env.js';
import fs from 'fs';
import path from 'path';
if (!env.DATABASE_PATH) {
throw new Error('DATABASE_PATH environment variable is not set');
}
// Ensure directory exists BEFORE opening the database file
const dbDir = path.dirname(env.DATABASE_PATH);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const sqlite = new Database(env.DATABASE_PATH);
// Enable WAL mode for better concurrent access
sqlite.pragma('journal_mode = WAL');
export const db = drizzle(sqlite, { schema });
// Auto-create tables if they don't exist
export function initDatabase() {
console.log('🔧 Initializing database...');
try {
// Check if users table exists (acts as a sentinel for initial setup)
const tableCheck = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get();
if (!tableCheck) {
console.log('📝 Creating database schema...');
sqlite.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
gitea_id TEXT UNIQUE NOT NULL,
gitea_username TEXT NOT NULL,
gitea_email TEXT,
display_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (unixepoch()),
last_login INTEGER
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS gallery_images (
id TEXT PRIMARY KEY,
image_url TEXT NOT NULL,
alt_text TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS content_sections (
id TEXT PRIMARY KEY,
section_name TEXT UNIQUE NOT NULL,
content_json TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS publish_history (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
commit_hash TEXT,
commit_message TEXT,
published_at INTEGER DEFAULT (unixepoch())
);
`);
console.log('✅ Database schema created successfully!');
} else {
console.log('✅ Database already initialized.');
}
} catch (error) {
console.error('❌ Error initializing database:', error);
throw error;
}
}

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

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

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

@ -0,0 +1,72 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
giteaId: text('gitea_id').notNull().unique(),
giteaUsername: text('gitea_username').notNull(),
giteaEmail: text('gitea_email'),
displayName: text('display_name'),
avatarUrl: text('avatar_url'),
role: text('role').default('admin'),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
lastLogin: integer('last_login', { mode: 'timestamp' }),
});
// Events table
export const events = sqliteTable('events', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
date: text('date').notNull(),
description: text('description').notNull(),
imageUrl: text('image_url').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Gallery images table
export const galleryImages = sqliteTable('gallery_images', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
imageUrl: text('image_url').notNull(),
altText: text('alt_text').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Content sections table (for text-based sections)
export const contentSections = sqliteTable('content_sections', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
sectionName: text('section_name').notNull().unique(),
contentJson: text('content_json', { mode: 'json' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Site settings table (global config)
export const siteSettings = sqliteTable('site_settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Publish history (audit log)
export const publishHistory = sqliteTable('publish_history', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').references(() => users.id),
commitHash: text('commit_hash'),
commitMessage: text('commit_message'),
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Banner table (for announcements like holidays, special info)
export const banners = sqliteTable('banners', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
text: text('text').notNull(),
startDate: text('start_date').notNull(), // ISO date string
endDate: text('end_date').notNull(), // ISO date string
isActive: integer('is_active', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});

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

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

View File

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

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

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

View File

@ -0,0 +1,152 @@
import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js';
import { banners } from '../db/schema.js';
import { eq, and, lte, gte, desc } from 'drizzle-orm';
const bannerBodyJsonSchema = {
type: 'object',
required: ['text', 'startDate', 'endDate'],
properties: {
text: { type: 'string' },
startDate: { type: 'string' },
endDate: { type: 'string' },
isActive: { type: 'boolean' },
},
} as const;
const bannersRoute: FastifyPluginAsync = async (fastify) => {
// Get active banner (public endpoint)
fastify.get('/banners/active', async (request, reply) => {
// Use local date to avoid timezone issues
const now = new Date();
const today = new Date(now.getTime() - (now.getTimezoneOffset() * 60000))
.toISOString()
.split('T')[0]; // YYYY-MM-DD
const [activeBanner] = await db
.select()
.from(banners)
.where(
and(
eq(banners.isActive, true),
lte(banners.startDate, today),
gte(banners.endDate, today)
)
)
.orderBy(desc(banners.createdAt))
.limit(1);
if (!activeBanner) {
return { banner: null };
}
return {
banner: {
id: activeBanner.id,
text: activeBanner.text,
startDate: activeBanner.startDate,
endDate: activeBanner.endDate,
},
};
});
// Get all banners (admin only)
fastify.get('/banners', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const allBanners = await db.select().from(banners);
return {
banners: allBanners.map((b: any) => ({
id: b.id,
text: b.text,
startDate: b.startDate,
endDate: b.endDate,
isActive: b.isActive,
createdAt: b.createdAt,
updatedAt: b.updatedAt,
})),
};
});
// Create banner (admin only)
fastify.post('/banners', {
schema: {
body: bannerBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { text, startDate, endDate, isActive = true } = request.body as any;
const [newBanner] = await db
.insert(banners)
.values({
text,
startDate,
endDate,
isActive,
})
.returning();
return {
banner: {
id: newBanner.id,
text: newBanner.text,
startDate: newBanner.startDate,
endDate: newBanner.endDate,
isActive: newBanner.isActive,
},
};
});
// Update banner (admin only)
fastify.put('/banners/:id', {
schema: {
body: bannerBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const { text, startDate, endDate, isActive } = request.body as any;
const [updated] = await db
.update(banners)
.set({
text,
startDate,
endDate,
isActive,
updatedAt: new Date(),
})
.where(eq(banners.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Banner not found' });
}
return {
banner: {
id: updated.id,
text: updated.text,
startDate: updated.startDate,
endDate: updated.endDate,
isActive: updated.isActive,
},
};
});
// Delete banner (admin only)
fastify.delete('/banners/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
await db.delete(banners).where(eq(banners.id, id));
return { success: true };
});
};
export default bannersRoute;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,190 @@
import { db } from '../config/database.js';
import { events, galleryImages } from '../db/schema.js';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Old events data
const oldEvents = [
{
image: "/images/events/event_karaoke.jpg",
title: "Karaoke",
date: "2025-12-31", // Set as ongoing event
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 0,
},
{
image: "/images/events/event_pub-quiz.jpg",
title: "Pub Quiz",
date: "2025-12-31", // Set as ongoing event
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
Auch Einzelpersonen sind herzlich willkommen! <br>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
image: "/images/events/event_schlager-karaoke.jpeg",
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 2,
},
{
image: "/images/events/event_advents-kalender.jpeg",
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
displayOrder: 3,
},
{
image: "/images/events/event_santa_karaoke.jpeg",
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
displayOrder: 4,
},
{
image: "/images/events/event_ferien.jpeg",
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
displayOrder: 5,
},
{
image: "/images/events/event_neujahrs-apero.jpeg",
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ src: "/images/gallery/Gallery7.png", alt: "Gallery 7" },
{ src: "/images/gallery/Gallery8.png", alt: "Gallery 8" },
{ src: "/images/gallery/Gallery9.png", alt: "Gallery 9" },
{ src: "/images/gallery/Gallery6.png", alt: "Gallery 6" },
{ src: "/images/gallery/Gallery1.png", alt: "Gallery 1" },
{ src: "/images/gallery/Gallery2.png", alt: "Gallery 2" },
{ src: "/images/gallery/Gallery3.png", alt: "Gallery 3" },
{ src: "/images/gallery/Gallery4.png", alt: "Gallery 4" },
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
];
async function copyAndConvertImage(
sourcePath: string,
destDir: string,
filename: string
): Promise<string> {
const projectRoot = path.join(process.cwd(), '..');
const fullSourcePath = path.join(projectRoot, 'public', sourcePath);
// Ensure destination directory exists
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const ext = path.extname(filename);
const baseName = path.basename(filename, ext);
const webpFilename = `${baseName}.webp`;
const destPath = path.join(destDir, webpFilename);
console.log(`Processing: ${fullSourcePath} -> ${destPath}`);
// Check if source exists
if (!fs.existsSync(fullSourcePath)) {
console.error(`Source file not found: ${fullSourcePath}`);
throw new Error(`Source file not found: ${fullSourcePath}`);
}
// Convert to webp and copy
await sharp(fullSourcePath)
.rotate() // Auto-rotate based on EXIF
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(destPath);
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
}
async function migrateEvents() {
console.log('\n=== Migrating Events ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const eventsImageDir = path.join(dataDir, 'images', 'events');
for (const event of oldEvents) {
try {
const filename = path.basename(event.image);
const newImageUrl = await copyAndConvertImage(
event.image,
eventsImageDir,
filename
);
const [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: newImageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error);
}
}
}
async function migrateGallery() {
console.log('\n=== Migrating Gallery Images ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const galleryImageDir = path.join(dataDir, 'images', 'gallery');
for (let i = 0; i < oldGalleryImages.length; i++) {
const img = oldGalleryImages[i];
try {
const filename = path.basename(img.src);
const newImageUrl = await copyAndConvertImage(
img.src,
galleryImageDir,
filename
);
const [newImage] = await db.insert(galleryImages).values({
imageUrl: newImageUrl,
altText: img.alt,
displayOrder: i,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error);
}
}
}
async function main() {
console.log('Starting migration of old data...\n');
console.log('Working directory:', process.cwd());
console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'));
try {
await migrateEvents();
await migrateGallery();
console.log('\n✓ Migration completed successfully!');
} catch (error) {
console.error('\n✗ Migration failed:', error);
process.exit(1);
}
}
main();

View File

@ -0,0 +1,241 @@
import { writeFile } from 'fs/promises';
import path from 'path';
interface Event {
title: string;
date: string;
description: string;
imageUrl: string;
}
interface GalleryImage {
imageUrl: string;
altText: string;
}
interface ContentSection {
[key: string]: any;
}
export class FileGeneratorService {
escapeQuotes(str: string): string {
return str.replace(/"/g, '\\"');
}
escapeBackticks(str: string): string {
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
}
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
const eventsCode = events.map(e => `\t{
\t\timage: "${e.imageUrl}",
\t\ttitle: "${this.escapeQuotes(e.title)}",
\t\tdate: "${e.date}",
\t\tdescription: \`
\t\t\t${this.escapeBackticks(e.description)}
\t\t\`,
\t}`).join(',\n');
const imagesCode = images.map(g =>
`\t{ src: "${g.imageUrl}", alt: "${this.escapeQuotes(g.altText)}" }`
).join(',\n');
return `---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
import Hero from "../components/Hero.astro";
import Welcome from "../components/Welcome.astro";
import EventsGrid from "../components/EventsGrid.astro";
import Drinks from "../components/Drinks.astro";
import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
const events = [
${eventsCode}
];
const images = [
${imagesCode}
];
---
<Layout>
\t<Hero id="hero" />
\t<Banner />
\t<Welcome id="welcome" />
\t<EventsGrid id="events" events={events} />
\t<ImageCarousel id="gallery" images={images} />
\t<Drinks id="drinks" />
</Layout>
`;
}
generateHeroComponent(content: ContentSection): string {
return `---
// src/components/Hero.astro
import "../styles/components/Hero.css"
const { id } = Astro.props;
---
<section id={id} class="hero container">
\t<div class="hero-overlay">
\t\t<div class="hero-content">
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
\t\t</div>
\t</div>
</section>
<style>
</style>
`;
}
generateWelcomeComponent(content: ContentSection): string {
const highlightsList = (content.highlights || []).map((h: any) =>
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
).join('\n\n');
return `---
// src/components/Welcome.astro
import "../styles/components/Welcome.css"
const { id } = Astro.props;
---
<section id={id} class="welcome container">
\t<div class="welcome-text">
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
\t\t<p>
\t\t\t${content.introText || ''}
\t\t</p>
\t\t<p><b>Unsere Highlights:</b></p>
\t\t<ul>
${highlightsList}
\t\t</ul>
\t\t<p>
\t\t\t${content.closingText || ''}
\t\t</p>
\t</div>
\t<div class="welcome-image">
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
\t</div>
</section>
`;
}
generateDrinksComponent(content: ContentSection): string {
return `---
import "../styles/components/Drinks.css"
const { id } = Astro.props;
---
<section id={id} class="Drinks">
<h2 class="title">Drinks</h2>
<p class="note">
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein hier kannst du in entspannter Atmosphäre das Leben genießen.'}
</p>
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka">
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
</div>
<p class="note">
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
</p>
<div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
</section>
`;
}
async writeFiles(
workspaceDir: string,
events: Event[],
images: GalleryImage[],
sections: Map<string, ContentSection>
) {
// Write index.astro
const indexContent = this.generateIndexAstro(events, images);
await writeFile(
path.join(workspaceDir, 'src/pages/index.astro'),
indexContent,
'utf-8'
);
// Write Hero component
if (sections.has('hero')) {
const heroContent = this.generateHeroComponent(sections.get('hero')!);
await writeFile(
path.join(workspaceDir, 'src/components/Hero.astro'),
heroContent,
'utf-8'
);
}
// Write Welcome component
if (sections.has('welcome')) {
const welcomeContent = this.generateWelcomeComponent(sections.get('welcome')!);
await writeFile(
path.join(workspaceDir, 'src/components/Welcome.astro'),
welcomeContent,
'utf-8'
);
}
// Write Drinks component
if (sections.has('drinks')) {
const drinksContent = this.generateDrinksComponent(sections.get('drinks')!);
await writeFile(
path.join(workspaceDir, 'src/components/Drinks.astro'),
drinksContent,
'utf-8'
);
}
}
}

View File

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

View File

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

View File

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

View File

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

19
backend/tsconfig.json Normal file
View File

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

38
docker-compose.yml Normal file
View File

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

View File

@ -1,5 +1,5 @@
app = "gallus-pub" app = "gallus-pub"
primary_region = "fra" # Frankfurt region, change if needed primary_region = "fra"
kill_signal = "SIGINT" kill_signal = "SIGINT"
kill_timeout = 5 kill_timeout = 5
@ -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
@ -26,7 +29,7 @@ kill_timeout = 5
[[http_service.checks]] [[http_service.checks]]
interval = "30s" interval = "30s"
timeout = "5s" timeout = "5s"
grace_period = "10s" grace_period = "30s"
method = "GET" method = "GET"
path = "/" path = "/"
protocol = "http" protocol = "http"
@ -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"

5036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{ {
"name": "gallus-pub-v1", "name": "Gallus Pub Site",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {

3114
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

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

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