Compare commits
42 Commits
main-backu
...
25305c4aad
| Author | SHA1 | Date | |
|---|---|---|---|
| 25305c4aad | |||
| 0597c73690 | |||
| 0a2aa84a8c | |||
| 1120472af8 | |||
| db3a38ed45 | |||
| 4f8feb8652 | |||
| 0c291079ff | |||
| fe2f61cdc2 | |||
| af4877300f | |||
| 4a103cf7d6 | |||
| 97e7f88906 | |||
| 807c56de5a | |||
| 8ca30ae5f3 | |||
| 8f1254840c | |||
| 8b2d00385a | |||
| c55e274718 | |||
| e9a95ccf8d | |||
| b16ac76620 | |||
| 0e03b9dea9 | |||
| da3a950a1a | |||
| fb7eaa6bb2 | |||
| daccc43677 | |||
| 3b6cb0a3fb | |||
| 6a3c77d7c5 | |||
| a28d43db45 | |||
| af930f345c | |||
| 22494084ce | |||
| bc6c1e95d3 | |||
| f2a0422f3b | |||
| 2cae2e86ed | |||
| 636c7fc03a | |||
| 5fdea37a90 | |||
| 11932d51ec | |||
| 803c7907f1 | |||
| 3d4bbf77bc | |||
| 71a586280e | |||
| 1f4cea0c35 | |||
| 9adec32839 | |||
| 688b4de945 | |||
| 193f3ff0bb | |||
| 292747d197 | |||
| 18f7ea5da5 |
31
.env.example
@ -1,31 +0,0 @@
|
|||||||
# Copy this file to .env.local for local development
|
|
||||||
# Then run: npm run dev:local
|
|
||||||
|
|
||||||
# Public base URL for your local dev server
|
|
||||||
PUBLIC_BASE_URL=http://localhost:4321
|
|
||||||
|
|
||||||
# OAuth (Gitea) settings for local development
|
|
||||||
# Create an OAuth2 Application in your Gitea with Redirect URI:
|
|
||||||
# http://localhost:4321/api/auth/callback
|
|
||||||
# Then paste the resulting Client ID/Secret below
|
|
||||||
OAUTH_PROVIDER=gitea
|
|
||||||
OAUTH_CLIENT_ID=
|
|
||||||
OAUTH_CLIENT_SECRET=
|
|
||||||
OAUTH_AUTHORIZE_URL=https://git.bookageek.ch/login/oauth/authorize
|
|
||||||
OAUTH_TOKEN_URL=https://git.bookageek.ch/login/oauth/access_token
|
|
||||||
OAUTH_USERINFO_URL=https://git.bookageek.ch/api/v1/user
|
|
||||||
|
|
||||||
# Optional access control
|
|
||||||
# OAUTH_ALLOWED_USERS=user1,user2
|
|
||||||
# OAUTH_ALLOWED_ORG=your-org
|
|
||||||
|
|
||||||
# Gitea API for committing content changes (service account PAT)
|
|
||||||
GITEA_BASE=https://git.bookageek.ch
|
|
||||||
GITEA_OWNER=
|
|
||||||
GITEA_REPO=
|
|
||||||
GITEA_TOKEN=
|
|
||||||
GIT_BRANCH=main
|
|
||||||
|
|
||||||
# Session and CSRF secrets (use random long strings in .env.local)
|
|
||||||
SESSION_SECRET=
|
|
||||||
CSRF_SECRET=
|
|
||||||
2
.gitignore
vendored
@ -16,10 +16,10 @@ pnpm-debug.log*
|
|||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
.env.local
|
|
||||||
|
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
/ai/
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
steps:
|
steps:
|
||||||
deploy:
|
deploy_frontend:
|
||||||
image: node:20
|
image: node:20
|
||||||
environment:
|
environment:
|
||||||
FLY_API_TOKEN:
|
FLY_API_TOKEN:
|
||||||
@ -7,10 +7,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- curl -L https://fly.io/install.sh | sh
|
- curl -L https://fly.io/install.sh | sh
|
||||||
- export PATH="$HOME/.fly/bin:$PATH"
|
- export PATH="$HOME/.fly/bin:$PATH"
|
||||||
- flyctl deploy --config fly.toml --app gallus-pub
|
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
|
||||||
|
when:
|
||||||
when:
|
branch: main
|
||||||
branch:
|
event: push
|
||||||
- main
|
|
||||||
event:
|
|
||||||
- push
|
|
||||||
23
Dockerfile
@ -1,29 +1,24 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
# Fallback to npm install if no lockfile is present
|
||||||
|
RUN npm ci || npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Ensure CSS variables are present
|
||||||
|
RUN mkdir -p public/styles
|
||||||
|
RUN cp -r styles/* public/styles/ || true
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN npm install -g serve
|
||||||
# Copy built app and minimal runtime files
|
COPY --from=build /app/dist ./dist
|
||||||
COPY --from=build /app/dist /app/dist
|
|
||||||
COPY --from=build /app/package*.json /app/
|
|
||||||
|
|
||||||
RUN npm pkg delete devDependencies || true
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
|
||||||
# Run Astro server entry (node adapter standalone)
|
CMD ["serve", "-l", "3000", "dist"]
|
||||||
CMD ["node", "dist/server/entry.mjs"]
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||||
9
Dockerfile.caddy
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
|
# Embed Caddyfile directly to avoid host path issues on Windows
|
||||||
|
RUN mkdir -p /etc/caddy \
|
||||||
|
&& printf ":80\nlog\nroute {\n handle /api/* {\n reverse_proxy backend:8080\n }\n handle {\n reverse_proxy frontend:3000\n }\n}\n" > /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
142
MIGRATION_README.md
Normal file
@ -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
|
||||||
87
README.md
@ -1,55 +1,48 @@
|
|||||||
# Gallus Pub Website
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
This is the Gallus Pub website built with Astro. It includes an admin area at `/admin` for editing content (events, gallery, texts). Changes are committed back to the Git repository via the Gitea API which triggers your Woodpecker + Fly.io deployment pipeline.
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
To run the site locally with OAuth login (Gitea):
|
|
||||||
|
|
||||||
1. Copy the example env file and fill values:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env.local
|
|
||||||
```
|
|
||||||
- Create a Gitea OAuth application with Redirect URI: `http://localhost:4321/api/auth/callback`.
|
|
||||||
- Set `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` from Gitea.
|
|
||||||
- Set `GITEA_OWNER`, `GITEA_REPO`, and a `GITEA_TOKEN` (PAT) with write access to the repo.
|
|
||||||
- Generate random secrets for sessions/CSRF (e.g. `openssl rand -hex 32`).
|
|
||||||
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start dev server using your local env file:
|
|
||||||
```bash
|
|
||||||
npm run dev:local
|
|
||||||
```
|
|
||||||
The site runs at http://localhost:4321. Visit http://localhost:4321/admin to log in via Gitea OAuth.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- If OAuth variables are missing or malformed, the auth endpoints return a clear 500 with guidance instead of crashing.
|
|
||||||
- Production secrets are configured on Fly.io; `.env.local` is ignored by Git.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
|
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
|
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
/
|
/
|
||||||
├── public/ # static assets
|
├── public/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── content/ # editable JSON content (events, gallery)
|
│ └── pages/
|
||||||
│ ├── pages/ # Astro pages, includes /admin and API routes
|
│ └── index.astro
|
||||||
│ ├── components/ # UI components
|
|
||||||
│ └── utils/ # session helpers
|
|
||||||
├── .env.example # template for local env
|
|
||||||
├── fly.toml # Fly.io config
|
|
||||||
├── Dockerfile
|
|
||||||
└── package.json
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
- `npm install` – install deps
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
- `npm run dev` – dev server without loading .env.local (expects env to be present in the shell)
|
|
||||||
- `npm run dev:local` – dev server loading `.env.local` via dotenv-cli
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
- `npm run build` – production build (SSR via @astrojs/node)
|
|
||||||
- `npm run preview` – preview the production build
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
|
# Test commit to trigger Woodpecker
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import node from '@astrojs/node';
|
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({});
|
||||||
output: 'server',
|
|
||||||
adapter: node({ mode: 'standalone' })
|
|
||||||
});
|
|
||||||
|
|||||||
20
backend/.dockerignore
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
tmp
|
||||||
|
/tmp
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
29
backend/.env.example
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Database (SQLite)
|
||||||
|
DATABASE_PATH=./data/gallus_cms.db
|
||||||
|
|
||||||
|
# Gitea OAuth
|
||||||
|
GITEA_URL=https://git.bookageek.ch
|
||||||
|
GITEA_CLIENT_ID=your-oauth-client-id-here
|
||||||
|
GITEA_CLIENT_SECRET=your-oauth-client-secret-here
|
||||||
|
GITEA_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
||||||
|
GITEA_ALLOWED_USERS=sabrina,raphael,admin
|
||||||
|
|
||||||
|
# Git Configuration (use Gitea repository)
|
||||||
|
GIT_REPO_URL=https://git.bookageek.ch/yourusername/Gallus_Pub.git
|
||||||
|
GIT_TOKEN=your-gitea-personal-access-token-here
|
||||||
|
GIT_USER_NAME=Gallus CMS
|
||||||
|
GIT_USER_EMAIL=cms@galluspub.ch
|
||||||
|
GIT_WORKSPACE_DIR=./data/workspace
|
||||||
|
|
||||||
|
# JWT & Session
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||||
|
SESSION_SECRET=your-session-secret-change-this
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
34
backend/.env.local
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Local development environment for Gallus CMS Backend
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_CLIENT=sqlite
|
||||||
|
DATABASE_URL=
|
||||||
|
DATABASE_PATH=./data/gallus_cms.db
|
||||||
|
|
||||||
|
# Gitea OAuth
|
||||||
|
GITEA_URL=https://git.bookageek.ch
|
||||||
|
GITEA_CLIENT_ID=bcddfe24-3099-41bc-bb7a-6c6d80bd9048
|
||||||
|
GITEA_CLIENT_SECRET=gto_me7hsswrsdq2ygey65edlc6qa2xxr3i5nrq7q4jvjwx654ytrh7q
|
||||||
|
# Frontend proxy callback in local dev
|
||||||
|
GITEA_REDIRECT_URI=http://localhost:4321/api/auth/callback
|
||||||
|
GITEA_ALLOWED_USERS=Gallus-maintanance
|
||||||
|
|
||||||
|
# Git repository for content versioning
|
||||||
|
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub
|
||||||
|
GIT_TOKEN=1482ae7bcdbd7610bf0cfd468b6757722d16a2a2
|
||||||
|
GIT_USER_NAME=Gallus-maintanance
|
||||||
|
GIT_USER_EMAIL=Admin@gallus-pub.ch
|
||||||
|
GIT_WORKSPACE_DIR=./data/workspace
|
||||||
|
|
||||||
|
# JWT & Session secrets (use strong random strings in real deployments)
|
||||||
|
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
|
||||||
|
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
|
||||||
|
|
||||||
|
# Server & CORS
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
FRONTEND_URL=http://localhost:4321
|
||||||
|
CORS_ORIGIN=http://localhost:4321
|
||||||
|
|
||||||
|
# Upload limits
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
12
backend/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
/tmp
|
||||||
|
/data/*.db
|
||||||
|
/data/*.db-wal
|
||||||
|
/data/*.db-shm
|
||||||
|
/data/workspace
|
||||||
|
# Allow images to be committed
|
||||||
|
!/data/images
|
||||||
195
backend/DEPLOYMENT.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
|
||||||
|
2. Fly.io account: `flyctl auth login`
|
||||||
|
3. Gitea OAuth app configured at git.bookageek.ch
|
||||||
|
4. Gitea Personal Access Token for git operations
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
### 1. Create Fly.io App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
flyctl apps create gallus-cms-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Volume for Data (SQLite DB + Git Workspace)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flyctl volumes create gallus_data --size 2 --region ams
|
||||||
|
```
|
||||||
|
|
||||||
|
This volume will store:
|
||||||
|
- SQLite database at `/app/data/gallus_cms.db`
|
||||||
|
- Git workspace at `/app/data/workspace`
|
||||||
|
|
||||||
|
### 3. Set Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flyctl secrets set \
|
||||||
|
GITEA_CLIENT_ID="<your-gitea-oauth-client-id>" \
|
||||||
|
GITEA_CLIENT_SECRET="<your-gitea-oauth-client-secret>" \
|
||||||
|
GIT_TOKEN="<your-gitea-personal-access-token>" \
|
||||||
|
JWT_SECRET="$(openssl rand -base64 32)" \
|
||||||
|
SESSION_SECRET="$(openssl rand -base64 32)" \
|
||||||
|
GIT_REPO_URL="https://git.bookageek.ch/yourusername/Gallus_Pub.git" \
|
||||||
|
GIT_USER_NAME="Gallus CMS" \
|
||||||
|
GIT_USER_EMAIL="cms@galluspub.ch" \
|
||||||
|
GITEA_REDIRECT_URI="https://gallus-cms-backend.fly.dev/api/auth/callback" \
|
||||||
|
FRONTEND_URL="https://cms.galluspub.ch" \
|
||||||
|
CORS_ORIGIN="https://cms.galluspub.ch" \
|
||||||
|
GITEA_ALLOWED_USERS="sabrina,raphael"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Initialize Database
|
||||||
|
|
||||||
|
After first deployment, SSH into the container and run migrations:
|
||||||
|
```bash
|
||||||
|
flyctl ssh console
|
||||||
|
cd /app
|
||||||
|
node dist/index.js # Start once to create the database file
|
||||||
|
# Then exit (Ctrl+C) and run migrations
|
||||||
|
npm run db:migrate
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simply let the app run - the database will be created automatically on first start.
|
||||||
|
|
||||||
|
## Gitea OAuth Configuration
|
||||||
|
|
||||||
|
Update your Gitea OAuth application redirect URI to include:
|
||||||
|
```
|
||||||
|
https://gallus-cms-backend.fly.dev/api/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
```bash
|
||||||
|
flyctl logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Status
|
||||||
|
```bash
|
||||||
|
flyctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSH into Container
|
||||||
|
```bash
|
||||||
|
flyctl ssh console
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scale App
|
||||||
|
```bash
|
||||||
|
flyctl scale count 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Secrets
|
||||||
|
```bash
|
||||||
|
flyctl secrets list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a Secret
|
||||||
|
```bash
|
||||||
|
flyctl secrets set KEY=VALUE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart App
|
||||||
|
```bash
|
||||||
|
flyctl apps restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
curl https://gallus-cms-backend.fly.dev/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Metrics
|
||||||
|
```bash
|
||||||
|
flyctl dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Deployment Fails
|
||||||
|
- Check logs: `flyctl logs`
|
||||||
|
- Verify all secrets are set: `flyctl secrets list`
|
||||||
|
- Ensure Docker builds locally: `docker build -t test .`
|
||||||
|
|
||||||
|
### OAuth Not Working
|
||||||
|
- Verify GITEA_REDIRECT_URI matches Gitea settings exactly
|
||||||
|
- Check CORS_ORIGIN includes frontend domain
|
||||||
|
- Review logs for authentication errors
|
||||||
|
|
||||||
|
### Git Push Fails
|
||||||
|
- Verify GIT_TOKEN has correct permissions
|
||||||
|
- Check GIT_REPO_URL is accessible
|
||||||
|
- Ensure workspace volume is mounted
|
||||||
|
|
||||||
|
### Database Issues
|
||||||
|
- Verify DATABASE_PATH is set correctly
|
||||||
|
- Check volume is mounted: `flyctl ssh console` then `ls -la /app/data`
|
||||||
|
- Verify database file permissions
|
||||||
|
- Run migrations if needed: `flyctl ssh console` then `npm run db:migrate`
|
||||||
|
|
||||||
|
## Cost Optimization
|
||||||
|
|
||||||
|
Current configuration uses:
|
||||||
|
- `shared-cpu-1x` with 512MB RAM
|
||||||
|
- Auto-suspend when idle
|
||||||
|
- 2GB volume for SQLite database + git workspace
|
||||||
|
|
||||||
|
Estimated cost: ~$5-10/month (no separate database cost with SQLite!)
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
To deploy updates:
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
To rollback to previous version:
|
||||||
|
```bash
|
||||||
|
flyctl releases list
|
||||||
|
flyctl releases rollback <version-number>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
All sensitive environment variables should be set as Fly.io secrets (not in fly.toml):
|
||||||
|
|
||||||
|
Note: DATABASE_PATH and GIT_WORKSPACE_DIR are set in fly.toml as they're not sensitive.
|
||||||
|
- `GITEA_CLIENT_ID` - OAuth client ID
|
||||||
|
- `GITEA_CLIENT_SECRET` - OAuth client secret
|
||||||
|
- `GIT_TOKEN` - Gitea personal access token
|
||||||
|
- `JWT_SECRET` - JWT signing secret
|
||||||
|
- `SESSION_SECRET` - Session cookie secret
|
||||||
|
- `GIT_REPO_URL` - Full git repository URL
|
||||||
|
- `GITEA_REDIRECT_URI` - OAuth callback URL
|
||||||
|
- `FRONTEND_URL` - Frontend application URL
|
||||||
|
- `CORS_ORIGIN` - Allowed CORS origin
|
||||||
|
- `GITEA_ALLOWED_USERS` - Comma-separated list of allowed usernames
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] All secrets set and not exposed in logs
|
||||||
|
- [ ] HTTPS enforced (fly.toml: force_https = true)
|
||||||
|
- [ ] CORS configured correctly
|
||||||
|
- [ ] GITEA_ALLOWED_USERS whitelist configured
|
||||||
|
- [ ] Database backups enabled
|
||||||
|
- [ ] Health checks configured
|
||||||
|
- [ ] Monitoring and alerts set up
|
||||||
59
backend/Dockerfile
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Multi-stage build for Gallus CMS Backend
|
||||||
|
|
||||||
|
# Stage 1: Builder
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for native modules (better-sqlite3)
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
||||||
|
RUN npm ci || npm install
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
||||||
|
RUN apk add --no-cache git sqlite
|
||||||
|
|
||||||
|
# Copy production dependencies from builder (already compiled native modules)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
RUN mkdir -p /app/workspace /app/data
|
||||||
|
|
||||||
|
# Ensure proper permissions
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV DATABASE_PATH=/app/data/gallus_cms.db
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
|
# Run DB migrations if present, then start application
|
||||||
|
CMD ["/bin/sh", "-lc", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
||||||
55
backend/README.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# Gallus Pub CMS Backend
|
||||||
|
|
||||||
|
Headless CMS backend for managing Gallus Pub website content with Gitea OAuth authentication.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `.env` file from `.env.example`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update environment variables in `.env`:
|
||||||
|
- Set Gitea OAuth credentials
|
||||||
|
- Set Git repository URL and token
|
||||||
|
- JWT secrets are already generated
|
||||||
|
|
||||||
|
4. Create data directory and run migrations:
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Generate and run migrations:
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Server will run at http://localhost:3000
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
- `npm run dev` - Start development server with watch mode
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm run start` - Start production server
|
||||||
|
- `npm run db:generate` - Generate database migrations
|
||||||
|
- `npm run db:migrate` - Run database migrations
|
||||||
|
- `npm run db:studio` - Open Drizzle Studio
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
See parent directory for complete documentation:
|
||||||
|
- `CMS_CONCEPT.md` - System architecture
|
||||||
|
- `CMS_GITEA_AUTH.md` - Authentication details
|
||||||
|
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
|
||||||
|
- `CMS_SETUP_GUIDE.md` - Deployment guide
|
||||||
216
backend/SETUP_QUICK_START.md
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# Quick Start Guide - SQLite Version
|
||||||
|
|
||||||
|
## ✅ Migration Complete: PostgreSQL → SQLite
|
||||||
|
|
||||||
|
The backend now uses **SQLite** instead of PostgreSQL for simplified deployment and lower costs.
|
||||||
|
|
||||||
|
## 🚀 Quick Start (3 Steps)
|
||||||
|
|
||||||
|
### 1. Configure Environment
|
||||||
|
|
||||||
|
Edit `.env` file (already created):
|
||||||
|
```bash
|
||||||
|
# Required: Update these values
|
||||||
|
GITEA_CLIENT_ID=<your-gitea-oauth-client-id>
|
||||||
|
GITEA_CLIENT_SECRET=<your-gitea-oauth-client-secret>
|
||||||
|
GIT_REPO_URL=https://git.bookageek.ch/<yourusername>/Gallus_Pub.git
|
||||||
|
GIT_TOKEN=<your-gitea-personal-access-token>
|
||||||
|
GITEA_ALLOWED_USERS=sabrina,raphael
|
||||||
|
|
||||||
|
# Already set (JWT secrets generated)
|
||||||
|
JWT_SECRET=dOrvUqifjBLvk68kkDOvWPQper/gjsNMlAbWlVBQIrc=
|
||||||
|
SESSION_SECRET=SD0ZrvLkv9GrtI8+3GDkxZXA1UnCN4CE3c4+2vA/fIM=
|
||||||
|
|
||||||
|
# Database (SQLite - no changes needed)
|
||||||
|
DATABASE_PATH=./data/gallus_cms.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Initialize Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration files from schema
|
||||||
|
pnpm run db:generate
|
||||||
|
|
||||||
|
# Run migrations to create tables
|
||||||
|
pnpm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Server will start at **http://localhost:3000**
|
||||||
|
|
||||||
|
## 📝 What Changed?
|
||||||
|
|
||||||
|
### Before (PostgreSQL)
|
||||||
|
- Required PostgreSQL installation
|
||||||
|
- Separate database service
|
||||||
|
- Connection string configuration
|
||||||
|
- ~$15/month hosting cost on Fly.io
|
||||||
|
|
||||||
|
### After (SQLite)
|
||||||
|
- Single file database (`./data/gallus_cms.db`)
|
||||||
|
- No separate database service needed
|
||||||
|
- Works out of the box
|
||||||
|
- **$0 database cost** (included in app volume)
|
||||||
|
|
||||||
|
## 🗂️ Database Location
|
||||||
|
|
||||||
|
- **Local:** `./data/gallus_cms.db`
|
||||||
|
- **Production (Fly.io):** `/app/data/gallus_cms.db` (on persistent volume)
|
||||||
|
- **Git Workspace:** Same `data/` directory
|
||||||
|
|
||||||
|
## 🧪 Test Authentication Flow
|
||||||
|
|
||||||
|
1. Make sure you have Gitea OAuth credentials configured
|
||||||
|
2. Start dev server: `pnpm run dev`
|
||||||
|
3. Visit: http://localhost:3000/api/auth/gitea
|
||||||
|
4. Login with your Gitea credentials
|
||||||
|
5. Should redirect back with JWT token
|
||||||
|
|
||||||
|
## 📚 Available Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Flow
|
||||||
|
```
|
||||||
|
GET /api/auth/gitea - Initiate OAuth
|
||||||
|
GET /api/auth/callback - OAuth callback
|
||||||
|
GET /api/auth/me - Get current user (requires JWT)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Management (all require JWT)
|
||||||
|
```
|
||||||
|
GET/POST/PUT/DELETE /api/events
|
||||||
|
GET/POST/PUT/DELETE /api/gallery
|
||||||
|
GET/PUT /api/content/:section
|
||||||
|
GET/PUT /api/settings/:key
|
||||||
|
POST /api/publish
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Getting Gitea OAuth Credentials
|
||||||
|
|
||||||
|
1. Go to https://git.bookageek.ch/user/settings/applications
|
||||||
|
2. Click "Manage OAuth2 Applications"
|
||||||
|
3. Create new OAuth2 application:
|
||||||
|
- **Name:** Gallus Pub CMS
|
||||||
|
- **Redirect URI:** `http://localhost:3000/api/auth/callback`
|
||||||
|
- **Confidential:** Yes
|
||||||
|
4. Copy Client ID and Client Secret to `.env`
|
||||||
|
|
||||||
|
## 🎫 Getting Gitea Personal Access Token
|
||||||
|
|
||||||
|
1. Go to https://git.bookageek.ch/user/settings/applications
|
||||||
|
2. Generate New Token
|
||||||
|
3. **Name:** Gallus CMS Backend
|
||||||
|
4. **Scopes:** Select `repo` (full repository access)
|
||||||
|
5. Copy token to `.env` as `GIT_TOKEN`
|
||||||
|
|
||||||
|
## 📦 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── data/ # SQLite database & git workspace (gitignored)
|
||||||
|
│ ├── gallus_cms.db # Database file
|
||||||
|
│ └── workspace/ # Git repository clone
|
||||||
|
├── src/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── database.ts # SQLite connection (updated)
|
||||||
|
│ │ └── env.ts # DATABASE_PATH instead of URL
|
||||||
|
│ ├── db/
|
||||||
|
│ │ └── schema.ts # SQLite schema (updated)
|
||||||
|
│ ├── routes/ # API routes
|
||||||
|
│ ├── services/ # Core services
|
||||||
|
│ └── index.ts # Main server
|
||||||
|
├── .env # Your configuration
|
||||||
|
├── package.json # Updated with better-sqlite3
|
||||||
|
└── drizzle.config.ts # SQLite dialect
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # Install dependencies (done)
|
||||||
|
pnpm run dev # Start dev server with watch
|
||||||
|
pnpm run build # Build TypeScript
|
||||||
|
pnpm run start # Start production server
|
||||||
|
pnpm run db:generate # Generate migrations
|
||||||
|
pnpm run db:migrate # Run migrations
|
||||||
|
pnpm run db:studio # Open Drizzle Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deploy to Fly.io
|
||||||
|
|
||||||
|
See `DEPLOYMENT.md` for full deployment guide.
|
||||||
|
|
||||||
|
**Quick version:**
|
||||||
|
```bash
|
||||||
|
# Create volume for database & git workspace
|
||||||
|
flyctl volumes create gallus_data --size 2 --region ams
|
||||||
|
|
||||||
|
# Set secrets
|
||||||
|
flyctl secrets set GITEA_CLIENT_ID=... GITEA_CLIENT_SECRET=... # etc
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cost:** ~$5-10/month (no separate database!)
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "tsx: command not found"
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### "DATABASE_PATH not set"
|
||||||
|
Check `.env` file exists and has `DATABASE_PATH=./data/gallus_cms.db`
|
||||||
|
|
||||||
|
### "Database file not found"
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
pnpm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### "better-sqlite3" build errors
|
||||||
|
Make sure you have build tools:
|
||||||
|
- **Linux:** `apt-get install python3 make g++`
|
||||||
|
- **macOS:** Install Xcode Command Line Tools
|
||||||
|
- **Windows:** Install windows-build-tools
|
||||||
|
|
||||||
|
Then rebuild:
|
||||||
|
```bash
|
||||||
|
pnpm rebuild better-sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✨ Benefits of SQLite
|
||||||
|
|
||||||
|
1. **Simpler** - No database server to manage
|
||||||
|
2. **Faster** - No network overhead
|
||||||
|
3. **Portable** - Single file, easy backups
|
||||||
|
4. **Cost-effective** - No hosting fees
|
||||||
|
5. **Perfect fit** - Low concurrency, simple queries
|
||||||
|
|
||||||
|
## 📖 Documentation
|
||||||
|
|
||||||
|
- `SQLITE_MIGRATION.md` - Detailed migration notes
|
||||||
|
- `DEPLOYMENT.md` - Fly.io deployment guide
|
||||||
|
- `README.md` - General setup instructions
|
||||||
|
- `CMS_GITEA_AUTH.md` - OAuth authentication details (parent dir)
|
||||||
|
- `CMS_CONCEPT.md` - Full system architecture (parent dir)
|
||||||
|
|
||||||
|
## ✅ Ready to Go!
|
||||||
|
|
||||||
|
Your backend is now configured for SQLite. Just:
|
||||||
|
1. Add your Gitea credentials to `.env`
|
||||||
|
2. Run `pnpm run db:generate && pnpm run db:migrate`
|
||||||
|
3. Start with `pnpm run dev`
|
||||||
|
|
||||||
|
Happy coding! 🎉
|
||||||
217
backend/SQLITE_MIGRATION.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# SQLite Migration Summary
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
The backend has been migrated from PostgreSQL to SQLite for both local development and production (Fly.io).
|
||||||
|
|
||||||
|
### Benefits of SQLite
|
||||||
|
|
||||||
|
1. **Simplified Deployment** - No separate database service needed
|
||||||
|
2. **Lower Cost** - Save ~$15/month (no Postgres hosting)
|
||||||
|
3. **Easier Development** - No need to install/run PostgreSQL locally
|
||||||
|
4. **Single File Database** - Easy backups and migrations
|
||||||
|
5. **Perfect for this use case** - Low concurrent writes, simple queries
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- **package.json**
|
||||||
|
- Removed: `pg`, `@types/pg`
|
||||||
|
- Added: `better-sqlite3`, `@types/better-sqlite3`
|
||||||
|
|
||||||
|
### Database Configuration
|
||||||
|
- **src/config/database.ts**
|
||||||
|
- Changed from `drizzle-orm/node-postgres` to `drizzle-orm/better-sqlite3`
|
||||||
|
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
|
||||||
|
- Enabled WAL mode for better concurrent access
|
||||||
|
|
||||||
|
- **src/config/env.ts**
|
||||||
|
- Changed `DATABASE_URL` to `DATABASE_PATH`
|
||||||
|
- Default: `./data/gallus_cms.db`
|
||||||
|
|
||||||
|
- **src/db/schema.ts**
|
||||||
|
- Changed from `pgTable` to `sqliteTable`
|
||||||
|
- Changed `uuid()` to `text()` with `crypto.randomUUID()`
|
||||||
|
- Changed `jsonb()` to `text(..., { mode: 'json' })`
|
||||||
|
- Changed `timestamp()` to `integer(..., { mode: 'timestamp' })`
|
||||||
|
- Changed `boolean()` to `integer(..., { mode: 'boolean' })`
|
||||||
|
- Uses `sql\`(unixepoch())\`` for default timestamps
|
||||||
|
|
||||||
|
- **drizzle.config.ts**
|
||||||
|
- Changed dialect from `postgresql` to `sqlite`
|
||||||
|
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
|
||||||
|
|
||||||
|
### Environment Files
|
||||||
|
- **.env** and **.env.example**
|
||||||
|
- Changed `DATABASE_URL=postgresql://...` to `DATABASE_PATH=./data/gallus_cms.db`
|
||||||
|
- Changed `GIT_WORKSPACE_DIR=/tmp/gallus-repo` to `./data/workspace`
|
||||||
|
|
||||||
|
### Docker Configuration
|
||||||
|
- **Dockerfile**
|
||||||
|
- Added build tools for `better-sqlite3` native module (python3, make, g++)
|
||||||
|
- Added `sqlite` CLI tool
|
||||||
|
- Creates `/app/data` directory for database
|
||||||
|
- Sets `DATABASE_PATH=/app/data/gallus_cms.db`
|
||||||
|
- Proper permissions for non-root user
|
||||||
|
|
||||||
|
- **fly.toml**
|
||||||
|
- Added `DATABASE_PATH` and `GIT_WORKSPACE_DIR` to [env]
|
||||||
|
- Changed volume mount from `gallus_repo_workspace` to `gallus_data`
|
||||||
|
- Mount destination: `/app/data` (contains both DB and git workspace)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **README.md** - Updated setup instructions
|
||||||
|
- **DEPLOYMENT.md** - Removed Postgres setup, updated volume creation
|
||||||
|
- **SQLITE_MIGRATION.md** - This file!
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```bash
|
||||||
|
# Dependencies already installed
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Create data directory (done)
|
||||||
|
mkdir -p data
|
||||||
|
|
||||||
|
# Database will be created automatically at ./data/gallus_cms.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate and Run Migrations
|
||||||
|
```bash
|
||||||
|
# Generate migration files from schema
|
||||||
|
pnpm run db:generate
|
||||||
|
|
||||||
|
# Run migrations to create tables
|
||||||
|
pnpm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start Development Server
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The database file will be created at `./data/gallus_cms.db` on first run.
|
||||||
|
|
||||||
|
## Production (Fly.io)
|
||||||
|
|
||||||
|
### Volume Setup
|
||||||
|
```bash
|
||||||
|
# Create single volume for both database and git workspace
|
||||||
|
flyctl volumes create gallus_data --size 2 --region ams
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Set in fly.toml (non-sensitive):
|
||||||
|
- `DATABASE_PATH=/app/data/gallus_cms.db`
|
||||||
|
- `GIT_WORKSPACE_DIR=/app/data/workspace`
|
||||||
|
|
||||||
|
Set as secrets (sensitive):
|
||||||
|
- All other env vars (OAuth credentials, tokens, etc.)
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
```bash
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Database will be created automatically on first start. No need for separate database service!
|
||||||
|
|
||||||
|
## Database Location
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
- **Database:** `./data/gallus_cms.db`
|
||||||
|
- **WAL files:** `./data/gallus_cms.db-wal`, `./data/gallus_cms.db-shm`
|
||||||
|
- **Git workspace:** `./data/workspace/`
|
||||||
|
|
||||||
|
### Production (Fly.io)
|
||||||
|
- **Database:** `/app/data/gallus_cms.db` (on volume)
|
||||||
|
- **Git workspace:** `/app/data/workspace/` (on volume)
|
||||||
|
- **Volume name:** `gallus_data` (2GB)
|
||||||
|
|
||||||
|
## Backup Strategy
|
||||||
|
|
||||||
|
### Manual Backup
|
||||||
|
```bash
|
||||||
|
# Local
|
||||||
|
cp data/gallus_cms.db data/gallus_cms.backup.db
|
||||||
|
|
||||||
|
# Production (Fly.io)
|
||||||
|
flyctl ssh console
|
||||||
|
sqlite3 /app/data/gallus_cms.db ".backup /app/data/backup.db"
|
||||||
|
# Then copy back: flyctl ssh sftp get /app/data/backup.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated Backup (Optional)
|
||||||
|
Consider setting up a cron job or Fly.io machine to periodically:
|
||||||
|
1. Create SQLite backup
|
||||||
|
2. Upload to S3/Backblaze/etc.
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
SQLite is perfect for this use case because:
|
||||||
|
- **Low write concurrency** - Single admin user making changes
|
||||||
|
- **Read-heavy** - Mostly reading content for publish operations
|
||||||
|
- **Small dataset** - Events, gallery images, content sections
|
||||||
|
- **Simple queries** - No complex joins or aggregations
|
||||||
|
|
||||||
|
WAL mode is enabled for:
|
||||||
|
- Better concurrent read access
|
||||||
|
- Safer writes (crash recovery)
|
||||||
|
- Improved performance
|
||||||
|
|
||||||
|
## Migration from Existing Data
|
||||||
|
|
||||||
|
If you had PostgreSQL data to migrate:
|
||||||
|
|
||||||
|
1. Export from Postgres:
|
||||||
|
```sql
|
||||||
|
\copy events TO 'events.csv' CSV HEADER;
|
||||||
|
\copy gallery_images TO 'gallery.csv' CSV HEADER;
|
||||||
|
-- etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Import to SQLite:
|
||||||
|
```sql
|
||||||
|
.mode csv
|
||||||
|
.import events.csv events
|
||||||
|
.import gallery.csv gallery_images
|
||||||
|
-- etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **No native UUID type** - Using TEXT with UUID format
|
||||||
|
2. **No native JSON type** - Using TEXT with JSON serialization (Drizzle handles this)
|
||||||
|
3. **No native TIMESTAMP** - Using INTEGER with Unix epoch (Drizzle handles this)
|
||||||
|
4. **Single writer** - Only one write transaction at a time (not an issue for this use case)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Database is locked" error
|
||||||
|
- WAL mode should prevent this
|
||||||
|
- Check if multiple processes are accessing the database
|
||||||
|
- Ensure proper file permissions
|
||||||
|
|
||||||
|
### Native module build errors
|
||||||
|
- Make sure build tools are installed: `apt-get install python3 make g++` (Linux)
|
||||||
|
- On Alpine: `apk add python3 make g++`
|
||||||
|
- Try rebuilding: `pnpm rebuild better-sqlite3`
|
||||||
|
|
||||||
|
### Database file not found
|
||||||
|
- Check `DATABASE_PATH` is set correctly
|
||||||
|
- Ensure `data/` directory exists
|
||||||
|
- Check file permissions
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Update dependencies
|
||||||
|
2. ✅ Update database configuration
|
||||||
|
3. ✅ Update schema
|
||||||
|
4. ✅ Update Docker configuration
|
||||||
|
5. ⏳ Generate migrations: `pnpm run db:generate`
|
||||||
|
6. ⏳ Run migrations: `pnpm run db:migrate`
|
||||||
|
7. ⏳ Test development server: `pnpm run dev`
|
||||||
|
8. ⏳ Test publish flow
|
||||||
|
9. ⏳ Deploy to Fly.io
|
||||||
|
|
||||||
|
The migration is complete! Just need to generate/run migrations and test.
|
||||||
BIN
backend/data/images/events/event_advents-kalender.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/data/images/events/event_ferien.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
backend/data/images/events/event_karaoke.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
backend/data/images/events/event_neujahrs-apero.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
backend/data/images/events/event_pub-quiz.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
backend/data/images/events/event_santa_karaoke.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
backend/data/images/events/event_schlager-karaoke.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
backend/data/images/gallery/Gallery1.webp
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
backend/data/images/gallery/Gallery2.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
backend/data/images/gallery/Gallery3.webp
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
backend/data/images/gallery/Gallery4.webp
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
backend/data/images/gallery/Gallery5.webp
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
backend/data/images/gallery/Gallery6.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
backend/data/images/gallery/Gallery7.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
backend/data/images/gallery/Gallery8.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
backend/data/images/gallery/Gallery9.webp
Normal file
|
After Width: | Height: | Size: 162 KiB |
10
backend/drizzle.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './src/db/migrations',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_PATH || './data/gallus_cms.db',
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
41
backend/fly.toml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Fly.io configuration for Gallus CMS Backend
|
||||||
|
app = "gallus-cms-backend"
|
||||||
|
primary_region = "ams"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Ensure Fly uses the Dockerfile in this backend directory
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
PORT = "8080"
|
||||||
|
NODE_ENV = "production"
|
||||||
|
GITEA_URL = "https://git.bookageek.ch"
|
||||||
|
DATABASE_PATH = "/app/data/gallus_cms.db"
|
||||||
|
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||||
|
# Cross-site frontend and OAuth
|
||||||
|
FRONTEND_URL = "https://gallus-pub.ch"
|
||||||
|
CORS_ORIGIN = "https://gallus-pub.ch"
|
||||||
|
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 8080
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = "suspend"
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
processes = ["app"]
|
||||||
|
|
||||||
|
[[http_service.checks]]
|
||||||
|
grace_period = "10s"
|
||||||
|
interval = "30s"
|
||||||
|
method = "GET"
|
||||||
|
timeout = "5s"
|
||||||
|
path = "/health"
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
size = "shared-cpu-1x"
|
||||||
|
memory = "512mb"
|
||||||
|
|
||||||
|
[mounts]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
37
backend/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "gallus-cms-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Headless CMS backend for Gallus Pub website",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^9.3.1",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@fastify/jwt": "^8.0.0",
|
||||||
|
"@fastify/static": "^6.12.0",
|
||||||
|
"@fastify/multipart": "^8.1.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"better-sqlite3": "^11.10.0",
|
||||||
|
"drizzle-orm": "^0.33.0",
|
||||||
|
"fastify": "^4.26.0",
|
||||||
|
"sharp": "^0.33.2",
|
||||||
|
"simple-git": "^3.22.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.9",
|
||||||
|
"@types/node": "^20.11.16",
|
||||||
|
"drizzle-kit": "^0.24.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
104
backend/src/config/database.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
if (!env.DATABASE_PATH) {
|
||||||
|
throw new Error('DATABASE_PATH environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists BEFORE opening the database file
|
||||||
|
const dbDir = path.dirname(env.DATABASE_PATH);
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(env.DATABASE_PATH);
|
||||||
|
|
||||||
|
// Enable WAL mode for better concurrent access
|
||||||
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Auto-create tables if they don't exist
|
||||||
|
export function initDatabase() {
|
||||||
|
console.log('🔧 Initializing database...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if users table exists (acts as a sentinel for initial setup)
|
||||||
|
const tableCheck = sqlite
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!tableCheck) {
|
||||||
|
console.log('📝 Creating database schema...');
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
gitea_id TEXT UNIQUE NOT NULL,
|
||||||
|
gitea_username TEXT NOT NULL,
|
||||||
|
gitea_email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT DEFAULT 'admin',
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
last_login INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
is_published INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gallery_images (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
alt_text TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
is_published INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_sections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
section_name TEXT UNIQUE NOT NULL,
|
||||||
|
content_json TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS publish_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
commit_hash TEXT,
|
||||||
|
commit_message TEXT,
|
||||||
|
published_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ Database schema created successfully!');
|
||||||
|
} else {
|
||||||
|
console.log('✅ Database already initialized.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error initializing database:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/config/env.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// Environment configuration with validation
|
||||||
|
export const env = {
|
||||||
|
// Database
|
||||||
|
DATABASE_PATH: process.env.DATABASE_PATH || './data/gallus_cms.db',
|
||||||
|
|
||||||
|
// Gitea OAuth
|
||||||
|
GITEA_URL: process.env.GITEA_URL || 'https://git.bookageek.ch',
|
||||||
|
GITEA_CLIENT_ID: process.env.GITEA_CLIENT_ID || '',
|
||||||
|
GITEA_CLIENT_SECRET: process.env.GITEA_CLIENT_SECRET || '',
|
||||||
|
GITEA_REDIRECT_URI: process.env.GITEA_REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
|
||||||
|
GITEA_ALLOWED_USERS: process.env.GITEA_ALLOWED_USERS || '',
|
||||||
|
|
||||||
|
// Git Configuration
|
||||||
|
GIT_REPO_URL: process.env.GIT_REPO_URL || '',
|
||||||
|
GIT_TOKEN: process.env.GIT_TOKEN || '',
|
||||||
|
GIT_USER_NAME: process.env.GIT_USER_NAME || 'Gallus CMS',
|
||||||
|
GIT_USER_EMAIL: process.env.GIT_USER_EMAIL || 'cms@galluspub.ch',
|
||||||
|
GIT_WORKSPACE_DIR: process.env.GIT_WORKSPACE_DIR || '/tmp/gallus-repo',
|
||||||
|
|
||||||
|
// JWT & Session
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET || '',
|
||||||
|
SESSION_SECRET: process.env.SESSION_SECRET || '',
|
||||||
|
|
||||||
|
// Server
|
||||||
|
PORT: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
|
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate required environment variables
|
||||||
|
export function validateEnv() {
|
||||||
|
const required = [
|
||||||
|
'DATABASE_PATH',
|
||||||
|
'GITEA_CLIENT_ID',
|
||||||
|
'GITEA_CLIENT_SECRET',
|
||||||
|
'GIT_REPO_URL',
|
||||||
|
'GIT_TOKEN',
|
||||||
|
'JWT_SECRET',
|
||||||
|
'SESSION_SECRET',
|
||||||
|
];
|
||||||
|
|
||||||
|
const missing = required.filter(key => !env[key as keyof typeof env]);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/db/schema.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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(),
|
||||||
|
giteaUsername: text('gitea_username').notNull(),
|
||||||
|
giteaEmail: text('gitea_email'),
|
||||||
|
displayName: text('display_name'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
role: text('role').default('admin'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
lastLogin: integer('last_login', { mode: 'timestamp' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events table
|
||||||
|
export const events = sqliteTable('events', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
date: text('date').notNull(),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
imageUrl: text('image_url').notNull(),
|
||||||
|
displayOrder: integer('display_order').notNull(),
|
||||||
|
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gallery images table
|
||||||
|
export const galleryImages = sqliteTable('gallery_images', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
imageUrl: text('image_url').notNull(),
|
||||||
|
altText: text('alt_text').notNull(),
|
||||||
|
displayOrder: integer('display_order').notNull(),
|
||||||
|
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content sections table (for text-based sections)
|
||||||
|
export const contentSections = sqliteTable('content_sections', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
sectionName: text('section_name').notNull().unique(),
|
||||||
|
contentJson: text('content_json', { mode: 'json' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Site settings table (global config)
|
||||||
|
export const siteSettings = sqliteTable('site_settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish history (audit log)
|
||||||
|
export const publishHistory = sqliteTable('publish_history', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text('user_id').references(() => users.id),
|
||||||
|
commitHash: text('commit_hash'),
|
||||||
|
commitMessage: text('commit_message'),
|
||||||
|
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
125
backend/src/index.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import jwt from '@fastify/jwt';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import { authenticate } from './middleware/auth.middleware.js';
|
||||||
|
import { env, validateEnv } from './config/env.js';
|
||||||
|
import { db, initDatabase } from './config/database.js';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
import authRoute from './routes/auth.js';
|
||||||
|
import eventsRoute from './routes/events.js';
|
||||||
|
import galleryRoute from './routes/gallery.js';
|
||||||
|
import contentRoute from './routes/content.js';
|
||||||
|
import settingsRoute from './routes/settings.js';
|
||||||
|
import publishRoute from './routes/publish.js';
|
||||||
|
|
||||||
|
// Validate environment variables
|
||||||
|
try {
|
||||||
|
validateEnv();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Environment validation failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fastify = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
transport: env.NODE_ENV === 'development' ? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
translateTime: 'HH:MM:ss Z',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register plugins
|
||||||
|
fastify.register(cors, {
|
||||||
|
origin: env.CORS_ORIGIN,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.register(cookie);
|
||||||
|
|
||||||
|
fastify.register(jwt, {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
cookie: {
|
||||||
|
cookieName: 'token',
|
||||||
|
signed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: env.MAX_FILE_SIZE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files (uploaded images, etc.) from persistent volume
|
||||||
|
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
fastify.register(fastifyStatic, {
|
||||||
|
root: dataDir,
|
||||||
|
prefix: '/static/',
|
||||||
|
decorateReply: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decorate fastify with authenticate method
|
||||||
|
fastify.decorate('authenticate', authenticate);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
fastify.register(authRoute, { prefix: '/api' });
|
||||||
|
fastify.register(eventsRoute, { prefix: '/api' });
|
||||||
|
fastify.register(galleryRoute, { prefix: '/api' });
|
||||||
|
fastify.register(contentRoute, { prefix: '/api' });
|
||||||
|
fastify.register(settingsRoute, { prefix: '/api' });
|
||||||
|
fastify.register(publishRoute, { prefix: '/api' });
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
fastify.get('/health', async () => {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: env.NODE_ENV,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Root endpoint
|
||||||
|
fastify.get('/', async () => {
|
||||||
|
return {
|
||||||
|
name: 'Gallus Pub CMS Backend',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
fastify.setErrorHandler((error, request, reply) => {
|
||||||
|
fastify.log.error(error);
|
||||||
|
|
||||||
|
reply.status(error.statusCode || 500).send({
|
||||||
|
error: error.message || 'Internal Server Error',
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const start = async () => {
|
||||||
|
try {
|
||||||
|
// Initialize database before starting server
|
||||||
|
initDatabase();
|
||||||
|
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||||
|
console.log(`🚀 Server listening on port ${env.PORT}`);
|
||||||
|
console.log(`📝 Environment: ${env.NODE_ENV}`);
|
||||||
|
console.log(`🔐 CORS Origin: ${env.CORS_ORIGIN}`);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
12
backend/src/middleware/auth.middleware.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
export async function authenticate(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(401).send({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
}
|
||||||
188
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { GiteaService } from '../services/gitea.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
// Use explicit JSON schema for Fastify route validation to avoid provider issues
|
||||||
|
const callbackQueryJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['code', 'state'],
|
||||||
|
properties: {
|
||||||
|
code: { type: 'string' },
|
||||||
|
state: { type: 'string' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
const giteaService = new GiteaService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/gitea
|
||||||
|
* Initiate OAuth flow
|
||||||
|
*/
|
||||||
|
fastify.get('/auth/gitea', async (request, reply) => {
|
||||||
|
// Generate CSRF state token
|
||||||
|
const state = giteaService.generateState();
|
||||||
|
|
||||||
|
// Store state in a short-lived cookie
|
||||||
|
reply.setCookie('oauth_state', state, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'none',
|
||||||
|
secure: true,
|
||||||
|
maxAge: 10 * 60, // 10 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
const authUrl = giteaService.getAuthorizationUrl(state);
|
||||||
|
|
||||||
|
// Redirect to Gitea
|
||||||
|
return reply.redirect(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/callback
|
||||||
|
* OAuth callback endpoint
|
||||||
|
*/
|
||||||
|
fastify.get('/auth/callback', {
|
||||||
|
schema: {
|
||||||
|
querystring: callbackQueryJsonSchema,
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { code, state } = request.query as { code: string; state: string };
|
||||||
|
|
||||||
|
// Verify CSRF state from cookie
|
||||||
|
const expectedState = request.cookies?.oauth_state as string | undefined;
|
||||||
|
if (!expectedState || state !== expectedState) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid state parameter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state cookie
|
||||||
|
reply.clearCookie('oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
// Exchange code for access token
|
||||||
|
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
||||||
|
|
||||||
|
// Fetch user info from Gitea
|
||||||
|
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
|
||||||
|
|
||||||
|
// Check if user is allowed
|
||||||
|
if (!giteaService.isUserAllowed(giteaUser.login)) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Access denied. You are not authorized to access this CMS.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user in database
|
||||||
|
let [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.giteaId, giteaUser.id.toString()))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Create new user
|
||||||
|
[user] = await db.insert(users).values({
|
||||||
|
giteaId: giteaUser.id.toString(),
|
||||||
|
giteaUsername: giteaUser.login,
|
||||||
|
giteaEmail: giteaUser.email,
|
||||||
|
displayName: giteaUser.full_name,
|
||||||
|
avatarUrl: giteaUser.avatar_url,
|
||||||
|
lastLogin: new Date(),
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
// Update existing user
|
||||||
|
[user] = await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
giteaUsername: giteaUser.login,
|
||||||
|
giteaEmail: giteaUser.email,
|
||||||
|
displayName: giteaUser.full_name,
|
||||||
|
avatarUrl: giteaUser.avatar_url,
|
||||||
|
lastLogin: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT for session management
|
||||||
|
const token = fastify.jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
giteaId: user.giteaId,
|
||||||
|
username: user.giteaUsername || '',
|
||||||
|
role: user.role ?? 'admin',
|
||||||
|
},
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
|
||||||
|
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
|
||||||
|
reply.setCookie('token', token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
|
||||||
|
secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
|
||||||
|
maxAge: 60 * 60 * 24, // 24h
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to admin dashboard
|
||||||
|
const frontendUrl = env.FRONTEND_URL;
|
||||||
|
return reply.redirect(`${frontendUrl}/admin`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error({ err: error }, 'OAuth callback error');
|
||||||
|
return reply.code(500).send({ error: 'Authentication failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/me
|
||||||
|
* Get current user info
|
||||||
|
*/
|
||||||
|
fastify.get('/auth/me', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.user.id;
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
giteaUsername: user.giteaUsername,
|
||||||
|
giteaEmail: user.giteaEmail,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/logout
|
||||||
|
* Logout (client-side token deletion)
|
||||||
|
*/
|
||||||
|
fastify.post('/auth/logout', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
// For JWT, logout is primarily client-side (delete token)
|
||||||
|
// You could maintain a token blacklist in Redis for production
|
||||||
|
reply.clearCookie('token', { path: '/' });
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authRoute;
|
||||||
104
backend/src/routes/content.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { contentSections } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for content section body
|
||||||
|
const contentBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['contentJson'],
|
||||||
|
properties: {
|
||||||
|
contentJson: {}, // allow any JSON
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
|
// Get content section
|
||||||
|
fastify.get('/content/:section', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { section } = request.params as { section: string };
|
||||||
|
|
||||||
|
const [content] = await db
|
||||||
|
.select()
|
||||||
|
.from(contentSections)
|
||||||
|
.where(eq(contentSections.sectionName, section))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return reply.code(404).send({ error: 'Content section not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
section: content.sectionName,
|
||||||
|
content: content.contentJson,
|
||||||
|
updatedAt: content.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update content section
|
||||||
|
fastify.put('/content/:section', {
|
||||||
|
schema: {
|
||||||
|
body: contentBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { section } = request.params as { section: string };
|
||||||
|
const { contentJson } = request.body as any;
|
||||||
|
|
||||||
|
// Check if section exists
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(contentSections)
|
||||||
|
.where(eq(contentSections.sectionName, section))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
[result] = await db
|
||||||
|
.update(contentSections)
|
||||||
|
.set({
|
||||||
|
contentJson,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(contentSections.sectionName, section))
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
[result] = await db
|
||||||
|
.insert(contentSections)
|
||||||
|
.values({
|
||||||
|
sectionName: section,
|
||||||
|
contentJson,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
section: result.sectionName,
|
||||||
|
content: result.contentJson,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all content sections
|
||||||
|
fastify.get('/content', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const sections = await db.select().from(contentSections);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections: (sections as any[]).map((s: any) => ({
|
||||||
|
section: s.sectionName,
|
||||||
|
content: s.contentJson,
|
||||||
|
updatedAt: s.updatedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default contentRoute;
|
||||||
97
backend/src/routes/events.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { events } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for event body
|
||||||
|
const eventBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
date: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
|
description: { type: 'string', minLength: 1 },
|
||||||
|
imageUrl: { type: 'string', minLength: 1 },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
isPublished: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const reorderBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['orders'],
|
||||||
|
properties: {
|
||||||
|
orders: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
// PUBLIC: List published events (no auth required)
|
||||||
|
fastify.get('/events/public', async () => {
|
||||||
|
const all = await db.select().from(events)
|
||||||
|
.where(eq(events.isPublished, true))
|
||||||
|
.orderBy(events.displayOrder);
|
||||||
|
return { events: all };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all events (by displayOrder) - admin only
|
||||||
|
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
||||||
|
const all = await db.select().from(events).orderBy(events.displayOrder);
|
||||||
|
return { events: all };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single event
|
||||||
|
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||||
|
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
return { event: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create event
|
||||||
|
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const data = request.body as any;
|
||||||
|
const [row] = await db.insert(events).values(data).returning();
|
||||||
|
return reply.code(201).send({ event: row });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update event
|
||||||
|
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const data = request.body as any;
|
||||||
|
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
||||||
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
return { event: row };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete event
|
||||||
|
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
||||||
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
return { message: 'Event deleted successfully' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder events (synchronous transaction for better-sqlite3)
|
||||||
|
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
|
db.transaction((tx: any) => {
|
||||||
|
for (const { id, displayOrder } of orders) {
|
||||||
|
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { message: 'Events reordered successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default eventsRoute;
|
||||||
221
backend/src/routes/gallery.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { galleryImages } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
// Fastify JSON schema for gallery image body
|
||||||
|
const galleryBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['imageUrl', 'altText', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
imageUrl: { type: 'string', minLength: 1 },
|
||||||
|
altText: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
isPublished: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
|
// PUBLIC: List published gallery images (no auth required)
|
||||||
|
fastify.get('/gallery/public', async () => {
|
||||||
|
const images = await db.select().from(galleryImages)
|
||||||
|
.where(eq(galleryImages.isPublished, true))
|
||||||
|
.orderBy(galleryImages.displayOrder);
|
||||||
|
return { images };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all gallery images - admin only
|
||||||
|
fastify.get('/gallery', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
|
||||||
|
return { images };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single gallery image
|
||||||
|
fastify.get('/gallery/:id', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||||
|
|
||||||
|
if (image.length === 0) {
|
||||||
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { image: image[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create gallery image
|
||||||
|
fastify.post('/gallery', {
|
||||||
|
schema: {
|
||||||
|
body: galleryBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const data = request.body as any;
|
||||||
|
|
||||||
|
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({ image: newImage });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload image file (multipart)
|
||||||
|
fastify.post('/gallery/upload', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
// Expect a single file field named "file"
|
||||||
|
const file = await (request as any).file();
|
||||||
|
if (!file) {
|
||||||
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const altText = (file.fields?.altText?.value as string | undefined) || '';
|
||||||
|
const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0';
|
||||||
|
const displayOrder = Number.parseInt(displayOrderRaw) || 0;
|
||||||
|
|
||||||
|
const mime = file.mimetype as string | undefined;
|
||||||
|
if (!mime || !mime.startsWith('image/')) {
|
||||||
|
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare directories - use persistent volume for Fly.io
|
||||||
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const uploadDir = path.join(dataDir, 'images', 'gallery');
|
||||||
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
// Read uploaded stream into buffer
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of file.file) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const inputBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const rand = Math.random().toString(36).slice(2, 8);
|
||||||
|
const baseName = `${stamp}-${rand}`;
|
||||||
|
|
||||||
|
// Try to convert to webp and limit size; fallback to original
|
||||||
|
let outBuffer: Buffer | null = null;
|
||||||
|
let outExt = '.webp';
|
||||||
|
try {
|
||||||
|
outBuffer = await sharp(inputBuffer)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch {
|
||||||
|
outBuffer = inputBuffer;
|
||||||
|
// naive extension from mimetype
|
||||||
|
const extFromMime = mime.split('/')[1] || 'bin';
|
||||||
|
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = baseName + outExt;
|
||||||
|
const destPath = path.join(uploadDir, filename);
|
||||||
|
fs.writeFileSync(destPath, outBuffer);
|
||||||
|
|
||||||
|
// Public URL (served via /static)
|
||||||
|
const publicUrl = `/static/images/gallery/${filename}`;
|
||||||
|
|
||||||
|
// Store in DB (optional but useful)
|
||||||
|
const [row] = await db.insert(galleryImages).values({
|
||||||
|
imageUrl: publicUrl,
|
||||||
|
altText: altText || filename,
|
||||||
|
displayOrder,
|
||||||
|
isPublished: true,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({ image: row });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
|
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update gallery image
|
||||||
|
fastify.put('/gallery/:id', {
|
||||||
|
schema: {
|
||||||
|
body: galleryBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const data = request.body as any;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(galleryImages)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(galleryImages.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { image: updated };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete gallery image
|
||||||
|
fastify.delete('/gallery/:id', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(galleryImages)
|
||||||
|
.where(eq(galleryImages.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Image deleted successfully' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder gallery images
|
||||||
|
fastify.put('/gallery/reorder', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['orders'],
|
||||||
|
properties: {
|
||||||
|
orders: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
|
|
||||||
|
// Update all in synchronous transaction (better-sqlite3 requirement)
|
||||||
|
db.transaction((tx: any) => {
|
||||||
|
for (const { id, displayOrder } of orders) {
|
||||||
|
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Gallery images reordered successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default galleryRoute;
|
||||||
127
backend/src/routes/publish.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { GitService } from '../services/git.service.js';
|
||||||
|
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for publish body
|
||||||
|
const publishBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['commitMessage'],
|
||||||
|
properties: {
|
||||||
|
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
fastify.post('/publish', {
|
||||||
|
schema: {
|
||||||
|
body: publishBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { commitMessage } = request.body as any;
|
||||||
|
const userId = request.user.id;
|
||||||
|
|
||||||
|
fastify.log.info('Starting publish process...');
|
||||||
|
|
||||||
|
// Initialize git service
|
||||||
|
const gitService = new GitService();
|
||||||
|
await gitService.initialize();
|
||||||
|
|
||||||
|
fastify.log.info('Git repository initialized');
|
||||||
|
|
||||||
|
// Fetch all content from database
|
||||||
|
const eventsData = await db
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq(events.isPublished, true))
|
||||||
|
.orderBy(events.displayOrder);
|
||||||
|
|
||||||
|
const galleryData = await db
|
||||||
|
.select()
|
||||||
|
.from(galleryImages)
|
||||||
|
.where(eq(galleryImages.isPublished, true))
|
||||||
|
.orderBy(galleryImages.displayOrder);
|
||||||
|
|
||||||
|
const sectionsData = await db.select().from(contentSections);
|
||||||
|
const sectionsMap = new Map<string, any>(
|
||||||
|
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
|
||||||
|
|
||||||
|
// Generate and write files
|
||||||
|
const fileGenerator = new FileGeneratorService();
|
||||||
|
await fileGenerator.writeFiles(
|
||||||
|
gitService.getWorkspacePath(''),
|
||||||
|
(eventsData as any[]).map((e: any) => ({
|
||||||
|
title: e.title,
|
||||||
|
date: e.date,
|
||||||
|
description: e.description,
|
||||||
|
imageUrl: e.imageUrl,
|
||||||
|
})),
|
||||||
|
(galleryData as any[]).map((g: any) => ({
|
||||||
|
imageUrl: g.imageUrl,
|
||||||
|
altText: g.altText,
|
||||||
|
})),
|
||||||
|
sectionsMap
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.log.info('Files generated successfully');
|
||||||
|
|
||||||
|
// Commit and push
|
||||||
|
const commitHash = await gitService.commitAndPush(commitMessage);
|
||||||
|
|
||||||
|
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||||
|
|
||||||
|
// Record in history
|
||||||
|
await db.insert(publishHistory).values({
|
||||||
|
userId,
|
||||||
|
commitHash,
|
||||||
|
commitMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commitHash,
|
||||||
|
message: 'Changes published successfully',
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error({ err: error }, 'Publish error');
|
||||||
|
|
||||||
|
// Attempt to reset git state on error
|
||||||
|
try {
|
||||||
|
const gitService = new GitService();
|
||||||
|
await gitService.reset();
|
||||||
|
} catch (resetError) {
|
||||||
|
fastify.log.error({ err: resetError }, 'Failed to reset git state');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to publish changes',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get publish history
|
||||||
|
fastify.get('/publish/history', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const history = await db
|
||||||
|
.select()
|
||||||
|
.from(publishHistory)
|
||||||
|
.orderBy(publishHistory.publishedAt)
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
return { history };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default publishRoute;
|
||||||
121
backend/src/routes/settings.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { siteSettings } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for settings body
|
||||||
|
const settingBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['value'],
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
|
// Get all settings
|
||||||
|
fastify.get('/settings', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const settings = await db.select().from(siteSettings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: settings.reduce((acc, setting) => {
|
||||||
|
acc[setting.key] = setting.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single setting
|
||||||
|
fastify.get('/settings/:key', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { key } = request.params as { key: string };
|
||||||
|
|
||||||
|
const [setting] = await db
|
||||||
|
.select()
|
||||||
|
.from(siteSettings)
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
return reply.code(404).send({ error: 'Setting not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: setting.value,
|
||||||
|
updatedAt: setting.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update setting
|
||||||
|
fastify.put('/settings/:key', {
|
||||||
|
schema: {
|
||||||
|
body: settingBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { key } = request.params as { key: string };
|
||||||
|
const { value } = request.body as any;
|
||||||
|
|
||||||
|
// Check if setting exists
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(siteSettings)
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
[result] = await db
|
||||||
|
.update(siteSettings)
|
||||||
|
.set({
|
||||||
|
value,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
[result] = await db
|
||||||
|
.insert(siteSettings)
|
||||||
|
.values({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: result.key,
|
||||||
|
value: result.value,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete setting
|
||||||
|
fastify.delete('/settings/:key', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { key } = request.params as { key: string };
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(siteSettings)
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: 'Setting not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Setting deleted successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default settingsRoute;
|
||||||
190
backend/src/scripts/migrate-old-data.ts
Normal 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();
|
||||||
239
backend/src/services/file-generator.service.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GalleryImage {
|
||||||
|
imageUrl: string;
|
||||||
|
altText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentSection {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileGeneratorService {
|
||||||
|
|
||||||
|
escapeQuotes(str: string): string {
|
||||||
|
return str.replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeBackticks(str: string): string {
|
||||||
|
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||||
|
const eventsCode = events.map(e => `\t{
|
||||||
|
\t\timage: "${e.imageUrl}",
|
||||||
|
\t\ttitle: "${this.escapeQuotes(e.title)}",
|
||||||
|
\t\tdate: "${e.date}",
|
||||||
|
\t\tdescription: \`
|
||||||
|
\t\t\t${this.escapeBackticks(e.description)}
|
||||||
|
\t\t\`,
|
||||||
|
\t}`).join(',\n');
|
||||||
|
|
||||||
|
const imagesCode = images.map(g =>
|
||||||
|
`\t{ src: "${g.imageUrl}", alt: "${this.escapeQuotes(g.altText)}" }`
|
||||||
|
).join(',\n');
|
||||||
|
|
||||||
|
return `---
|
||||||
|
import Layout from "../components/Layout.astro";
|
||||||
|
import Hero from "../components/Hero.astro";
|
||||||
|
import Welcome from "../components/Welcome.astro";
|
||||||
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
|
import Drinks from "../components/Drinks.astro";
|
||||||
|
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||||
|
import Contact from "../components/Contact.astro";
|
||||||
|
import About from "../components/About.astro";
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
${eventsCode}
|
||||||
|
];
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
${imagesCode}
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
\t<Hero id="hero" />
|
||||||
|
\t<Welcome id="welcome" />
|
||||||
|
\t<EventsGrid id="events" events={events} />
|
||||||
|
\t<ImageCarousel id="gallery" images={images} />
|
||||||
|
\t<Drinks id="drinks" />
|
||||||
|
</Layout>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateHeroComponent(content: ContentSection): string {
|
||||||
|
return `---
|
||||||
|
// src/components/Hero.astro
|
||||||
|
import "../styles/components/Hero.css"
|
||||||
|
|
||||||
|
const { id } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id={id} class="hero container">
|
||||||
|
|
||||||
|
\t<div class="hero-overlay">
|
||||||
|
|
||||||
|
\t\t<div class="hero-content">
|
||||||
|
|
||||||
|
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
||||||
|
|
||||||
|
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
||||||
|
|
||||||
|
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||||
|
\t\t</div>
|
||||||
|
|
||||||
|
\t</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateWelcomeComponent(content: ContentSection): string {
|
||||||
|
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||||
|
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
return `---
|
||||||
|
// src/components/Welcome.astro
|
||||||
|
import "../styles/components/Welcome.css"
|
||||||
|
|
||||||
|
const { id } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id={id} class="welcome container">
|
||||||
|
|
||||||
|
\t<div class="welcome-text">
|
||||||
|
|
||||||
|
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
||||||
|
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
||||||
|
|
||||||
|
\t\t<p>
|
||||||
|
\t\t\t${content.introText || ''}
|
||||||
|
\t\t</p>
|
||||||
|
|
||||||
|
\t\t<p><b>Unsere Highlights:</b></p>
|
||||||
|
|
||||||
|
\t\t<ul>
|
||||||
|
${highlightsList}
|
||||||
|
\t\t</ul>
|
||||||
|
|
||||||
|
\t\t<p>
|
||||||
|
\t\t\t${content.closingText || ''}
|
||||||
|
\t\t</p>
|
||||||
|
|
||||||
|
\t</div>
|
||||||
|
|
||||||
|
|
||||||
|
\t<div class="welcome-image">
|
||||||
|
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
||||||
|
\t</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDrinksComponent(content: ContentSection): string {
|
||||||
|
return `---
|
||||||
|
import "../styles/components/Drinks.css"
|
||||||
|
|
||||||
|
const { id } = Astro.props;
|
||||||
|
---
|
||||||
|
<section id={id} class="Drinks">
|
||||||
|
<h2 class="title">Drinks</h2>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||||
|
|
||||||
|
<h3 class="monats-hit">Monats Hit</h3>
|
||||||
|
|
||||||
|
<div class="mate-vodka">
|
||||||
|
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
||||||
|
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="circle-row">
|
||||||
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
|
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
|
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
|
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFiles(
|
||||||
|
workspaceDir: string,
|
||||||
|
events: Event[],
|
||||||
|
images: GalleryImage[],
|
||||||
|
sections: Map<string, ContentSection>
|
||||||
|
) {
|
||||||
|
// Write index.astro
|
||||||
|
const indexContent = this.generateIndexAstro(events, images);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/pages/index.astro'),
|
||||||
|
indexContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write Hero component
|
||||||
|
if (sections.has('hero')) {
|
||||||
|
const heroContent = this.generateHeroComponent(sections.get('hero')!);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/components/Hero.astro'),
|
||||||
|
heroContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write Welcome component
|
||||||
|
if (sections.has('welcome')) {
|
||||||
|
const welcomeContent = this.generateWelcomeComponent(sections.get('welcome')!);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/components/Welcome.astro'),
|
||||||
|
welcomeContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write Drinks component
|
||||||
|
if (sections.has('drinks')) {
|
||||||
|
const drinksContent = this.generateDrinksComponent(sections.get('drinks')!);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/components/Drinks.astro'),
|
||||||
|
drinksContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/services/git.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import simpleGit, { SimpleGit } from 'simple-git';
|
||||||
|
import { mkdir, rm } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export class GitService {
|
||||||
|
private git: SimpleGit;
|
||||||
|
private workspaceDir: string;
|
||||||
|
private repoUrl: string;
|
||||||
|
private token: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.workspaceDir = env.GIT_WORKSPACE_DIR;
|
||||||
|
this.repoUrl = env.GIT_REPO_URL;
|
||||||
|
this.token = env.GIT_TOKEN;
|
||||||
|
this.git = simpleGit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
// Ensure workspace directory exists
|
||||||
|
await mkdir(this.workspaceDir, { recursive: true });
|
||||||
|
|
||||||
|
// Add token to repo URL for authentication
|
||||||
|
const authenticatedUrl = this.repoUrl.replace(
|
||||||
|
'https://',
|
||||||
|
`https://oauth2:${this.token}@`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if repo already exists
|
||||||
|
await this.git.cwd(this.workspaceDir);
|
||||||
|
await this.git.status();
|
||||||
|
console.log('Repository already exists, pulling latest...');
|
||||||
|
await this.git.pull();
|
||||||
|
} catch {
|
||||||
|
// Clone if doesn't exist
|
||||||
|
console.log('Cloning repository...');
|
||||||
|
await rm(this.workspaceDir, { recursive: true, force: true });
|
||||||
|
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
||||||
|
await this.git.cwd(this.workspaceDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user
|
||||||
|
await this.git.addConfig('user.name', env.GIT_USER_NAME);
|
||||||
|
await this.git.addConfig('user.email', env.GIT_USER_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitAndPush(message: string): Promise<string> {
|
||||||
|
await this.git.add('.');
|
||||||
|
await this.git.commit(message);
|
||||||
|
await this.git.push('origin', 'main');
|
||||||
|
|
||||||
|
const log = await this.git.log({ maxCount: 1 });
|
||||||
|
return log.latest?.hash || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspacePath(relativePath: string): string {
|
||||||
|
return path.join(this.workspaceDir, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
await this.git.reset(['--hard', 'HEAD']);
|
||||||
|
await this.git.clean('f', ['-d']);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
backend/src/services/gitea.service.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
interface GiteaUser {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GiteaService {
|
||||||
|
private giteaUrl: string;
|
||||||
|
private clientId: string;
|
||||||
|
private clientSecret: string;
|
||||||
|
private redirectUri: string;
|
||||||
|
private allowedUsers: Set<string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.giteaUrl = env.GITEA_URL;
|
||||||
|
this.clientId = env.GITEA_CLIENT_ID;
|
||||||
|
this.clientSecret = env.GITEA_CLIENT_SECRET;
|
||||||
|
this.redirectUri = env.GITEA_REDIRECT_URI;
|
||||||
|
|
||||||
|
const allowed = env.GITEA_ALLOWED_USERS;
|
||||||
|
this.allowedUsers = new Set(allowed.split(',').map(u => u.trim()).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate OAuth authorization URL
|
||||||
|
*/
|
||||||
|
getAuthorizationUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.clientId,
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
state,
|
||||||
|
scope: 'read:user',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${this.giteaUrl}/login/oauth/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token
|
||||||
|
*/
|
||||||
|
async exchangeCodeForToken(code: string): Promise<OAuthTokenResponse> {
|
||||||
|
const response = await fetch(`${this.giteaUrl}/login/oauth/access_token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: this.clientId,
|
||||||
|
client_secret: this.clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to exchange code: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user info from Gitea using access token
|
||||||
|
*/
|
||||||
|
async getUserInfo(accessToken: string): Promise<GiteaUser> {
|
||||||
|
const response = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch user info: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is allowed to access the CMS
|
||||||
|
*/
|
||||||
|
isUserAllowed(username: string): boolean {
|
||||||
|
// If no allowed users specified, allow all
|
||||||
|
if (this.allowedUsers.size === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.allowedUsers.has(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random state for CSRF protection
|
||||||
|
*/
|
||||||
|
generateState(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
87
backend/src/services/media.service.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export class MediaService {
|
||||||
|
private allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
private maxFileSize: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.maxFileSize = env.MAX_FILE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file type and size
|
||||||
|
*/
|
||||||
|
async validateFile(file: any): Promise<void> {
|
||||||
|
if (!this.allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
throw new Error(`Invalid file type. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
if (buffer.length > this.maxFileSize) {
|
||||||
|
throw new Error(`File too large. Maximum size: ${this.maxFileSize / 1024 / 1024}MB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate safe filename
|
||||||
|
*/
|
||||||
|
generateFilename(originalName: string): string {
|
||||||
|
const ext = path.extname(originalName);
|
||||||
|
const hash = crypto.randomBytes(8).toString('hex');
|
||||||
|
const timestamp = Date.now();
|
||||||
|
return `${timestamp}-${hash}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimize and save image
|
||||||
|
*/
|
||||||
|
async processAndSaveImage(
|
||||||
|
file: any,
|
||||||
|
destinationDir: string
|
||||||
|
): Promise<{ filename: string; url: string }> {
|
||||||
|
await this.validateFile(file);
|
||||||
|
|
||||||
|
// Ensure destination directory exists
|
||||||
|
await mkdir(destinationDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const filename = this.generateFilename(file.filename);
|
||||||
|
const filepath = path.join(destinationDir, filename);
|
||||||
|
|
||||||
|
// Get file buffer
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
|
||||||
|
// Process image with sharp (optimize and resize if needed)
|
||||||
|
await sharp(buffer)
|
||||||
|
.resize(2000, 2000, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.png({ quality: 85 })
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.toFile(filepath);
|
||||||
|
|
||||||
|
// Return filename and URL path
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
url: `/images/${filename}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save image to git workspace
|
||||||
|
*/
|
||||||
|
async saveToGitWorkspace(
|
||||||
|
file: any,
|
||||||
|
workspaceDir: string
|
||||||
|
): Promise<{ filename: string; url: string }> {
|
||||||
|
const imagesDir = path.join(workspaceDir, 'public', 'images');
|
||||||
|
return this.processAndSaveImage(file, imagesDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/types/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
id: string;
|
||||||
|
giteaId: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastifyRequest {
|
||||||
|
user: JWTPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/jwt' {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: JWTPayload;
|
||||||
|
user: JWTPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
38
docker-compose.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=http://proxy:4321
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env.local
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=8080
|
||||||
|
- DATABASE_PATH=/app/data/gallus_cms.db
|
||||||
|
- GIT_WORKSPACE_DIR=/app/workspace
|
||||||
|
volumes:
|
||||||
|
- backend_data:/app/data
|
||||||
|
- backend_workspace:/app/workspace
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.caddy
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "4321:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_data:
|
||||||
|
backend_workspace:
|
||||||
7
fly.toml
@ -9,6 +9,9 @@ kill_timeout = 5
|
|||||||
[env]
|
[env]
|
||||||
PORT = "3000"
|
PORT = "3000"
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
|
BACKEND_PORT = "8080"
|
||||||
|
DATABASE_PATH = "/app/data/db/gallus_cms.db"
|
||||||
|
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3000
|
internal_port = 3000
|
||||||
@ -40,3 +43,7 @@ kill_timeout = 5
|
|||||||
memory = "512MB"
|
memory = "512MB"
|
||||||
cpu_kind = "shared"
|
cpu_kind = "shared"
|
||||||
cpus = 1
|
cpus = 1
|
||||||
|
|
||||||
|
[[mounts]]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
910
package-lock.json
generated
@ -1,19 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "gallus-pub",
|
"name": "Gallus Pub Site",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:local": "dotenv -e .env.local -- astro dev",
|
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.12.8",
|
"astro": "^5.12.0"
|
||||||
"@astrojs/node": "^9.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"dotenv-cli": "^7.4.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3114
pnpm-lock.yaml
generated
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
|
Before Width: | Height: | Size: 47 KiB |
BIN
public/images/events/event_advents-kalender.jpeg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/images/events/event_ferien.jpeg
Normal file
|
After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
BIN
public/images/events/event_neujahrs-apero.jpeg
Normal file
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
BIN
public/images/events/event_santa_karaoke.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/images/events/event_schlager-karaoke.jpeg
Normal file
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 800 KiB |
|
Before Width: | Height: | Size: 747 KiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 604 KiB After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 580 KiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 567 KiB After Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
@ -29,15 +29,15 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
<div class="circle-row">
|
<div class="circle-row">
|
||||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
<img src="/images/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
|
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
<img src="/images/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
|
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
<img src="/images/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
// src/components/Footer.astro
|
// src/components/Footer.astro
|
||||||
import "/styles/components/Footer.css"
|
import "../styles/components/Footer.css"
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer" id="footer">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
|
|
||||||
|
|
||||||
<div class="footer-sections">
|
<div class="footer-sections">
|
||||||
<div class="footer-section">
|
<div class="footer-section">
|
||||||
<h3>Öffnungszeiten</h3>
|
<h3>Öffnungszeiten</h3>
|
||||||
@ -20,7 +22,7 @@ const currentYear = new Date().getFullYear();
|
|||||||
<p>Gallus Pub</p>
|
<p>Gallus Pub</p>
|
||||||
<p>Metzgergasse 13</p>
|
<p>Metzgergasse 13</p>
|
||||||
<p>9000 St. Gallen</p>
|
<p>9000 St. Gallen</p>
|
||||||
<p>Email:</p>
|
<p><a href="tel:0772322770">077 232 27 70</a></p>
|
||||||
<p><a href="mailto:info@gallus-pub.ch">info@gallus-pub.ch</a></p>
|
<p><a href="mailto:info@gallus-pub.ch">info@gallus-pub.ch</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -28,11 +30,9 @@ const currentYear = new Date().getFullYear();
|
|||||||
<h3>Raumreservationen</h3>
|
<h3>Raumreservationen</h3>
|
||||||
<p>Du planst einen Event?</p>
|
<p>Du planst einen Event?</p>
|
||||||
<p>Der "St.Gallerruum" im 2.OG</p>
|
<p>Der "St.Gallerruum" im 2.OG</p>
|
||||||
<p>Kann gemietet werden.</p>
|
<p>kann gemietet werden.</p>
|
||||||
<p>Reservierungen via Whatsapp</p>
|
<p>Reservierungen via Whatsapp</p>
|
||||||
<p><a href="tel:0772322770">077 232 27 70</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="copyright">
|
<div class="copyright">
|
||||||
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
<p>Im Herzen von St.Gallen</p>
|
<p>Im Herzen von St.Gallen</p>
|
||||||
|
|
||||||
<a href="#" class="button">Aktuelles ↓</a>
|
<a href="#welcome" class="button">Aktuelles ↓</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,7 +39,7 @@ const {title, description, image = "", date} = Astro.props;
|
|||||||
|
|
||||||
// Close card when clicking outside (mobile only)
|
// Close card when clicking outside (mobile only)
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (window.innerWidth <= 768 && !card.contains(e.target)) {
|
if (window.innerWidth <= 768 && !card.contains(e.target as Node)) {
|
||||||
card.classList.remove('active');
|
card.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"image": "/images/karaoke.jpg",
|
|
||||||
"title": "Karaoke",
|
|
||||||
"date": "Mittwoch - Samstag",
|
|
||||||
"description": "Bei uns gibt es Karaoke Mi-Sa!! <br>\nSeid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>\nReserviere am besten gleich per Whatsapp <a href=\"tel:+41772322770\">077 232 27 70</a>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"image": "/images/pub_quiz.jpg",
|
|
||||||
"title": "Pub Quiz",
|
|
||||||
"date": "Jeden Freitag",
|
|
||||||
"description": "Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>\nJede 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>\nAuch Einzelpersonen sind herzlich willkommen! <br>\n*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"image": "/images/crepes_sucette.jpg",
|
|
||||||
"title": "Crepes Sucette <br /> Live Music im Gallus Pub!",
|
|
||||||
"date": "Do, 04. September 2025",
|
|
||||||
"description": "<b>20:00 Uhr</b> <br>\n<a href=\"Metzgergasse 13, 9000 St. Gallen\">Metzgergasse 13, 9000 St. Gallen</a> <br>\nErlebt einen musikalischen Abend mit der Band <b>Crepes Sucette</b> <br>\nJetzt reservieren: <a href=\"tel:+41772322770\">077 232 27 70</a>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"image": "/images/kevin_mcflannigan.jpeg",
|
|
||||||
"title": "Kevin McFlannigan <br> Live Music im Gallus Pub!",
|
|
||||||
"date": "Sa, 27. September 2025",
|
|
||||||
"description": "<b>ab 20:00 Uhr</b> <br>\nSinger & Songwriter Kevin McFlannigan <br>\nEintritt ist Frei / Hutkollekte <br>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
[
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Erstes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Zweites Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Drittes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Viertes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Fünftes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Sechstes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Siebtes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Achtes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Neuntes Bild" },
|
|
||||||
{ "src": "/images/Logo.png", "alt": "Zehntes Bild" }
|
|
||||||
]
|
|
||||||
294
src/pages/admin.astro
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
---
|
||||||
|
const title = 'Admin';
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
|
||||||
|
h1, h2 { margin: 0.5rem 0; }
|
||||||
|
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
||||||
|
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
|
||||||
|
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
|
||||||
|
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
|
||||||
|
.muted { color: #666; }
|
||||||
|
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
|
||||||
|
.btn:hover { background: #444; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 0.75rem; }
|
||||||
|
.card { border: 1px solid #eee; padding: 0.75rem; border-radius: 6px; }
|
||||||
|
label { display:block; margin-top: 0.5rem; }
|
||||||
|
input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; }
|
||||||
|
img.thumb { max-width: 100%; height: auto; display: block; }
|
||||||
|
.toolbar { display:flex; gap:.5rem; align-items:center; margin:.5rem 0; }
|
||||||
|
.pill { font-size:.85rem; padding:.25rem .5rem; border:1px solid #ddd; border-radius:999px; background:#f7f7f7; }
|
||||||
|
.dragging { opacity:.5; }
|
||||||
|
.drag-handle { cursor:grab; padding:.25rem .5rem; border:1px dashed #bbb; border-radius:6px; font-size:.85rem; }
|
||||||
|
.row-buttons { display:flex; gap:.5rem; margin-top:.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<section>
|
||||||
|
<h2>Authentifizierung</h2>
|
||||||
|
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
|
||||||
|
<div class="row">
|
||||||
|
<a id="login-link" class="btn" href="https://cms.gallus-pub.ch/api/auth/gitea">Mit Gitea anmelden</a>
|
||||||
|
<button id="btn-relogin">Neu anmelden</button>
|
||||||
|
<button id="btn-logout">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sec-events" style="display:none">
|
||||||
|
<h2>Events verwalten</h2>
|
||||||
|
<div class="events-row">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Neues Event</h3>
|
||||||
|
<label>Titel<input id="ev-title" /></label>
|
||||||
|
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
|
||||||
|
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
|
||||||
|
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
|
||||||
|
<label>Alt-Text<input id="ev-alt" placeholder="Bildbeschreibung" /></label>
|
||||||
|
<button id="btn-create-ev">Event anlegen</button>
|
||||||
|
<div id="ev-create-msg" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="min-width:380px;">
|
||||||
|
<h3>Liste</h3>
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="pill" id="lbl-order">Anzeige: automatisch nach Datum (neueste zuerst)</span>
|
||||||
|
<button id="btn-toggle-reorder">Reihenfolge bearbeiten</button>
|
||||||
|
<button id="btn-save-order" style="display:none">Reihenfolge speichern</button>
|
||||||
|
<span id="order-msg" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<div id="events-list" class="grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sec-publish" style="display:none">
|
||||||
|
<h2>Veröffentlichen</h2>
|
||||||
|
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
||||||
|
<button id="btn-publish">Publish</button>
|
||||||
|
<div id="pub-status" class="muted"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Base-URL des Backends (separate Subdomain)
|
||||||
|
const API_BASE = 'https://cms.gallus-pub.ch';
|
||||||
|
|
||||||
|
const api = async (path, opts = {}) => {
|
||||||
|
const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
return ct.includes('application/json') ? res.json() : res.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function refreshAuth() {
|
||||||
|
try {
|
||||||
|
const me = await api('/api/auth/me');
|
||||||
|
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
|
||||||
|
// UI-Bereiche für eingeloggte Nutzer einblenden
|
||||||
|
document.getElementById('sec-events').style.display = '';
|
||||||
|
document.getElementById('sec-publish').style.display = '';
|
||||||
|
// Direkt Events laden und auf Sektion fokussieren
|
||||||
|
await loadEvents();
|
||||||
|
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
} catch (e) {
|
||||||
|
const el = document.getElementById('auth-status');
|
||||||
|
el.textContent = 'Nicht angemeldet';
|
||||||
|
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
|
||||||
|
document.getElementById('sec-events').style.display = 'none';
|
||||||
|
document.getElementById('sec-publish').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: falls der Link von Browser/Extensions blockiert wäre
|
||||||
|
const loginLink = document.getElementById('login-link');
|
||||||
|
loginLink.addEventListener('click', (e) => {
|
||||||
|
try {
|
||||||
|
// Stelle sicher, dass Navigieren erzwungen wird
|
||||||
|
window.location.assign(API_BASE + '/api/auth/gitea');
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
document.getElementById('btn-relogin').addEventListener('click', async () => {
|
||||||
|
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
||||||
|
document.cookie = 'token=; Path=/; Max-Age=0;';
|
||||||
|
window.location.assign(API_BASE + '/api/auth/gitea');
|
||||||
|
});
|
||||||
|
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||||
|
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
||||||
|
document.cookie = 'token=; Path=/; Max-Age=0;';
|
||||||
|
await refreshAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Events & Publish ==========
|
||||||
|
async function uploadImage(file, altText) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (altText) fd.append('altText', altText);
|
||||||
|
fd.append('displayOrder', '0');
|
||||||
|
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
let reorderMode = false;
|
||||||
|
let lastEvents = [];
|
||||||
|
|
||||||
|
function parseDateSafe(s){
|
||||||
|
const d = new Date(s);
|
||||||
|
return isNaN(+d) ? new Date(0) : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const listEl = document.getElementById('events-list');
|
||||||
|
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
||||||
|
try {
|
||||||
|
const data = await api('/api/events');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
// Merken, globale Liste aktualisieren
|
||||||
|
lastEvents = (data.events || []).slice();
|
||||||
|
let renderList = lastEvents.slice();
|
||||||
|
if (!reorderMode) {
|
||||||
|
// Automatisch nach Datum sortieren (neueste zuerst)
|
||||||
|
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
|
||||||
|
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
|
||||||
|
} else {
|
||||||
|
// Nach displayOrder aufsteigend
|
||||||
|
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
||||||
|
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList.forEach((ev, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
card.setAttribute('draggable', String(reorderMode));
|
||||||
|
card.dataset.id = ev.id;
|
||||||
|
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="row" style="justify-content:space-between;align-items:center">
|
||||||
|
<div><strong>${ev.title}</strong></div>
|
||||||
|
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="muted">${ev.date}</div>
|
||||||
|
<div>${ev.description || ''}</div>
|
||||||
|
<div class="muted">Bild: ${ev.imageUrl || ''}</div>
|
||||||
|
<div class="row-buttons">
|
||||||
|
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
|
||||||
|
</div>`;
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
if (!id) return;
|
||||||
|
if (!confirm('Event wirklich löschen?')) return;
|
||||||
|
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (reorderMode) {
|
||||||
|
enableDragAndDrop(listEl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag & Drop Reorder
|
||||||
|
function enableDragAndDrop(container){
|
||||||
|
let draggingEl = null;
|
||||||
|
container.querySelectorAll('.card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', (e) => {
|
||||||
|
draggingEl = card; card.classList.add('dragging');
|
||||||
|
e.dataTransfer.setData('text/plain', card.dataset.id || '');
|
||||||
|
});
|
||||||
|
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
|
||||||
|
card.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||||
|
card.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = card;
|
||||||
|
if (!draggingEl || draggingEl === target) return;
|
||||||
|
const cards = Array.from(container.querySelectorAll('.card'));
|
||||||
|
const draggingIdx = cards.indexOf(draggingEl);
|
||||||
|
const targetIdx = cards.indexOf(target);
|
||||||
|
if (draggingIdx < targetIdx) {
|
||||||
|
target.after(draggingEl);
|
||||||
|
} else {
|
||||||
|
target.before(draggingEl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-create-ev').addEventListener('click', async () => {
|
||||||
|
const title = (document.getElementById('ev-title')).value.trim();
|
||||||
|
const date = (document.getElementById('ev-date')).value.trim();
|
||||||
|
const desc = (document.getElementById('ev-desc')).value.trim();
|
||||||
|
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
|
||||||
|
const alt = (document.getElementById('ev-alt')).value.trim();
|
||||||
|
const msg = document.getElementById('ev-create-msg');
|
||||||
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
|
try {
|
||||||
|
let imageUrl = '';
|
||||||
|
if (file) {
|
||||||
|
const up = await uploadImage(file, alt || title);
|
||||||
|
imageUrl = up?.image?.imageUrl || '';
|
||||||
|
}
|
||||||
|
msg.textContent = 'Lege Event an...';
|
||||||
|
await api('/api/events', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, date, description: desc, imageUrl, displayOrder: 0, isPublished: true })
|
||||||
|
});
|
||||||
|
msg.textContent = 'Event erstellt';
|
||||||
|
(document.getElementById('ev-title')).value = '';
|
||||||
|
(document.getElementById('ev-date')).value = '';
|
||||||
|
(document.getElementById('ev-desc')).value = '';
|
||||||
|
(document.getElementById('ev-file')).value = '';
|
||||||
|
(document.getElementById('ev-alt')).value = '';
|
||||||
|
await loadEvents();
|
||||||
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-publish').addEventListener('click', async () => {
|
||||||
|
const s = document.getElementById('pub-status');
|
||||||
|
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
|
||||||
|
s.textContent = 'Veröffentliche...';
|
||||||
|
try {
|
||||||
|
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
|
||||||
|
s.textContent = res?.message || 'Veröffentlicht';
|
||||||
|
} catch(e){ s.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle Reorder
|
||||||
|
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
|
||||||
|
reorderMode = !reorderMode;
|
||||||
|
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
|
||||||
|
await loadEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save Order
|
||||||
|
document.getElementById('btn-save-order').addEventListener('click', async () => {
|
||||||
|
const container = document.getElementById('events-list');
|
||||||
|
const cards = Array.from(container.querySelectorAll('.card'));
|
||||||
|
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
|
||||||
|
const msg = document.getElementById('order-msg');
|
||||||
|
msg.textContent = 'Speichere Reihenfolge...';
|
||||||
|
try {
|
||||||
|
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
|
||||||
|
msg.textContent = 'Reihenfolge gespeichert';
|
||||||
|
// Bleibe in Reorder-Mode oder verlasse ihn? Hier: verlassen
|
||||||
|
reorderMode = false;
|
||||||
|
document.getElementById('btn-save-order').style.display = 'none';
|
||||||
|
await loadEvents();
|
||||||
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
import Layout from "../../components/Layout.astro";
|
|
||||||
import eventsData from "../../content/events.json";
|
|
||||||
import imagesData from "../../content/gallery.json";
|
|
||||||
import { getSessionFromRequest } from "../../utils/session";
|
|
||||||
|
|
||||||
const session = getSessionFromRequest(Astro.request);
|
|
||||||
if (!session?.user) {
|
|
||||||
// Not logged in: redirect to OAuth login
|
|
||||||
return Astro.redirect("/api/auth/login");
|
|
||||||
}
|
|
||||||
const csrf = session.csrf;
|
|
||||||
const events = eventsData;
|
|
||||||
const images = imagesData;
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<section>
|
|
||||||
<h1>Admin</h1>
|
|
||||||
<p>Eingeloggt als {session.user.login}</p>
|
|
||||||
<form id="editor">
|
|
||||||
<h2>Events (JSON)</h2>
|
|
||||||
<textarea id="events" name="events" rows="16" style="width:100%;font-family:monospace;">{JSON.stringify(events, null, 2)}</textarea>
|
|
||||||
|
|
||||||
<h2>Galerie (JSON)</h2>
|
|
||||||
<textarea id="images" name="images" rows="10" style="width:100%;font-family:monospace;">{JSON.stringify(images, null, 2)}</textarea>
|
|
||||||
|
|
||||||
<h2>Bilder hochladen</h2>
|
|
||||||
<input type="file" id="fileInput" multiple accept="image/*" />
|
|
||||||
|
|
||||||
<div style="margin-top:1rem;display:flex;gap:.5rem;">
|
|
||||||
<button id="saveBtn" type="button">Speichern</button>
|
|
||||||
<button id="logoutBtn" type="button">Logout</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<meta name="csrf" content={csrf} />
|
|
||||||
<script type="module">
|
|
||||||
const csrf = document.querySelector('meta[name="csrf"]').content;
|
|
||||||
|
|
||||||
async function uploadFiles(files){
|
|
||||||
const uploads = [];
|
|
||||||
for (const file of files){
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
|
||||||
uploads.push({ path: `public/images/${file.name}`, content: base64 });
|
|
||||||
}
|
|
||||||
return uploads;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(){
|
|
||||||
let events, images;
|
|
||||||
try{
|
|
||||||
events = JSON.parse(document.getElementById('events').value);
|
|
||||||
images = JSON.parse(document.getElementById('images').value);
|
|
||||||
}catch(e){
|
|
||||||
alert('JSON fehlerhaft: ' + e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
{ path: 'src/content/events.json', content: JSON.stringify(events, null, 2) },
|
|
||||||
{ path: 'src/content/gallery.json', content: JSON.stringify(images, null, 2) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const input = document.getElementById('fileInput');
|
|
||||||
if (input.files && input.files.length){
|
|
||||||
const imageFiles = await uploadFiles(input.files);
|
|
||||||
files.push(...imageFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('/api/save', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf },
|
|
||||||
body: JSON.stringify({ files, message: 'Admin-Inhalte aktualisiert' })
|
|
||||||
});
|
|
||||||
if (!res.ok){
|
|
||||||
const t = await res.text();
|
|
||||||
alert('Fehler beim Speichern: ' + t);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
alert('Gespeichert! Build wird gestartet.');
|
|
||||||
// optional: Seite neu laden
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('saveBtn').addEventListener('click', save);
|
|
||||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
location.href = '/';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</Layout>
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { createSessionCookie, sessionCookieHeader, randomToken } from "../../../utils/session";
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const code = url.searchParams.get("code");
|
|
||||||
const state = url.searchParams.get("state");
|
|
||||||
const cookie = request.headers.get("cookie") || "";
|
|
||||||
const stateCookie = cookie.match(/(?:^|; )oauth_state=([^;]+)/)?.[1];
|
|
||||||
|
|
||||||
if (!code || !state || !stateCookie || stateCookie !== state) {
|
|
||||||
return new Response("Invalid OAuth state", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
|
||||||
const tokenUrlRaw = process.env.OAUTH_TOKEN_URL;
|
|
||||||
const userinfoUrl = process.env.OAUTH_USERINFO_URL;
|
|
||||||
if (!clientId || !clientSecret || !tokenUrlRaw || !userinfoUrl) {
|
|
||||||
return new Response("OAuth not fully configured. Please set OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_TOKEN_URL, OAUTH_USERINFO_URL.", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute redirect_uri consistent with login, robust against invalid PUBLIC_BASE_URL
|
|
||||||
let redirectUri: string;
|
|
||||||
try {
|
|
||||||
if (process.env.PUBLIC_BASE_URL) {
|
|
||||||
const base = new URL(process.env.PUBLIC_BASE_URL);
|
|
||||||
redirectUri = new URL("/api/auth/callback", base).toString();
|
|
||||||
} else {
|
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate token URL
|
|
||||||
let tokenUrl: URL;
|
|
||||||
try {
|
|
||||||
tokenUrl = new URL(tokenUrlRaw);
|
|
||||||
} catch {
|
|
||||||
return new Response("Invalid OAUTH_TOKEN_URL", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange code for token
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("client_id", clientId);
|
|
||||||
params.set("client_secret", clientSecret);
|
|
||||||
params.set("code", code);
|
|
||||||
params.set("grant_type", "authorization_code");
|
|
||||||
params.set("redirect_uri", redirectUri);
|
|
||||||
|
|
||||||
const tokenRes = await fetch(tokenUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenRes.ok) {
|
|
||||||
const t = await tokenRes.text();
|
|
||||||
return new Response(`OAuth token error: ${tokenRes.status} ${t}`, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenData = await tokenRes.json().catch(async () => {
|
|
||||||
// Some Gitea versions return application/x-www-form-urlencoded
|
|
||||||
const text = await tokenRes.text();
|
|
||||||
const usp = new URLSearchParams(text);
|
|
||||||
return Object.fromEntries(usp.entries());
|
|
||||||
});
|
|
||||||
const accessToken = tokenData.access_token || tokenData["access_token"];
|
|
||||||
if (!accessToken) {
|
|
||||||
return new Response("No access token", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRes = await fetch(userinfoUrl, {
|
|
||||||
headers: { Authorization: `token ${accessToken}` },
|
|
||||||
});
|
|
||||||
if (!userRes.ok) {
|
|
||||||
const t = await userRes.text();
|
|
||||||
return new Response(`Userinfo error: ${userRes.status} ${t}`, { status: 500 });
|
|
||||||
}
|
|
||||||
const user = await userRes.json();
|
|
||||||
|
|
||||||
// Optional allowlist
|
|
||||||
const allowedUsers = (process.env.OAUTH_ALLOWED_USERS || "").split(/[\,\s]+/).filter(Boolean);
|
|
||||||
const allowedOrg = process.env.OAUTH_ALLOWED_ORG;
|
|
||||||
if (allowedUsers.length && !allowedUsers.includes(user.login)) {
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
|
||||||
if (allowedOrg) {
|
|
||||||
// Best-effort org check
|
|
||||||
try {
|
|
||||||
const orgsRes = await fetch(process.env.GITEA_BASE + "/api/v1/user/orgs", {
|
|
||||||
headers: { Authorization: `token ${accessToken}` },
|
|
||||||
});
|
|
||||||
if (orgsRes.ok) {
|
|
||||||
const orgs = await orgsRes.json();
|
|
||||||
const inOrg = Array.isArray(orgs) && orgs.some((o: any) => o.username === allowedOrg || o.login === allowedOrg || o.name === allowedOrg);
|
|
||||||
if (!inOrg) return new Response("Forbidden (org)", { status: 403 });
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const csrf = randomToken(16);
|
|
||||||
const sessionValue = createSessionCookie({
|
|
||||||
user: { id: user.id, login: user.login, name: user.full_name || user.username || user.login, email: user.email },
|
|
||||||
csrf,
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set("Set-Cookie", sessionCookieHeader(sessionValue));
|
|
||||||
headers.append("Set-Cookie", "oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");
|
|
||||||
headers.set("Location", "/admin");
|
|
||||||
return new Response(null, { status: 302, headers });
|
|
||||||
};
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { randomToken, setTempCookie } from "../../../utils/session";
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
|
||||||
const authorizeUrlRaw = process.env.OAUTH_AUTHORIZE_URL;
|
|
||||||
if (!clientId || !authorizeUrlRaw) {
|
|
||||||
return new Response(
|
|
||||||
"OAuth not configured. Please set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL (and related secrets) for local dev.",
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine callback URL
|
|
||||||
let finalRedirect: string;
|
|
||||||
try {
|
|
||||||
if (process.env.PUBLIC_BASE_URL) {
|
|
||||||
// Ensure PUBLIC_BASE_URL is an absolute URL
|
|
||||||
const base = new URL(process.env.PUBLIC_BASE_URL);
|
|
||||||
finalRedirect = new URL("/api/auth/callback", base).toString();
|
|
||||||
} else {
|
|
||||||
// Fallback to current request origin
|
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// As a last resort, use request URL
|
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate authorize URL
|
|
||||||
let authorizeUrl: URL;
|
|
||||||
try {
|
|
||||||
authorizeUrl = new URL(authorizeUrlRaw);
|
|
||||||
} catch {
|
|
||||||
return new Response("Invalid OAUTH_AUTHORIZE_URL", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = randomToken(16);
|
|
||||||
authorizeUrl.searchParams.set("client_id", clientId);
|
|
||||||
authorizeUrl.searchParams.set("redirect_uri", finalRedirect);
|
|
||||||
authorizeUrl.searchParams.set("response_type", "code");
|
|
||||||
authorizeUrl.searchParams.set("state", state);
|
|
||||||
|
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: {
|
|
||||||
Location: authorizeUrl.toString(),
|
|
||||||
"Set-Cookie": setTempCookie("oauth_state", state),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { clearCookieHeader } from "../../../utils/session";
|
|
||||||
|
|
||||||
export const POST: APIRoute = async () => {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 204,
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": clearCookieHeader(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { getSessionFromRequest } from "../../utils/session";
|
|
||||||
|
|
||||||
const GITEA_BASE = process.env.GITEA_BASE!;
|
|
||||||
const GITEA_OWNER = process.env.GITEA_OWNER!;
|
|
||||||
const GITEA_REPO = process.env.GITEA_REPO!;
|
|
||||||
const GITEA_TOKEN = process.env.GITEA_TOKEN!;
|
|
||||||
const DEFAULT_BRANCH = process.env.GIT_BRANCH || "main";
|
|
||||||
|
|
||||||
function isAllowedPath(path: string) {
|
|
||||||
if (path === "src/content/events.json") return true;
|
|
||||||
if (path === "src/content/gallery.json") return true;
|
|
||||||
if (path.startsWith("public/images/")) {
|
|
||||||
return /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(path);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getShaIfExists(path: string): Promise<string | undefined> {
|
|
||||||
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}?ref=${encodeURIComponent(DEFAULT_BRANCH)}`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { Authorization: `token ${GITEA_TOKEN}` },
|
|
||||||
});
|
|
||||||
if (res.status === 404) return undefined;
|
|
||||||
if (!res.ok) throw new Error(`Gitea get sha error ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return data.sha;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
const session = getSessionFromRequest(request);
|
|
||||||
if (!session?.user) return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
|
||||||
|
|
||||||
// CSRF header required
|
|
||||||
const csrfHeader = request.headers.get("x-csrf") || request.headers.get("X-CSRF");
|
|
||||||
if (!csrfHeader || csrfHeader !== session.csrf) {
|
|
||||||
return new Response(JSON.stringify({ error: "invalid csrf" }), { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: any;
|
|
||||||
try {
|
|
||||||
payload = await request.json();
|
|
||||||
} catch {
|
|
||||||
return new Response(JSON.stringify({ error: "invalid json" }), { status: 400 });
|
|
||||||
}
|
|
||||||
if (!payload || !Array.isArray(payload.files)) {
|
|
||||||
return new Response(JSON.stringify({ error: "missing files" }), { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: any[] = [];
|
|
||||||
for (const file of payload.files) {
|
|
||||||
const path = String(file.path || "");
|
|
||||||
if (!isAllowedPath(path)) {
|
|
||||||
return new Response(JSON.stringify({ error: `path not allowed: ${path}` }), { status: 400 });
|
|
||||||
}
|
|
||||||
let contentBase64: string;
|
|
||||||
if (path.startsWith("public/images/")) {
|
|
||||||
// Expect already base64 string of binary
|
|
||||||
contentBase64 = String(file.content || "");
|
|
||||||
// Remove possible data URL prefix
|
|
||||||
const match = contentBase64.match(/^data:[^;]+;base64,(.*)$/);
|
|
||||||
if (match) contentBase64 = match[1];
|
|
||||||
} else {
|
|
||||||
// Text file
|
|
||||||
contentBase64 = Buffer.from(String(file.content ?? ""), "utf-8").toString("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha = await getShaIfExists(path).catch(() => undefined);
|
|
||||||
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}`;
|
|
||||||
const body: any = {
|
|
||||||
content: contentBase64,
|
|
||||||
message: payload.message || `Update ${path}`,
|
|
||||||
branch: DEFAULT_BRANCH,
|
|
||||||
};
|
|
||||||
if (sha) body.sha = sha;
|
|
||||||
if (session.user) {
|
|
||||||
body.author = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
|
|
||||||
body.committer = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${GITEA_TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text();
|
|
||||||
return new Response(JSON.stringify({ error: `Gitea error for ${path}: ${res.status} ${t}` }), { status: 500 });
|
|
||||||
}
|
|
||||||
results.push(await res.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
};
|
|
||||||
29
src/pages/auth/callback.astro
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
const title = 'Anmeldung wird abgeschlossen...';
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>{title}</p>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = params.get('token');
|
||||||
|
if (token) {
|
||||||
|
const secure = window.location.protocol === 'https:';
|
||||||
|
document.cookie = `token=${encodeURIComponent(token)}; Path=/; Max-Age=${60*60*24}; SameSite=Lax; ${secure ? 'Secure' : ''}`.trim();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to process OAuth token', e);
|
||||||
|
}
|
||||||
|
window.location.replace('/admin');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
// src/pages/index.astro
|
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Hero from "../components/Hero.astro";
|
import Hero from "../components/Hero.astro";
|
||||||
import Welcome from "../components/Welcome.astro";
|
import Welcome from "../components/Welcome.astro";
|
||||||
@ -9,9 +8,13 @@ import ImageCarousel from "../components/ImageCarousel.astro";
|
|||||||
import Contact from "../components/Contact.astro";
|
import Contact from "../components/Contact.astro";
|
||||||
import About from "../components/About.astro";
|
import About from "../components/About.astro";
|
||||||
|
|
||||||
// Inhalte aus Dateien laden (editierbar über Admin)
|
const events = [
|
||||||
import events from "../content/events.json";
|
|
||||||
import images from "../content/gallery.json";
|
];
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
{ src: "/static/images/gallery/miyma9zc-8he1di.webp", alt: "Schwarzes bild" }
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.Drinks {
|
.Drinks {
|
||||||
font-family: var(--font-family-primary), serif;
|
font-family: var(--font-family-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: var(--font-size-large);
|
font-size: var(--font-size-large);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -25,6 +25,7 @@
|
|||||||
.card-link {
|
.card-link {
|
||||||
border: 2px solid var(--color-accent-beige);
|
border: 2px solid var(--color-accent-beige);
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
@ -68,7 +69,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
width: 80%;
|
width: 80%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
@ -81,8 +81,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.circle {
|
.circle {
|
||||||
height: 9em;
|
height: 35vh;
|
||||||
width: 9em;
|
width: 35vh;
|
||||||
border: 2px solid var(--color-accent-beige);
|
border: 2px solid var(--color-accent-beige);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
@ -94,6 +94,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle:hover {
|
.circle:hover {
|
||||||
@ -109,12 +110,25 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
transition: opacity var(--transition-standard);
|
transition: opacity var(--transition-standard);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle:hover .circle-label {
|
.circle:hover .circle-label {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.circle-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.circle-row {
|
.circle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -149,10 +163,6 @@
|
|||||||
width: 90%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle {
|
|
||||||
height: 5em;
|
|
||||||
width: 5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-label {
|
.circle-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
.hover-card {
|
.hover-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 400px;
|
width: 25rem;
|
||||||
height: 400px;
|
height: 25rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
background-color: var(--color-accent-green);
|
background-color: var(--color-accent-green);
|
||||||
box-shadow: var(--box-shadow);
|
box-shadow: var(--box-shadow);
|
||||||
@ -12,8 +12,28 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-card:hover {
|
/* Hover effects only for devices that support hover */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.hover-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card:hover .hover-text {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card:hover .card-image {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
padding: 15px 15px 5px 15px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-accent-beige);
|
||||||
|
font-size: var(--font-size-medium);
|
||||||
|
text-align: center;
|
||||||
|
order: -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card_date {
|
.card_date {
|
||||||
@ -84,11 +104,12 @@
|
|||||||
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
|
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-card:hover .hover-text {
|
/* Active state for mobile tap functionality */
|
||||||
|
.hover-card.active .hover-text {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-card:hover .card-image {
|
.hover-card.active .card-image {
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,5 +122,34 @@
|
|||||||
.hover-card {
|
.hover-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
|
/* Maintain square aspect ratio */
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
height: auto;
|
||||||
|
/* Add cursor pointer to indicate it's clickable */
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add visual feedback for tap */
|
||||||
|
.hover-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: var(--color-accent-beige);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the hint when card is active */
|
||||||
|
.hover-card.active::after {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
export type SessionData = {
|
|
||||||
user?: {
|
|
||||||
id: number;
|
|
||||||
login: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
csrf?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const COOKIE_NAME = "gp_session";
|
|
||||||
|
|
||||||
function b64url(input: Buffer | string) {
|
|
||||||
return Buffer.from(input)
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/=/g, "")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sign(payload: string, secret: string) {
|
|
||||||
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSessionCookie(data: SessionData, secret = process.env.SESSION_SECRET || "") {
|
|
||||||
const payload = b64url(JSON.stringify(data));
|
|
||||||
const sig = sign(payload, secret);
|
|
||||||
return `${payload}.${sig}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSessionCookie(cookieValue: string | undefined, secret = process.env.SESSION_SECRET || ""): SessionData | undefined {
|
|
||||||
if (!cookieValue) return undefined;
|
|
||||||
const [payload, sig] = cookieValue.split(".");
|
|
||||||
if (!payload || !sig) return undefined;
|
|
||||||
const expected = sign(payload, secret);
|
|
||||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return undefined;
|
|
||||||
try {
|
|
||||||
const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
|
||||||
return JSON.parse(json);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearCookieHeader(name = COOKIE_NAME) {
|
|
||||||
return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionCookieHeader(value: string, name = COOKIE_NAME) {
|
|
||||||
// 7 days
|
|
||||||
const maxAge = 60 * 60 * 24 * 7;
|
|
||||||
return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSessionFromRequest(req: Request, secret = process.env.SESSION_SECRET || ""): SessionData | undefined {
|
|
||||||
const cookie = req.headers.get("cookie") || "";
|
|
||||||
const match = cookie.match(/(?:^|; )gp_session=([^;]+)/);
|
|
||||||
if (!match) return undefined;
|
|
||||||
return parseSessionCookie(match[1], secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function randomToken(bytes = 32) {
|
|
||||||
return crypto.randomBytes(bytes).toString("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COOKIE_NAME_STATE = "oauth_state";
|
|
||||||
|
|
||||||
export function setTempCookie(name: string, value: string) {
|
|
||||||
// short lived: 10 minutes
|
|
||||||
const maxAge = 60 * 10;
|
|
||||||
return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
|
|
||||||
}
|
|
||||||