130 Commits
Author SHA1 Message Date
KenzoandClaude Opus 5 1a7a4f8a02 perf(ci): Builds beschleunigen und kaputtes sharp reparieren
ci/woodpecker/push/woodpecker Pipeline was successful
Nur bauen was sich geaendert hat:
- deploy_backend laeuft nur bei Aenderungen unter backend/, deploy_frontend
  nur bei src/, public/ und den Frontend-Konfigdateien. Die haeufigsten
  Commits sind Inhaltsaenderungen aus dem CMS und fassen nur src/ und
  public/ an - die brauchen kein Backend-Deployment mehr.
- Der Abhaengigkeits-Audit laeuft nur noch, wenn sich package.json oder
  package-lock.json aendern. Sonst kam bei jedem Inhalts-Commit derselbe
  Bericht nach Discord.
- "[ALL]" in der Commit-Message erzwingt weiterhin den vollen Durchlauf.

Build-Kontext:
- Es gab kein .dockerignore im Root. Bei jedem Frontend-Deploy wanderte
  das komplette Repo zum Fly-Remote-Builder, inklusive 50 MB Git-Historie
  und des gesamten Backends. 98 MB -> 47 MB.
- Zwei tote Zeilen im Frontend-Dockerfile entfernt: ein Root-styles/ gibt
  es nicht und /styles/ wird als URL nirgends verwendet.

Backend-Image:
- npm ci lief in beiden Stages, better-sqlite3 wurde also doppelt
  uebersetzt. Jetzt einmal im Builder, danach npm prune --omit=dev und die
  fertigen node_modules wandern weiter.
- Die Build-Werkzeuge sind raus aus der Laufzeit-Stage. Das apk del vorher
  hat sie nur unsichtbar gemacht, die Layer blieben im Image.
- Ohne Cache lokal: 1:31 -> 0:29. Image: 845 MB -> 312 MB.

sharp war die ganze Zeit kaputt:
- backend/package-lock.json enthielt als einziges Plattform-Binary
  @img/sharp-win32-x64, das Lockfile stammt von einer Windows-Maschine.
  npm ci installiert strikt nach Lockfile, auf Alpine kam damit gar kein
  sharp-Binary an. Der Upload fiel jedes Mal auf den Fallback zurueck:
  keine Verkleinerung auf 1600px, keine WebP-Wandlung, das Originalbild
  landete unveraendert im Repo. Genau deshalb sind die Bilder so gross -
  in der Historie liegen entsprechend .jpeg statt .webp.
- Lockfile mit allen Plattformvarianten neu aufgeloest. Keine einzige
  bestehende Paketversion aendert sich dabei; dazugekommen sind die
  Binaries, weggefallen sind 12 Postgres-Pakete, die drizzle-kit optional
  mitzieht und die hier niemand benutzt.
- vips aus dem Image entfernt, sharp bringt seit 0.33 sein eigenes libvips
  mit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:13:52 +02:00
KenzoandClaude Opus 5 60246f3941 feat(cms): Aufraeumen von Uploads, Texte-Bearbeitung und PDF-Slot
Bilder wurden nie geloescht - weder beim Loeschen eines Datensatzes noch
beim Austauschen. Dazu waren die Textbereiche zwar im Backend vorhanden,
aber ohne Oberflaeche, und die Getraenkekarte hing hartkodiert im Markup.

Aufraeumen:
- asset.service.ts loescht ausschliesslich Dateien, die das CMS selbst
  angelegt hat (Muster <zeitstempel>-<zufall>.<ext>) und nur innerhalb der
  Upload-Ordner. Handgepflegte Assets wie event_karaoke.jpg oder Welcome.png
  bleiben unangetastet, auch wenn sie nirgends referenziert sind.
- image-refs.service.ts sammelt alle benutzten Bild-URLs, inklusive der
  Pfade aus den Textbereichen. Geloescht wird nur, was wirklich niemand
  mehr benutzt - ein von zwei Events geteiltes Bild bleibt liegen.
- Events und Gallery raeumen beim Loeschen und beim Bildwechsel mit auf.
- Der Publish entfernt zusaetzlich Verwaiste. Die Referenzliste deckt
  bewusst alle Zeilen ab, auch unveroeffentlichte, sonst verlieren die ihr
  Bild. Die Loeschungen werden mitcommittet, das Repo schrumpft also.

Texte:
- Neuer Adminbereich fuer Hero, Willkommen und Drinks ueber die schon
  vorhandenen /api/content-Endpunkte, samt Highlights-Liste und Bildern.
- Der Generator maskiert Texte jetzt. Ohne das haette ein "<" oder "&" in
  einem Feld gereicht, um den Astro-Build und damit den Deploy zu killen.

PDF:
- POST /api/pdf/drinks nimmt die Getraenkekarte entgegen, hinterlegt die
  URL in der drinks-Section und raeumt den Vorgaenger weg. Der Generator
  verlinkt sie, statt den Pfad fest im Markup zu haben.

Ausserdem:
- Vorschaubilder im Admin auf eine feste Box gezwungen. Uploads sind bis
  1600px breit und haben das Layout je nach Seitenverhaeltnis zerrissen.
- git.service.ts sichert public/pdf beim Neu-Clone mit weg, nicht nur
  public/images.
- Der Publish scheitert nicht mehr, wenn nur das Audit-Log nicht
  geschrieben werden kann - der Push ist da laengst durch.
- Upload-Logik lag dreifach kopiert vor, jetzt in upload.service.ts. Der
  Helfer prueft auch auf abgeschnittene Dateien; bisher landete bei zu
  grossen Uploads ein kaputtes Bild auf der Platte.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 11:42:59 +02:00
Gallus-maintanance 2af70c238b Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 09:36:48 +00:00
Gallus-maintanance 30f4b9d8c5 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 08:47:41 +00:00
KenzoandClaude Opus 5 390b016cc2 fix(backend): Bilder unter /images/ ausliefern
ci/woodpecker/push/woodpecker Pipeline was successful
Die Bild-URLs in der DB sind /images/events/... bzw. /images/gallery/... -
das ist der Pfad auf der publizierten Astro-Seite. Das Backend lieferte die
Dateien aber nur unter /static/ mit Root auf dem Workspace aus, die Datei lag
also unter /static/public/images/... Ein Aufruf von /images/... traf auf gar
keine Route, kam als JSON-404 zurueck und wurde vom Browser als
OpaqueResponseBlocking verworfen. Dadurch waren im Adminbereich saemtliche
Event- und Gallery-Bilder kaputt.

Zusaetzlicher statischer Mount /images/ -> <workspace>/public/images. Die DB
bleibt unveraendert und die URLs stimmen weiterhin fuer die publizierte Seite.
/static/ bleibt zusaetzlich bestehen.

Beide Verzeichnisse werden vorab angelegt - @fastify/static verweigert sonst
die Registrierung, wenn der Workspace noch nicht geklont ist.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 10:22:40 +02:00
KenzoandClaude Opus 5 e9d7ce262d ci: Backend per Woodpecker deployen
Die Pipeline hatte nur einen Deploy-Step fuer das Frontend (App gallus-pub).
Die Backend-App gallus-cms-backend wurde von der CI nie ausgerollt, sondern
haing am letzten manuellen fly deploy. Ein Push auf main hat damit zwar die
Admin-Seite aktualisiert, aber nie die API dahinter.

Step laeuft vor deploy_frontend und nutzt denselben FLY_API_TOKEN.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 10:22:40 +02:00
Kenzo fdd3793ee1 Merge pull request 'fix(backend): Publish repariert - spawn git ENOENT und kaputter Image-Build' (#5) from fix/publish-git-enoent into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #5
2026-08-11 09:51:59 +02:00
KenzoandClaude Opus 5 88ad16c86f fix(backend): Publish repariert - spawn git ENOENT und kaputter Image-Build
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
Publish schlug bei jedem Versuch mit "spawn git ENOENT" fehl, obwohl git im
Container installiert ist. Node meldet ENOENT auch dann, wenn das cwd des
Kindprozesses nicht existiert: initialize() setzte simple-git auf den
Workspace, loeschte diesen per rm -rf und startete den Clone anschliessend
aus dem geloeschten Verzeichnis heraus.

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

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

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

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

Images are stored in backend/data/images/ which maps to
the persistent Fly.io volume at /app/data/
2025-12-09 16:41:57 +01:00
Kenzo db3a38ed45 Revert "Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth."
This reverts commit 4f8feb8652.
2025-12-09 16:36:16 +01:00
Kenzo 4f8feb8652 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
ci/woodpecker/push/woodpecker Pipeline is running
2025-12-09 15:56:55 +01:00
Kenzo 0c291079ff Test: Trigger Woodpecker CI 2025-12-09 15:56:10 +01:00
Kenzo fe2f61cdc2 Simplify Woodpecker pipeline by consolidating when conditions and using secrets for Fly.io authentication 2025-12-09 15:55:51 +01:00
Kenzo af4877300f Add public endpoints and refactor deployments
ci/woodpecker/push/woodpecker Pipeline failed
- Implemented public `/gallery/public` and `/events/public` endpoints for fetching published data without authentication.
- Updated persistent volume configuration for Fly.io across backend and static file serving.
- Adjusted frontend to dynamically fetch events and gallery images from backend API.
- Refined Woodpecker pipeline for clearer separation of backend and frontend deployments.
2025-12-09 15:53:39 +01:00
Kenzo 4a103cf7d6 Merge remote-tracking branch 'origin/main'
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:17:25 +01:00
Kenzo 97e7f88906 Revert "ned soll endlich pushen"
This reverts commit 8ca30ae5f3.
2025-12-09 15:16:45 +01:00
Gallus-maintanance 807c56de5a Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:09:39 +00:00
Kenzo 8ca30ae5f3 ned soll endlich pushen
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:45:46 +01:00
Kenzo 8f1254840c anpassungen am woodpecker und fly .toml
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:36:31 +01:00
Kenzo 8b2d00385a test
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:03:36 +01:00
Kenzo c55e274718 woodpecker soll nun auch das backend deployen
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 13:58:39 +01:00
Kenzo e9a95ccf8d Implement cross-domain support for OAuth and API requests
ci/woodpecker/push/woodpecker Pipeline was successful
- Updated frontend to use `https://cms.gallus-pub.ch` as the API base URL.
- Configured cookies with `SameSite=None` and `Secure` for production in `auth.ts`.
- Enhanced `fly.toml` to include `FRONTEND_URL`, `CORS_ORIGIN`, and `GITEA_REDIRECT_URI`.
- Adjusted `.gitignore` to ignore `/ai/` directory.
2025-12-09 12:01:49 +01:00
Kenzo b16ac76620 Update fly.toml to adjust [env] paths and simplify volume mounts configuration
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-08 18:34:18 +01:00
Kenzo 0e03b9dea9 Fix typo in deployment guide section header
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:27:27 +01:00
Kenzo da3a950a1a Update fly.toml to reference Dockerfile instead of Dockerfile.fly
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:19:01 +01:00
125 changed files with 7341 additions and 739 deletions
+29
View File
@@ -0,0 +1,29 @@
# Ohne diese Datei wandert das komplette Repo zum Fly-Remote-Builder,
# inklusive der ~50 MB Git-Historie und des gesamten Backends. Der
# Frontend-Build braucht davon nichts.
.git
.gitignore
node_modules
dist
.astro
# Das Backend wird als eigene Fly-App aus backend/ heraus gebaut
backend
# Nicht vom Frontend-Build verwendet
.woodpecker.yml
Dockerfile.caddy
docker-compose.yml
pnpm-lock.yaml
pnpm-workspace.yaml
MIGRATION_README.md
.vscode
.idea
.DS_Store
.env
.env.*
*.log
+1
View File
@@ -22,3 +22,4 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
/ai/
+220 -7
View File
@@ -1,5 +1,106 @@
steps:
deploy:
audit_dependencies:
image: node:20
commands:
- npm install --package-lock-only
- npm audit --audit-level=moderate --json > audit-result.json 2>&1 || echo "Audit completed"
- npm audit --audit-level=moderate > audit-output.txt 2>&1 || echo "Audit completed"
# Nur wenn sich Abhaengigkeiten geaendert haben - bei reinen
# Inhaltsaenderungen kaeme sonst jedes Mal derselbe Bericht
when:
- branch: main
event: push
path:
include:
- 'package.json'
- 'package-lock.json'
- '.woodpecker.yml'
ignore_message: '[ALL]'
discord_notify_audit:
image: alpine:latest
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
if [ -f audit-result.json ]; then
TOTAL=$(jq -r '.metadata.vulnerabilities.total // 0' audit-result.json 2>/dev/null || echo "0")
CRITICAL=$(jq -r '.metadata.vulnerabilities.critical // 0' audit-result.json 2>/dev/null || echo "0")
HIGH=$(jq -r '.metadata.vulnerabilities.high // 0' audit-result.json 2>/dev/null || echo "0")
MODERATE=$(jq -r '.metadata.vulnerabilities.moderate // 0' audit-result.json 2>/dev/null || echo "0")
LOW=$(jq -r '.metadata.vulnerabilities.low // 0' audit-result.json 2>/dev/null || echo "0")
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ] || [ "$MODERATE" -gt 0 ]; then
COLOR=16744448
STATUS="⚠️ Vulnerabilities Found"
else
COLOR=3066993
STATUS="✅ No Vulnerabilities"
fi
if [ -f audit-output.txt ]; then
VULNS=$(head -50 audit-output.txt | tail -40 || echo "No details")
else
VULNS="No audit output available"
fi
printf '%s' "$VULNS" > /tmp/vulns.txt
PAYLOAD=$(jq -n \
--arg title "🔒 Security Audit - Build #${CI_BUILD_NUMBER}" \
--arg status "$STATUS" \
--arg total "$TOTAL" \
--arg critical "$CRITICAL" \
--arg high "$HIGH" \
--arg moderate "$MODERATE" \
--arg low "$LOW" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--rawfile details /tmp/vulns.txt \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
--argjson color "$COLOR" \
'{
embeds: [{
title: $title,
description: $status,
color: $color,
fields: [
{ name: "Total", value: $total, inline: true },
{ name: "Critical", value: $critical, inline: true },
{ name: "High", value: $high, inline: true },
{ name: "Moderate", value: $moderate, inline: true },
{ name: "Low", value: $low, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Details", value: ("```\n" + ($details[:800]) + (if ($details | length) > 800 then "\n... (truncated)" else "" end) + "\n```"), inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
else
echo "No audit results found - listing workspace files:"
ls -la
fi
# Gleicher Filter wie der Audit-Schritt, sonst meldet Discord bei jedem
# Inhalts-Commit "keine Ergebnisse gefunden"
when:
- branch: main
event: push
path:
include:
- 'package.json'
- 'package-lock.json'
- '.woodpecker.yml'
ignore_message: '[ALL]'
# Backend zuerst - die Admin-Seite braucht die API.
# Laeuft nur, wenn sich am Backend etwas geaendert hat. Die haeufigsten
# Commits sind Inhaltsaenderungen aus dem CMS und fassen nur src/ und
# public/ an - die brauchen kein Backend-Deployment.
deploy_backend:
image: node:20
environment:
FLY_API_TOKEN:
@@ -7,10 +108,122 @@ steps:
commands:
- curl -L https://fly.io/install.sh | sh
- export PATH="$HOME/.fly/bin:$PATH"
- flyctl deploy --config fly.toml --app gallus-pub
# aus backend/ heraus, damit Build-Kontext und Dockerfile stimmen
- cd backend && flyctl deploy --app gallus-cms-backend --remote-only
when:
- branch: main
event: push
path:
include:
- 'backend/**'
- '.woodpecker.yml'
# "[ALL]" in der Commit-Message erzwingt den vollen Durchlauf
ignore_message: '[ALL]'
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
path:
include:
- 'src/**'
- 'public/**'
- 'package.json'
- 'package-lock.json'
- 'astro.config.mjs'
- 'tsconfig.json'
- 'Dockerfile'
- '.dockerignore'
- 'fly.toml'
- '.woodpecker.yml'
ignore_message: '[ALL]'
notify_success:
image: alpine:latest
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
--arg title "✅ Build #${CI_BUILD_NUMBER} - Success" \
--arg repo "${CI_REPO}" \
--arg branch "${CI_COMMIT_BRANCH}" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--arg author "${CI_COMMIT_AUTHOR}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'. as $message | {
embeds: [{
title: $title,
description: "Build und Deployment erfolgreich abgeschlossen!",
color: 3066993,
fields: [
{ name: "Repository", value: $repo, inline: true },
{ name: "Branch", value: $branch, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Author", value: $author, inline: true },
{ name: "Commit Message", value: $message, inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
when:
- branch: main
event: push
status: success
notify_failure:
image: alpine:latest
environment:
DISCORD_WEBHOOK:
from_secret: discord_webhook
commands:
- apk add --no-cache curl jq
- |
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
--arg title "❌ Build #${CI_BUILD_NUMBER} - Failure" \
--arg repo "${CI_REPO}" \
--arg branch "${CI_COMMIT_BRANCH}" \
--arg commit "${CI_COMMIT_SHA:0:7}" \
--arg author "${CI_COMMIT_AUTHOR}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'. as $message | {
embeds: [{
title: $title,
description: "Build oder Deployment ist fehlgeschlagen!",
color: 15158332,
fields: [
{ name: "Repository", value: $repo, inline: true },
{ name: "Branch", value: $branch, inline: true },
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
{ name: "Author", value: $author, inline: true },
{ name: "Commit Message", value: $message, inline: false }
],
timestamp: $timestamp
}]
}')
curl -H "Content-Type: application/json" -X POST \
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
when:
- branch: main
event: push
status: failure
-3
View File
@@ -5,9 +5,6 @@ COPY package*.json ./
# Fallback to npm install if no lockfile is present
RUN npm ci || npm install
COPY . .
# Ensure CSS variables are present
RUN mkdir -p public/styles
RUN cp -r styles/* public/styles/ || true
RUN npm run build
FROM node:20-alpine AS production
+142
View File
@@ -0,0 +1,142 @@
# Migration der alten Events und Gallery-Bilder
## ✅ Was wurde migriert?
### Events (7 Stück):
- Karaoke (wiederkehrend)
- Pub Quiz (wiederkehrend)
- Schlager Hüttenzauber Karaoke
- Adventskalender
- Santa Karaoke-Party
- Weihnachtsferien
- Neujahrs-Apero
### Gallery-Bilder (9 Stück):
- Gallery1.webp bis Gallery9.webp
## 📁 Wo liegen die Bilder?
Alle Bilder wurden konvertiert und liegen jetzt in:
- **Events:** `backend/data/images/events/`
- **Gallery:** `backend/data/images/gallery/`
Die Bilder wurden automatisch:
- Von PNG/JPG/JPEG zu WebP konvertiert
- Auf max. 1600px Breite skaliert
- Mit 85% Qualität optimiert
## 🚀 Deployment-Schritte
### 1. Lokale Vorbereitung (bereits erledigt ✓)
- ✓ Migrations-Script erstellt
- ✓ Bilder konvertiert und in `backend/data/images/` kopiert
- ✓ Public API-Endpunkte erstellt (`/api/events/public`, `/api/gallery/public`)
- ✓ Frontend aktualisiert, um Events und Gallery dynamisch zu laden
### 2. Auf Fly.io deployen
Alle Änderungen committen und pushen:
```bash
git add .
git commit -m "feat: Migrate old events and gallery images to CMS"
git push origin main
```
Woodpecker CI wird automatisch beide Services deployen.
### 3. Nach dem ersten Deploy - Datenbank initialisieren
**Wichtig:** Die Bilder sind bereits im Repository in `backend/data/images/`, aber die Datenbank muss noch mit den Event- und Gallery-Einträgen befüllt werden.
#### Via fly ssh (Empfohlen):
```bash
# In das Backend einloggen
fly ssh console -a gallus-cms-backend
# Prüfen ob Bilder da sind
ls -la /app/data/images/events/
ls -la /app/data/images/gallery/
# Migrations-Script ausführen
cd /app
npm run migrate:old-data
```
#### Alternative: Manuell via Admin-Panel
1. Gehe zu https://gallus-pub.ch/admin
2. Melde dich an
3. Für jedes Event:
- Klicke auf "Neues Event"
- Gib Titel, Datum und Beschreibung ein
- Statt Bild hochzuladen, trage manuell die imageUrl ein:
- z.B. `/images/events/event_karaoke.webp`
- Speichere das Event
## 🔍 Verifikation
Nach dem Deployment prüfen:
1. **Frontend:** https://gallus-pub.ch/
- Events sollten angezeigt werden
- Gallery sollte Bilder zeigen
2. **Admin:** https://gallus-pub.ch/admin
- Events können bearbeitet werden
- Neue Events können hinzugefügt werden
3. **Backend Health:** https://cms.gallus-pub.ch/health
- Status sollte "ok" sein
## 📝 Event-Daten für manuelles Einfügen
Falls du die Events manuell via Admin-Panel einfügen möchtest:
### Karaoke
- **Titel:** Karaoke
- **Datum:** 2025-12-31
- **Beschreibung:** Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_karaoke.webp`
### Pub Quiz
- **Titel:** Pub Quiz
- **Datum:** 2025-12-31
- **Beschreibung:** Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
- **Bild-URL:** `/images/events/event_pub-quiz.webp`
### Schlager Hüttenzauber Karaoke
- **Titel:** Schlager Hüttenzauber Karaoke
- **Datum:** 2025-11-27
- **Beschreibung:** Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_schlager-karaoke.webp`
### Adventskalender
- **Titel:** Adventskalender
- **Datum:** 2025-12-20
- **Beschreibung:** Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
- **Bild-URL:** `/images/events/event_advents-kalender.webp`
### Santa Karaoke-Party
- **Titel:** Santa Karaoke-Party
- **Datum:** 2025-12-06
- **Beschreibung:** 🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻
- **Bild-URL:** `/images/events/event_santa_karaoke.webp`
### Weihnachtsferien
- **Titel:** Weihnachtsferien
- **Datum:** 2025-12-21
- **Beschreibung:** Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
- **Bild-URL:** `/images/events/event_ferien.webp`
### Neujahrs-Apero
- **Titel:** Neujahrs-Apero
- **Datum:** 2026-01-02
- **Beschreibung:** 18:00-20:00 Uhr
- **Bild-URL:** `/images/events/event_neujahrs-apero.webp`
## ⚠️ Wichtige Hinweise
1. **Bilder sind im Volume persistent:** Alle Bilder in `/app/data/` bleiben bei Restarts erhalten
2. **Datenbank ist persistent:** Die SQLite-DB in `/app/data/gallus_cms.db` bleibt erhalten
3. **Alte Bilder in `public/images/`:** Die alten Original-Bilder bleiben im Frontend-Repository, werden aber nicht mehr verwendet
+1
View File
@@ -45,3 +45,4 @@ All commands are run from the root of the project, from a terminal:
## 👀 Want to learn more?
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
+6 -4
View File
@@ -4,7 +4,9 @@ dist
*.log
.DS_Store
/tmp
/data
*.db
*.db-wal
*.db-shm
/data/*.db
/data/*.db-wal
/data/*.db-shm
/data/workspace
# Allow images to be committed
!/data/images
+1 -1
View File
@@ -1,6 +1,6 @@
# Deployment Guide
## Prerequisites
## Prerequisite
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
2. Fly.io account: `flyctl auth login`
+19 -17
View File
@@ -1,44 +1,46 @@
# Multi-stage build for Gallus CMS Backend
# Stage 1: Builder
# Hier werden die nativen Module EINMAL uebersetzt. Vorher lief npm ci in
# beiden Stages, better-sqlite3 wurde also doppelt kompiliert.
FROM node:20-alpine AS builder
WORKDIR /app
# Install build dependencies for native modules (better-sqlite3)
# Nur fuer better-sqlite3 - fuer musl gibt es davon keine Fertigbauten.
# sharp ab 0.33 bringt eigene Binaries samt libvips mit, vips-dev wird
# dadurch nicht mehr gebraucht.
RUN apk add --no-cache python3 make g++
# Install dependencies
# Eigener Layer: bleibt im Cache, solange sich das Lockfile nicht aendert
COPY package*.json ./
# Use npm ci when lockfile exists, fallback to npm install for local/dev
RUN npm ci || npm install
RUN npm ci
# Copy source
COPY . .
# Build TypeScript
RUN npm run build
# Dev-Abhaengigkeiten entfernen. Was uebrig bleibt, ist fertig uebersetzt
# und wandert unveraendert ins Laufzeit-Image.
RUN npm prune --omit=dev
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
# git fuer simple-git, sqlite fuer die CLI. Keine Build-Werkzeuge mehr -
# die blieben vorher als Layer im Image liegen, auch nach dem apk del.
RUN apk add --no-cache git sqlite
# Copy production dependencies from builder (already compiled native modules)
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# Copy built files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
COPY --from=builder /app/migrate-production.js ./migrate-production.js
COPY --from=builder /app/data/images ./data/images
# Create directories
RUN mkdir -p /app/workspace /app/data
# Ensure proper permissions
RUN chown -R node:node /app
# Create directories and ensure proper permissions
RUN mkdir -p /app/workspace /app/data && chown -R node:node /app
# Switch to non-root user
USER node
@@ -56,4 +58,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Run DB migrations if present, then start application
CMD ["/bin/sh", "-lc", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
CMD ["/bin/sh", "-lc", "mkdir -p /app/data/images/events /app/data/images/gallery && [ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
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

+6
View File
@@ -3,6 +3,8 @@ app = "gallus-cms-backend"
primary_region = "ams"
[build]
# Ensure Fly uses the Dockerfile in this backend directory
dockerfile = "Dockerfile"
[env]
PORT = "8080"
@@ -10,6 +12,10 @@ primary_region = "ams"
GITEA_URL = "https://git.bookageek.ch"
DATABASE_PATH = "/app/data/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
# Cross-site frontend and OAuth
FRONTEND_URL = "https://gallus-pub.ch"
CORS_ORIGIN = "https://gallus-pub.ch,https://www.gallus-pub.ch"
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
[http_service]
internal_port = 8080
+183
View File
@@ -0,0 +1,183 @@
// Production migration script - can be run directly with node
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Database schema
const events = sqliteTable('events', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
date: text('date').notNull(),
description: text('description').notNull(),
imageUrl: text('image_url').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
const galleryImages = sqliteTable('gallery_images', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
imageUrl: text('image_url').notNull(),
altText: text('alt_text').notNull(),
displayOrder: integer('display_order').notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Old events data
const oldEvents = [
{
title: "Karaoke",
date: "2025-12-31",
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
imageUrl: "/images/events/event_karaoke.webp",
displayOrder: 0,
},
{
title: "Pub Quiz",
date: "2025-12-31",
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
imageUrl: "/images/events/event_schlager-karaoke.webp",
displayOrder: 2,
},
{
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
imageUrl: "/images/events/event_advents-kalender.webp",
displayOrder: 3,
},
{
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
imageUrl: "/images/events/event_santa_karaoke.webp",
displayOrder: 4,
},
{
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
imageUrl: "/images/events/event_ferien.webp",
displayOrder: 5,
},
{
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
imageUrl: "/images/events/event_neujahrs-apero.webp",
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ imageUrl: "/images/gallery/Gallery7.webp", alt: "Gallery 7", order: 0 },
{ imageUrl: "/images/gallery/Gallery8.webp", alt: "Gallery 8", order: 1 },
{ imageUrl: "/images/gallery/Gallery9.webp", alt: "Gallery 9", order: 2 },
{ imageUrl: "/images/gallery/Gallery6.webp", alt: "Gallery 6", order: 3 },
{ imageUrl: "/images/gallery/Gallery1.webp", alt: "Gallery 1", order: 4 },
{ imageUrl: "/images/gallery/Gallery2.webp", alt: "Gallery 2", order: 5 },
{ imageUrl: "/images/gallery/Gallery3.webp", alt: "Gallery 3", order: 6 },
{ imageUrl: "/images/gallery/Gallery4.webp", alt: "Gallery 4", order: 7 },
{ imageUrl: "/images/gallery/Gallery5.webp", alt: "Gallery 5", order: 8 },
];
async function main() {
console.log('=== Production Migration Script ===\n');
const dbPath = process.env.DATABASE_PATH || '/app/data/gallus_cms.db';
console.log('Database path:', dbPath);
// Check if database exists
if (!fs.existsSync(dbPath)) {
console.error('ERROR: Database not found at:', dbPath);
console.error('Please ensure the backend has been started at least once to create the database.');
process.exit(1);
}
// Check if images exist
const dataDir = process.env.GIT_WORKSPACE_DIR || '/app/data';
const eventsDir = path.join(dataDir, 'images', 'events');
const galleryDir = path.join(dataDir, 'images', 'gallery');
console.log('Events images directory:', eventsDir);
console.log('Gallery images directory:', galleryDir);
if (!fs.existsSync(eventsDir)) {
console.error('ERROR: Events images directory not found:', eventsDir);
process.exit(1);
}
if (!fs.existsSync(galleryDir)) {
console.error('ERROR: Gallery images directory not found:', galleryDir);
process.exit(1);
}
// List available images
console.log('\nAvailable event images:', fs.readdirSync(eventsDir));
console.log('Available gallery images:', fs.readdirSync(galleryDir));
// Connect to database
const sqlite = new Database(dbPath);
const db = drizzle(sqlite);
console.log('\n=== Migrating Events ===\n');
for (const event of oldEvents) {
try {
const [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: event.imageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error.message);
}
}
console.log('\n=== Migrating Gallery Images ===\n');
for (const img of oldGalleryImages) {
try {
const [newImage] = await db.insert(galleryImages).values({
imageUrl: img.imageUrl,
altText: img.alt,
displayOrder: img.order,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error.message);
}
}
sqlite.close();
console.log('\n✓ Migration completed successfully!');
console.log('\nYou can verify the migration by visiting:');
console.log('- Frontend: https://gallus-pub.ch/');
console.log('- Admin: https://gallus-pub.ch/admin');
}
main().catch(error => {
console.error('\n✗ Migration failed:', error);
process.exit(1);
});
+4083
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -9,12 +9,14 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.0",
"@fastify/static": "^6.12.0",
"@fastify/multipart": "^8.1.0",
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.10.0",
+89
View File
@@ -2,14 +2,103 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from '../db/schema.js';
import { env } from './env.js';
import fs from 'fs';
import path from 'path';
if (!env.DATABASE_PATH) {
throw new Error('DATABASE_PATH environment variable is not set');
}
// Ensure directory exists BEFORE opening the database file
const dbDir = path.dirname(env.DATABASE_PATH);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const sqlite = new Database(env.DATABASE_PATH);
// Enable WAL mode for better concurrent access
sqlite.pragma('journal_mode = WAL');
export const db = drizzle(sqlite, { schema });
// Auto-create tables if they don't exist
export function initDatabase() {
console.log('🔧 Initializing database...');
try {
// Check if users table exists (acts as a sentinel for initial setup)
const tableCheck = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get();
if (!tableCheck) {
console.log('📝 Creating database schema...');
sqlite.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
gitea_id TEXT UNIQUE NOT NULL,
gitea_username TEXT NOT NULL,
gitea_email TEXT,
display_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (unixepoch()),
last_login INTEGER
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS gallery_images (
id TEXT PRIMARY KEY,
image_url TEXT NOT NULL,
alt_text TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS content_sections (
id TEXT PRIMARY KEY,
section_name TEXT UNIQUE NOT NULL,
content_json TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS publish_history (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
commit_hash TEXT,
commit_message TEXT,
published_at INTEGER DEFAULT (unixepoch())
);
`);
console.log('✅ Database schema created successfully!');
} else {
console.log('✅ Database already initialized.');
}
} catch (error) {
console.error('❌ Error initializing database:', error);
throw error;
}
}
+11 -1
View File
@@ -1,7 +1,6 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// Users table - stores Gitea user info for audit and access control
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
giteaId: text('gitea_id').notNull().unique(),
@@ -60,3 +59,14 @@ export const publishHistory = sqliteTable('publish_history', {
commitMessage: text('commit_message'),
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Banner table (for announcements like holidays, special info)
export const banners = sqliteTable('banners', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
text: text('text').notNull(),
startDate: text('start_date').notNull(), // ISO date string
endDate: text('end_date').notNull(), // ISO date string
isActive: integer('is_active', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
+57 -1
View File
@@ -5,6 +5,10 @@ import multipart from '@fastify/multipart';
import cookie from '@fastify/cookie';
import { authenticate } from './middleware/auth.middleware.js';
import { env, validateEnv } from './config/env.js';
import { db, initDatabase } from './config/database.js';
import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'fs';
// Import routes
import authRoute from './routes/auth.js';
@@ -13,6 +17,8 @@ import galleryRoute from './routes/gallery.js';
import contentRoute from './routes/content.js';
import settingsRoute from './routes/settings.js';
import publishRoute from './routes/publish.js';
import bannersRoute from './routes/banners.js';
import pdfRoute from './routes/pdf.js';
// Validate environment variables
try {
@@ -36,8 +42,24 @@ const fastify = Fastify({
});
// Register plugins
// Support multiple origins for CORS
const allowedOrigins = env.CORS_ORIGIN.split(',').map(o => o.trim());
fastify.register(cors, {
origin: env.CORS_ORIGIN,
origin: (origin, cb) => {
// Allow requests with no origin (like mobile apps or curl)
if (!origin) {
return cb(null, true);
}
// Check if origin is in allowed list
const isAllowed = allowedOrigins.includes(origin);
if (isAllowed) {
return cb(null, true);
} else {
return cb(null, false);
}
},
credentials: true,
});
@@ -57,6 +79,36 @@ fastify.register(multipart, {
},
});
// Serve static files (uploaded images, etc.) from persistent volume
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
// Muss existieren, sonst verweigert @fastify/static die Registrierung
fs.mkdirSync(dataDir, { recursive: true });
fastify.register(fastifyStatic, {
root: dataDir,
prefix: '/static/',
decorateReply: false
});
// Uploads liegen unter <workspace>/public/images. In der DB stehen sie als
// /images/... - das ist die URL auf der publizierten Astro-Seite. Damit die
// Admin-Oberflaeche dieselben Pfade verwenden kann, hier ebenso ausliefern.
const imagesDir = path.join(dataDir, 'public', 'images');
fs.mkdirSync(imagesDir, { recursive: true });
fastify.register(fastifyStatic, {
root: imagesDir,
prefix: '/images/',
decorateReply: false
});
// Dasselbe fuer die hochgeladenen PDFs (Getraenkekarte)
const pdfDir = path.join(dataDir, 'public', 'pdf');
fs.mkdirSync(pdfDir, { recursive: true });
fastify.register(fastifyStatic, {
root: pdfDir,
prefix: '/pdf/',
decorateReply: false
});
// Decorate fastify with authenticate method
fastify.decorate('authenticate', authenticate);
@@ -67,6 +119,8 @@ fastify.register(galleryRoute, { prefix: '/api' });
fastify.register(contentRoute, { prefix: '/api' });
fastify.register(settingsRoute, { prefix: '/api' });
fastify.register(publishRoute, { prefix: '/api' });
fastify.register(bannersRoute, { prefix: '/api' });
fastify.register(pdfRoute, { prefix: '/api' });
// Health check
fastify.get('/health', async () => {
@@ -99,6 +153,8 @@ fastify.setErrorHandler((error, request, reply) => {
// Start server
const start = async () => {
try {
// Initialize database before starting server
initDatabase();
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
console.log(`🚀 Server listening on port ${env.PORT}`);
console.log(`📝 Environment: ${env.NODE_ENV}`);
+6 -7
View File
@@ -6,7 +6,6 @@ import { eq } from 'drizzle-orm';
import { GiteaService } from '../services/gitea.service.js';
import { env } from '../config/env.js';
// Use explicit JSON schema for Fastify route validation to avoid provider issues
const callbackQueryJsonSchema = {
type: 'object',
required: ['code', 'state'],
@@ -31,9 +30,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
sameSite: 'none',
secure: true,
maxAge: 10 * 60, // 10 minutes
});
@@ -59,7 +57,7 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
// 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' });
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state cookie
@@ -122,11 +120,12 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
);
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
reply.setCookie('token', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
maxAge: 60 * 60 * 24, // 24h
});
+152
View File
@@ -0,0 +1,152 @@
import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js';
import { banners } from '../db/schema.js';
import { eq, and, lte, gte, desc } from 'drizzle-orm';
const bannerBodyJsonSchema = {
type: 'object',
required: ['text', 'startDate', 'endDate'],
properties: {
text: { type: 'string' },
startDate: { type: 'string' },
endDate: { type: 'string' },
isActive: { type: 'boolean' },
},
} as const;
const bannersRoute: FastifyPluginAsync = async (fastify) => {
// Get active banner (public endpoint)
fastify.get('/banners/active', async (request, reply) => {
// Use local date to avoid timezone issues
const now = new Date();
const today = new Date(now.getTime() - (now.getTimezoneOffset() * 60000))
.toISOString()
.split('T')[0]; // YYYY-MM-DD
const [activeBanner] = await db
.select()
.from(banners)
.where(
and(
eq(banners.isActive, true),
lte(banners.startDate, today),
gte(banners.endDate, today)
)
)
.orderBy(desc(banners.createdAt))
.limit(1);
if (!activeBanner) {
return { banner: null };
}
return {
banner: {
id: activeBanner.id,
text: activeBanner.text,
startDate: activeBanner.startDate,
endDate: activeBanner.endDate,
},
};
});
// Get all banners (admin only)
fastify.get('/banners', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const allBanners = await db.select().from(banners);
return {
banners: allBanners.map((b: any) => ({
id: b.id,
text: b.text,
startDate: b.startDate,
endDate: b.endDate,
isActive: b.isActive,
createdAt: b.createdAt,
updatedAt: b.updatedAt,
})),
};
});
// Create banner (admin only)
fastify.post('/banners', {
schema: {
body: bannerBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { text, startDate, endDate, isActive = true } = request.body as any;
const [newBanner] = await db
.insert(banners)
.values({
text,
startDate,
endDate,
isActive,
})
.returning();
return {
banner: {
id: newBanner.id,
text: newBanner.text,
startDate: newBanner.startDate,
endDate: newBanner.endDate,
isActive: newBanner.isActive,
},
};
});
// Update banner (admin only)
fastify.put('/banners/:id', {
schema: {
body: bannerBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const { text, startDate, endDate, isActive } = request.body as any;
const [updated] = await db
.update(banners)
.set({
text,
startDate,
endDate,
isActive,
updatedAt: new Date(),
})
.where(eq(banners.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Banner not found' });
}
return {
banner: {
id: updated.id,
text: updated.text,
startDate: updated.startDate,
endDate: updated.endDate,
isActive: updated.isActive,
},
};
});
// Delete banner (admin only)
fastify.delete('/banners/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
await db.delete(banners).where(eq(banners.id, id));
return { success: true };
});
};
export default bannersRoute;
+44
View File
@@ -3,6 +3,8 @@ import { z } from 'zod';
import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { saveUploadedImage } from '../services/upload.service.js';
import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js';
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
@@ -78,6 +80,20 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
.returning();
}
// Bilder, die durch die Aenderung herausgefallen sind, wegraeumen
const before = extractImageUrls((existing as any)?.contentJson);
const after = extractImageUrls(result.contentJson);
for (const url of before) {
if (after.has(url)) continue;
try {
if (await dropImageIfUnused(url)) {
fastify.log.info(`Removed unused content image ${url}`);
}
} catch (err) {
fastify.log.warn({ err }, 'Could not remove unused content image');
}
}
return {
section: result.sectionName,
content: result.contentJson,
@@ -85,6 +101,34 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
};
});
// Bild fuer einen Textbereich hochladen (Welcome-Bild, Monatshit, Whiskey)
fastify.post('/content/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const file = await (request as any).file();
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
const mime = file.mimetype as string | undefined;
if (!mime || !mime.startsWith('image/')) {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'content', fastify.log);
return reply.code(201).send({ imageUrl: saved.imageUrl });
} catch (err: any) {
if (err?.statusCode === 413) {
return reply.code(413).send({ error: err.message });
}
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// List all content sections
fastify.get('/content', {
preHandler: [fastify.authenticate],
+64 -1
View File
@@ -2,6 +2,19 @@ import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js';
import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { dropImageIfUnused } from '../services/image-refs.service.js';
import { saveUploadedImage } from '../services/upload.service.js';
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
try {
if (await dropImageIfUnused(url)) {
fastify.log.info(`Removed ${reason} ${url}`);
}
} catch (err) {
fastify.log.warn({ err }, `Could not remove ${reason}`);
}
}
// Fastify JSON schema for event body
const eventBodyJsonSchema = {
@@ -36,7 +49,15 @@ const reorderBodyJsonSchema = {
} as const;
const eventsRoute: FastifyPluginAsync = async (fastify) => {
// List all events (by displayOrder)
// PUBLIC: List published events (no auth required)
fastify.get('/events/public', async () => {
const all = await db.select().from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
return { events: all };
});
// List all events (by displayOrder) - admin only
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
const all = await db.select().from(events).orderBy(events.displayOrder);
return { events: all };
@@ -61,16 +82,58 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as any;
const [previous] = await db.select().from(events).where(eq(events.id, id)).limit(1);
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
if (!row) return reply.code(404).send({ error: 'Event not found' });
// Ausgetauschtes Bild wegraeumen, sonst bleibt es fuer immer liegen
if (previous && previous.imageUrl !== row.imageUrl) {
await dropUnusedImage(fastify, previous.imageUrl, 'replaced event image');
}
return { event: row };
});
// Upload event image file (multipart)
fastify.post('/events/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
const file = await (request as any).file();
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
const mime = file.mimetype as string | undefined;
if (!mime || !mime.startsWith('image/')) {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'events', fastify.log);
return reply.code(201).send({ imageUrl: saved.imageUrl });
} catch (err: any) {
if (err?.statusCode === 413) {
return reply.code(413).send({ error: err.message });
}
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// Delete event
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
if (!row) return reply.code(404).send({ error: 'Event not found' });
// Zugehoerige Bilddatei mitnehmen
await dropUnusedImage(fastify, row.imageUrl, 'event image');
return { message: 'Event deleted successfully' };
});
+73 -1
View File
@@ -3,6 +3,19 @@ import { z } from 'zod';
import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { dropImageIfUnused } from '../services/image-refs.service.js';
import { saveUploadedImage } from '../services/upload.service.js';
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
try {
if (await dropImageIfUnused(url)) {
fastify.log.info(`Removed ${reason} ${url}`);
}
} catch (err) {
fastify.log.warn({ err }, `Could not remove ${reason}`);
}
}
// Fastify JSON schema for gallery image body
const galleryBodyJsonSchema = {
@@ -18,7 +31,15 @@ const galleryBodyJsonSchema = {
const galleryRoute: FastifyPluginAsync = async (fastify) => {
// List all gallery images
// PUBLIC: List published gallery images (no auth required)
fastify.get('/gallery/public', async () => {
const images = await db.select().from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
return { images };
});
// List all gallery images - admin only
fastify.get('/gallery', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@@ -54,6 +75,47 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(201).send({ image: newImage });
});
// Upload image file (multipart)
fastify.post('/gallery/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
const file = await (request as any).file();
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
const altText = (file.fields?.altText?.value as string | undefined) || '';
const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0';
const displayOrder = Number.parseInt(displayOrderRaw) || 0;
const mime = file.mimetype as string | undefined;
if (!mime || !mime.startsWith('image/')) {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'gallery', fastify.log);
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({
imageUrl: saved.imageUrl,
altText: altText || saved.filename,
displayOrder,
isPublished: true,
}).returning();
return reply.code(201).send({ image: row });
} catch (err: any) {
if (err?.statusCode === 413) {
return reply.code(413).send({ error: err.message });
}
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// Update gallery image
fastify.put('/gallery/:id', {
schema: {
@@ -64,6 +126,8 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
const { id } = request.params as { id: string };
const data = request.body as any;
const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
const [updated] = await db
.update(galleryImages)
.set(data)
@@ -74,6 +138,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(404).send({ error: 'Image not found' });
}
// Ausgetauschte Datei wegraeumen
if (previous && previous.imageUrl !== updated.imageUrl) {
await dropUnusedImage(fastify, previous.imageUrl, 'replaced gallery image');
}
return { image: updated };
});
@@ -92,6 +161,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(404).send({ error: 'Image not found' });
}
// Zugehoerige Bilddatei mitnehmen
await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image');
return { message: 'Image deleted successfully' };
});
+132
View File
@@ -0,0 +1,132 @@
import { FastifyPluginAsync } from 'fastify';
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { AssetService } from '../services/asset.service.js';
import { env } from '../config/env.js';
const assets = new AssetService();
/**
* Feste PDF-Plaetze. Die URL landet jeweils in einer Content-Section, damit
* der Generator sie beim Publish in die Astro-Komponente schreiben kann.
*/
const PDF_SLOTS: Record<string, { section: string; field: string }> = {
drinks: { section: 'drinks', field: 'pdfUrl' },
};
/** contentJson kommt je nach Treiber als Objekt oder als String zurueck. */
function asObject(value: any): Record<string, any> {
if (!value) return {};
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
return typeof value === 'object' ? value : {};
}
const pdfRoute: FastifyPluginAsync = async (fastify) => {
// PDF fuer einen festen Platz hochladen und verlinken
fastify.post('/pdf/:slot', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { slot } = request.params as { slot: string };
const target = PDF_SLOTS[slot];
if (!target) {
return reply.code(404).send({ error: `Unknown PDF slot "${slot}"` });
}
try {
const file = await (request as any).file();
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
const mime = file.mimetype as string | undefined;
const originalName = (file.filename as string | undefined) || '';
if (mime !== 'application/pdf' && !originalName.toLowerCase().endsWith('.pdf')) {
return reply.code(400).send({ error: 'Only PDF uploads are allowed' });
}
const chunks: Buffer[] = [];
for await (const chunk of file.file) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
if ((file.file as any)?.truncated) {
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
}
// Inhalt gegenpruefen, damit nicht irgendetwas mit .pdf-Endung landet
if (buffer.subarray(0, 5).toString('latin1') !== '%PDF-') {
return reply.code(400).send({ error: 'File is not a valid PDF' });
}
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
fs.mkdirSync(uploadDir, { recursive: true });
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
const filename = `${stamp}-${rand}.pdf`;
fs.writeFileSync(path.join(uploadDir, filename), buffer);
const pdfUrl = `/pdf/${filename}`;
// URL in der Content-Section hinterlegen
const [existing] = await db
.select()
.from(contentSections)
.where(eq(contentSections.sectionName, target.section))
.limit(1);
const previousContent = asObject((existing as any)?.contentJson);
const previousUrl = previousContent[target.field];
const content = { ...previousContent, [target.field]: pdfUrl };
if (existing) {
await db
.update(contentSections)
.set({ contentJson: content, updatedAt: new Date() })
.where(eq(contentSections.sectionName, target.section));
} else {
await db
.insert(contentSections)
.values({ sectionName: target.section, contentJson: content });
}
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
if (previousUrl && previousUrl !== pdfUrl) {
try {
if (assets.deletePdf(previousUrl)) {
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
}
} catch (err) {
fastify.log.warn({ err }, 'Could not remove replaced PDF');
}
}
return reply.code(201).send({ pdfUrl, section: target.section });
} catch (err: any) {
if (err?.code === 'FST_REQ_FILE_TOO_LARGE') {
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
}
fastify.log.error({ err }, 'PDF upload failed');
return reply.code(500).send({ error: 'Failed to upload PDF' });
}
});
};
export default pdfRoute;
+26 -6
View File
@@ -2,6 +2,8 @@ import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { GitService } from '../services/git.service.js';
import { FileGeneratorService } from '../services/file-generator.service.js';
import { AssetService } from '../services/asset.service.js';
import { collectReferencedImageUrls } from '../services/image-refs.service.js';
import { db } from '../config/database.js';
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm';
@@ -34,6 +36,17 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.log.info('Git repository initialized');
// Verwaiste Uploads entfernen, bevor committet wird. Die Referenzliste
// deckt bewusst alle Zeilen ab, auch unveroeffentlichte, und zusaetzlich
// die Bildpfade aus den Textbereichen.
const assetService = new AssetService();
const referencedImages = await collectReferencedImageUrls();
const removedImages = assetService.sweepOrphanedImages([...referencedImages]);
if (removedImages.length > 0) {
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
}
// Fetch all content from database
const eventsData = await db
.select()
@@ -78,16 +91,23 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.log.info(`Changes committed: ${commitHash}`);
// Record in history
await db.insert(publishHistory).values({
userId,
commitHash,
commitMessage,
});
// Record in history. Der Push ist an dieser Stelle bereits durch -
// ein Fehler im Protokoll darf die Veroeffentlichung nicht als
// gescheitert melden und den Workspace zuruecksetzen.
try {
await db.insert(publishHistory).values({
userId,
commitHash,
commitMessage,
});
} catch (historyError) {
fastify.log.warn({ err: historyError }, 'Could not record publish history');
}
return {
success: true,
commitHash,
removedImages: removedImages.length,
message: 'Changes published successfully',
};
+190
View File
@@ -0,0 +1,190 @@
import { db } from '../config/database.js';
import { events, galleryImages } from '../db/schema.js';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Old events data
const oldEvents = [
{
image: "/images/events/event_karaoke.jpg",
title: "Karaoke",
date: "2025-12-31", // Set as ongoing event
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 0,
},
{
image: "/images/events/event_pub-quiz.jpg",
title: "Pub Quiz",
date: "2025-12-31", // Set as ongoing event
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
Auch Einzelpersonen sind herzlich willkommen! <br>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
image: "/images/events/event_schlager-karaoke.jpeg",
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 2,
},
{
image: "/images/events/event_advents-kalender.jpeg",
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
displayOrder: 3,
},
{
image: "/images/events/event_santa_karaoke.jpeg",
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
displayOrder: 4,
},
{
image: "/images/events/event_ferien.jpeg",
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
displayOrder: 5,
},
{
image: "/images/events/event_neujahrs-apero.jpeg",
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ src: "/images/gallery/Gallery7.png", alt: "Gallery 7" },
{ src: "/images/gallery/Gallery8.png", alt: "Gallery 8" },
{ src: "/images/gallery/Gallery9.png", alt: "Gallery 9" },
{ src: "/images/gallery/Gallery6.png", alt: "Gallery 6" },
{ src: "/images/gallery/Gallery1.png", alt: "Gallery 1" },
{ src: "/images/gallery/Gallery2.png", alt: "Gallery 2" },
{ src: "/images/gallery/Gallery3.png", alt: "Gallery 3" },
{ src: "/images/gallery/Gallery4.png", alt: "Gallery 4" },
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
];
async function copyAndConvertImage(
sourcePath: string,
destDir: string,
filename: string
): Promise<string> {
const projectRoot = path.join(process.cwd(), '..');
const fullSourcePath = path.join(projectRoot, 'public', sourcePath);
// Ensure destination directory exists
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const ext = path.extname(filename);
const baseName = path.basename(filename, ext);
const webpFilename = `${baseName}.webp`;
const destPath = path.join(destDir, webpFilename);
console.log(`Processing: ${fullSourcePath} -> ${destPath}`);
// Check if source exists
if (!fs.existsSync(fullSourcePath)) {
console.error(`Source file not found: ${fullSourcePath}`);
throw new Error(`Source file not found: ${fullSourcePath}`);
}
// Convert to webp and copy
await sharp(fullSourcePath)
.rotate() // Auto-rotate based on EXIF
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(destPath);
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
}
async function migrateEvents() {
console.log('\n=== Migrating Events ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const eventsImageDir = path.join(dataDir, 'images', 'events');
for (const event of oldEvents) {
try {
const filename = path.basename(event.image);
const newImageUrl = await copyAndConvertImage(
event.image,
eventsImageDir,
filename
);
const [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: newImageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error);
}
}
}
async function migrateGallery() {
console.log('\n=== Migrating Gallery Images ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const galleryImageDir = path.join(dataDir, 'images', 'gallery');
for (let i = 0; i < oldGalleryImages.length; i++) {
const img = oldGalleryImages[i];
try {
const filename = path.basename(img.src);
const newImageUrl = await copyAndConvertImage(
img.src,
galleryImageDir,
filename
);
const [newImage] = await db.insert(galleryImages).values({
imageUrl: newImageUrl,
altText: img.alt,
displayOrder: i,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error);
}
}
}
async function main() {
console.log('Starting migration of old data...\n');
console.log('Working directory:', process.cwd());
console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'));
try {
await migrateEvents();
await migrateGallery();
console.log('\n✓ Migration completed successfully!');
} catch (error) {
console.error('\n✗ Migration failed:', error);
process.exit(1);
}
}
main();
+114
View File
@@ -0,0 +1,114 @@
import fs from 'fs';
import path from 'path';
import { env } from '../config/env.js';
/**
* Verwaltet die Dateien, die das CMS in den Git-Workspace schreibt.
*
* Grundregel: geloescht wird ausschliesslich, was das CMS selbst angelegt hat.
* Uploads bekommen den Namen <base36-zeitstempel>-<6 zufaellige zeichen>.<ext>
* (siehe events.ts / gallery.ts). Handgepflegte Assets aus dem Repo heissen
* anders - event_karaoke.jpg, Welcome.png, Gallery1.webp - und werden dadurch
* nie angefasst, auch wenn sie in keinem Datensatz mehr vorkommen.
*/
// Unterordner unterhalb von public/, in denen das CMS aufraeumen darf
const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
const MANAGED_PDF_DIR = 'pdf';
// Namensmuster der CMS-Uploads
const GENERATED_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
export class AssetService {
private publicDir: string;
constructor() {
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
}
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb */
private resolveInPublic(url: string): string | null {
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
const relative = url.replace(/^\/+/, '').split('?')[0].split('#')[0];
const absolute = path.resolve(this.publicDir, relative);
// Traversal-Schutz: muss unterhalb von public/ bleiben
if (absolute !== this.publicDir && !absolute.startsWith(this.publicDir + path.sep)) {
return null;
}
return absolute;
}
/**
* Pfad einer Datei, die das CMS loeschen darf.
* Liefert null fuer fremde Pfade und fuer handgepflegte Dateien.
*/
resolveDeletable(url: string, dirs: string[]): string | null {
const absolute = this.resolveInPublic(url);
if (!absolute) return null;
const relative = path.relative(this.publicDir, absolute);
const dir = path.dirname(relative).split(path.sep).join('/');
if (!dirs.includes(dir)) return null;
if (!GENERATED_NAME.test(path.basename(absolute))) return null;
return absolute;
}
/** Loescht ein hochgeladenes Bild. Gibt zurueck, ob wirklich etwas weg ist. */
deleteImage(url: string): boolean {
return this.unlink(this.resolveDeletable(url, MANAGED_IMAGE_DIRS));
}
/** Loescht ein hochgeladenes PDF. */
deletePdf(url: string): boolean {
return this.unlink(this.resolveDeletable(url, [MANAGED_PDF_DIR]));
}
private unlink(absolute: string | null): boolean {
if (!absolute) return false;
try {
fs.unlinkSync(absolute);
return true;
} catch (err: any) {
if (err?.code === 'ENOENT') return false;
throw err;
}
}
/**
* Entfernt alle hochgeladenen Bilder, die in keiner der uebergebenen URLs
* mehr vorkommen. Die Liste muss ALLE Datensaetze abdecken, auch
* unveroeffentlichte - sonst verlieren die ihr Bild.
*/
sweepOrphanedImages(referencedUrls: string[]): string[] {
const keep = new Set<string>();
for (const url of referencedUrls) {
const absolute = this.resolveInPublic(url);
if (absolute) keep.add(absolute);
}
const removed: string[] = [];
for (const dir of MANAGED_IMAGE_DIRS) {
const absoluteDir = path.join(this.publicDir, dir);
if (!fs.existsSync(absoluteDir)) continue;
for (const name of fs.readdirSync(absoluteDir)) {
const absolute = path.join(absoluteDir, name);
if (!GENERATED_NAME.test(name)) continue;
if (keep.has(absolute)) continue;
if (!fs.statSync(absolute).isFile()) continue;
fs.unlinkSync(absolute);
removed.push('/' + dir + '/' + name);
}
}
return removed;
}
}
+40 -17
View File
@@ -27,6 +27,27 @@ export class FileGeneratorService {
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
}
/**
* Texte aus dem Adminbereich landen direkt im Astro-Markup. Ohne Maskierung
* reicht ein "<" oder "&" in einem Feld, damit der Build der Seite scheitert
* und der Deploy stehen bleibt.
*/
escapeHtml(value: any): string {
if (value === undefined || value === null) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Wie escapeHtml, aber mit Rueckfallwert wenn nichts gesetzt ist. */
text(value: any, fallback = ''): string {
const raw = value === undefined || value === null || value === '' ? fallback : value;
return this.escapeHtml(raw);
}
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
const eventsCode = events.map(e => `\t{
\t\timage: "${e.imageUrl}",
@@ -43,6 +64,7 @@ export class FileGeneratorService {
return `---
import Layout from "../components/Layout.astro";
import Banner from "../components/Banner.astro";
import Hero from "../components/Hero.astro";
import Welcome from "../components/Welcome.astro";
import EventsGrid from "../components/EventsGrid.astro";
@@ -62,6 +84,7 @@ ${imagesCode}
<Layout>
\t<Hero id="hero" />
\t<Banner />
\t<Welcome id="welcome" />
\t<EventsGrid id="events" events={events} />
\t<ImageCarousel id="gallery" images={images} />
@@ -84,9 +107,9 @@ const { id } = Astro.props;
\t\t<div class="hero-content">
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
\t\t\t<h1>${this.text(content.heading, 'Dein Irish Pub')}</h1>
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
\t\t\t<p>${this.text(content.subheading, 'Im Herzen von St.Gallen')}</p>
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
\t\t</div>
@@ -103,7 +126,7 @@ const { id } = Astro.props;
generateWelcomeComponent(content: ContentSection): string {
const highlightsList = (content.highlights || []).map((h: any) =>
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
`\t\t\t<li>\n\t\t\t\t<b>${this.escapeHtml(h?.title)}:</b> ${this.escapeHtml(h?.description)}\n\t\t\t</li>`
).join('\n\n');
return `---
@@ -117,11 +140,11 @@ const { id } = Astro.props;
\t<div class="welcome-text">
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
\t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
\t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
\t\t<p>
\t\t\t${content.introText || ''}
\t\t\t${this.text(content.introText)}
\t\t</p>
\t\t<p><b>Unsere Highlights:</b></p>
@@ -131,14 +154,14 @@ ${highlightsList}
\t\t</ul>
\t\t<p>
\t\t\t${content.closingText || ''}
\t\t\t${this.text(content.closingText)}
\t\t</p>
\t</div>
\t<div class="welcome-image">
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
\t\t<img src="${this.text(content.imageUrl, '/images/Welcome.png')}" alt="Welcome background image" />
\t</div>
</section>
@@ -155,36 +178,36 @@ const { id } = Astro.props;
<h2 class="title">Drinks</h2>
<p class="note">
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein hier kannst du in entspannter Atmosphäre das Leben genießen.'}
${this.text(content.introText, 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein hier kannst du in entspannter Atmosphäre das Leben genießen.')}
</p>
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<a href="${this.text(content.pdfUrl, '/pdf/Getraenke_Gallus_2025.pdf')}" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka">
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
<div class="circle" title="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
<img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
<div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
</div>
<p class="note">
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
${this.text(content.whiskeyText, 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.')}
</p>
<div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
<img src="${this.text(content.whiskeyImage1, '/images/Whiskey1.png')}" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
<img src="${this.text(content.whiskeyImage2, '/images/Whiskey2.png')}" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
<img src="${this.text(content.whiskeyImage3, '/images/Whiskey3.png')}" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
+83 -15
View File
@@ -1,43 +1,94 @@
import simpleGit, { SimpleGit } from 'simple-git';
import { mkdir, rm } from 'fs/promises';
import { mkdir, rm, cp } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { env } from '../config/env.js';
// Verzeichnisse, in die das CMS hochlaedt. Die muessen einen Neu-Clone des
// Workspace ueberleben, sonst sind Bilder und Getraenkekarte weg.
const UPLOAD_DIRS = [
path.join('public', 'images'),
path.join('public', 'pdf'),
];
export class GitService {
private git: SimpleGit;
private workspaceDir: string;
private parentDir: string;
private repoUrl: string;
private token: string;
constructor() {
this.workspaceDir = env.GIT_WORKSPACE_DIR;
this.parentDir = path.dirname(this.workspaceDir);
this.repoUrl = env.GIT_REPO_URL;
this.token = env.GIT_TOKEN;
this.git = simpleGit();
}
async initialize() {
// Ensure workspace directory exists
await mkdir(this.workspaceDir, { recursive: true });
// Elternverzeichnis muss existieren - simple-git startet git von dort aus
await mkdir(this.parentDir, { recursive: true });
// Add token to repo URL for authentication
const authenticatedUrl = this.repoUrl.replace(
'https://',
`https://oauth2:${this.token}@`
`https://oauth2:${encodeURIComponent(this.token)}@`
);
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
let usable = false;
if (existsSync(path.join(this.workspaceDir, '.git'))) {
try {
this.git = simpleGit(this.workspaceDir);
await this.git.status();
console.log('Repository already exists, pulling latest...');
await this.git.pull();
usable = true;
} catch (error) {
// Token aus der Meldung entfernen - git gibt die Remote-URL mit aus
const msg = error instanceof Error ? error.message : String(error);
console.warn(
'Existing workspace unusable, re-cloning:',
msg.replace(/\/\/[^@\s/]*@/g, '//***@')
);
}
}
if (!usable) {
console.log('Cloning repository...');
// Hochgeladene Dateien liegen im Workspace und wuerden beim Loeschen
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
const backupRoot = path.join(this.parentDir, '.workspace-upload-backup');
await rm(backupRoot, { recursive: true, force: true });
const saved: string[] = [];
for (const relative of UPLOAD_DIRS) {
const source = path.join(this.workspaceDir, relative);
if (!existsSync(source)) continue;
await cp(source, path.join(backupRoot, relative), { recursive: true });
saved.push(relative);
}
await rm(this.workspaceDir, { recursive: true, force: true });
// Aus dem existierenden Elternverzeichnis klonen, nicht aus dem
// soeben geloeschten Zielverzeichnis (sonst: spawn git ENOENT)
this.git = simpleGit(this.parentDir);
await this.git.clone(authenticatedUrl, this.workspaceDir);
await this.git.cwd(this.workspaceDir);
this.git = simpleGit(this.workspaceDir);
for (const relative of saved) {
// force: false -> was schon im Repo liegt, bleibt unangetastet
await cp(path.join(backupRoot, relative), path.join(this.workspaceDir, relative), {
recursive: true,
force: false,
errorOnExist: false,
});
}
await rm(backupRoot, { recursive: true, force: true });
}
// Configure git user
@@ -47,6 +98,14 @@ export class GitService {
async commitAndPush(message: string): Promise<string> {
await this.git.add('.');
const status = await this.git.status();
if (status.isClean()) {
// Nichts zu committen - aktuellen Stand zurueckgeben statt zu scheitern
const current = await this.git.log({ maxCount: 1 });
return current.latest?.hash || '';
}
await this.git.commit(message);
await this.git.push('origin', 'main');
@@ -59,7 +118,16 @@ export class GitService {
}
async reset() {
await this.git.reset(['--hard', 'HEAD']);
await this.git.clean('f', ['-d']);
// Nur zuruecksetzen wenn wirklich ein Repo da ist - sonst laeuft git
// im Prozess-Arbeitsverzeichnis und raeumt dort auf
if (!existsSync(path.join(this.workspaceDir, '.git'))) {
return;
}
const git = simpleGit(this.workspaceDir);
await git.reset(['--hard', 'HEAD']);
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
const excludes = UPLOAD_DIRS.flatMap((dir) => ['-e', dir.split(path.sep).join('/')]);
await git.clean('f', ['-d', ...excludes]);
}
}
@@ -0,0 +1,77 @@
import { db } from '../config/database.js';
import { events, galleryImages, contentSections } from '../db/schema.js';
import { AssetService } from './asset.service.js';
const assets = new AssetService();
/**
* Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird.
* Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden.
*/
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
if (!url) return false;
const referenced = await collectReferencedImageUrls();
if (referenced.has(url)) return false;
return assets.deleteImage(url);
}
/**
* Sammelt jede Bild-URL, die irgendwo in der Datenbank vorkommt.
*
* Bewusst ueber ALLE Zeilen, nicht nur die veroeffentlichten - sonst wuerde
* ein unveroeffentlichtes Event sein Bild verlieren, sobald jemand publisht.
*
* Die Content-Sections enthalten beliebiges JSON (Welcome-Bild, Monatshit,
* Whiskey-Bilder), deshalb wird es rekursiv nach Bildpfaden durchsucht.
*/
export async function collectReferencedImageUrls(): Promise<Set<string>> {
const urls = new Set<string>();
for (const row of (await db.select().from(events)) as any[]) {
if (row.imageUrl) urls.add(row.imageUrl);
}
for (const row of (await db.select().from(galleryImages)) as any[]) {
if (row.imageUrl) urls.add(row.imageUrl);
}
for (const row of (await db.select().from(contentSections)) as any[]) {
collectFromJson(row.contentJson, urls);
}
return urls;
}
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
export function extractImageUrls(value: any): Set<string> {
const out = new Set<string>();
collectFromJson(value, out);
return out;
}
function collectFromJson(value: any, out: Set<string>): void {
if (typeof value === 'string') {
if (value.startsWith('/images/')) {
out.add(value);
return;
}
// Je nach Treiber kommt das JSON als String zurueck
if (value.startsWith('{') || value.startsWith('[')) {
try {
collectFromJson(JSON.parse(value), out);
} catch {
// kein JSON - ignorieren
}
}
return;
}
if (Array.isArray(value)) {
for (const entry of value) collectFromJson(entry, out);
return;
}
if (value && typeof value === 'object') {
for (const entry of Object.values(value)) collectFromJson(entry, out);
}
}
+67
View File
@@ -0,0 +1,67 @@
import fs from 'fs';
import path from 'path';
import { env } from '../config/env.js';
export type UploadSubdir = 'events' | 'gallery' | 'content';
export interface SavedImage {
filename: string;
imageUrl: string;
}
/**
* Nimmt einen Multipart-Upload entgegen, rechnet ihn nach WebP herunter und
* legt ihn unter public/images/<subdir> im Git-Workspace ab.
*
* Der Dateiname folgt dem Muster <zeitstempel>-<zufall>.<ext>. Daran erkennt
* der AssetService spaeter, dass er die Datei wieder loeschen darf.
*/
export async function saveUploadedImage(
file: any,
subdir: UploadSubdir,
log?: { warn: (obj: any, msg: string) => void }
): Promise<SavedImage> {
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
fs.mkdirSync(uploadDir, { recursive: true });
const chunks: Buffer[] = [];
for await (const chunk of file.file) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const inputBuffer = Buffer.concat(chunks);
// Ohne diese Pruefung landet bei zu grossen Dateien ein abgeschnittenes,
// kaputtes Bild auf der Platte
if (file.file?.truncated) {
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
const error: any = new Error(`Image too large. Maximum is ${limit} MB`);
error.statusCode = 413;
throw error;
}
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
let outBuffer: Buffer;
let outExt = '.webp';
try {
// Sharp erst laden wenn wirklich gebraucht
const sharp = (await import('sharp')).default;
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
} catch (err) {
log?.warn({ err }, 'Sharp processing failed, using original image');
outBuffer = inputBuffer;
const extFromMime = (file.mimetype || '').split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
const filename = `${stamp}-${rand}${outExt}`;
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
return { filename, imageUrl: `/images/${subdir}/${filename}` };
}
+6 -10
View File
@@ -4,14 +4,14 @@ kill_signal = "SIGINT"
kill_timeout = 5
[build]
dockerfile = "Dockerfile.fly"
dockerfile = "Dockerfile"
[env]
PORT = "3000" # Caddy (serves frontend + proxies /api/*)
PORT = "3000"
NODE_ENV = "production"
BACKEND_PORT = "8080" # Fastify backend will listen here
DATABASE_PATH = "/app/data/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/workspace"
BACKEND_PORT = "8080"
DATABASE_PATH = "/app/data/db/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
[http_service]
internal_port = 3000
@@ -46,8 +46,4 @@ kill_timeout = 5
[[mounts]]
source = "gallus_data"
destination = "/app/data"
[[mounts]]
source = "gallus_workspace"
destination = "/app/workspace"
destination = "/app/data"
+788 -555
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

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