Compare commits
8 Commits
feat/cms
...
48bae59264
| Author | SHA1 | Date | |
|---|---|---|---|
| 48bae59264 | |||
| 761ab5d5b5 | |||
| cb43b4a7b5 | |||
| cbcb17a35c | |||
| 5922d5d274 | |||
| 96322a4776 | |||
| a5bdf7b4f5 | |||
| 03671a4d3e |
25
.env.example
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Local development configuration for OAuth + Gitea
|
||||||
|
# Copy this file to .env.local and fill in values, then run: npm run dev:local
|
||||||
|
|
||||||
|
PUBLIC_BASE_URL=http://localhost:4321
|
||||||
|
|
||||||
|
# Gitea OAuth app created with redirect URI: http://localhost:4321/api/auth/callback
|
||||||
|
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 allow-list (comma separated usernames)
|
||||||
|
# OAUTH_ALLOWED_USERS=
|
||||||
|
|
||||||
|
# Gitea API for commits (service account PAT must have write:repository)
|
||||||
|
GITEA_BASE=https://git.bookageek.ch
|
||||||
|
GITEA_OWNER=
|
||||||
|
GITEA_REPO=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GIT_BRANCH=main
|
||||||
|
|
||||||
|
# Secrets (use long random strings)
|
||||||
|
SESSION_SECRET=
|
||||||
|
CSRF_SECRET=
|
||||||
1
.gitignore
vendored
@ -16,6 +16,7 @@ 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
|
||||||
|
|||||||
14
Dockerfile
@ -2,23 +2,19 @@ FROM node:20-alpine AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# Fallback to npm install if no lockfile is present
|
RUN npm ci
|
||||||
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
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm install -g serve
|
ENV NODE_ENV=production
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
|
# install minimal deps to run node if needed (alpine already has node)
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
CMD ["serve", "-l", "3000", "dist"]
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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"]
|
|
||||||
96
README.md
@ -1,47 +1,65 @@
|
|||||||
# Astro Starter Kit: Minimal
|
# Gallus Pub Website – Admin mit Gitea OAuth und Git-Commits
|
||||||
|
|
||||||
```sh
|
Dieses Projekt stellt eine Astro‑Seite bereit und enthält eine Admin‑Oberfläche unter `/admin`, mit der Inhalte (Events, Galerie und Bilder) ohne Datenbank gepflegt werden können. Änderungen werden als Commits direkt in das Gitea‑Repository geschrieben. Woodpecker baut daraufhin und Fly.io deployt.
|
||||||
npm create astro@latest -- --template minimal
|
|
||||||
|
## Inhalte (Headless, Git‑basiert)
|
||||||
|
- Editierbare Dateien im Repo:
|
||||||
|
- `src/content/events.json`
|
||||||
|
- `src/content/gallery.json`
|
||||||
|
- Bilder: `public/images/*`
|
||||||
|
- Die Startseite importiert diese Dateien und rendert sie.
|
||||||
|
|
||||||
|
## Admin & Auth
|
||||||
|
- Admin‑Seite: `https://<domain>/admin` (kein Link im UI, nur direkter Pfad)
|
||||||
|
- Login via Gitea OAuth:
|
||||||
|
- `/api/auth/login` → Gitea → `/api/auth/callback`
|
||||||
|
- Session als HttpOnly‑Cookie, CSRF‑Cookie für POSTs
|
||||||
|
- Speichern: `/api/save` validiert und committet die Dateien via Gitea‑API
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
1) `.env.example` nach `.env.local` kopieren und ausfüllen (Gitea OAuth‑App mit Redirect `http://localhost:4321/api/auth/callback`).
|
||||||
|
2) Installieren und starten:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev:local
|
||||||
```
|
```
|
||||||
|
3) Browser öffnen: `http://localhost:4321/admin` → Mit Gitea anmelden → Inhalte bearbeiten → Speichern.
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
Hinweis: Für lokales HTTP sind Cookies ohne `Secure` gesetzt. In Produktion werden Cookies automatisch als `Secure` markiert.
|
||||||
[](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!
|
## Produktion (Fly.io)
|
||||||
|
- Dockerfile baut Astro als SSR und startet `node dist/server/entry.mjs` auf Port 3000.
|
||||||
## 🚀 Project Structure
|
- Secrets auf Fly.io setzen (Beispiele, Werte anpassen):
|
||||||
|
|
||||||
Inside of your Astro project, you'll see the following folders and files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/
|
|
||||||
├── public/
|
|
||||||
├── src/
|
|
||||||
│ └── pages/
|
|
||||||
│ └── index.astro
|
|
||||||
└── package.json
|
|
||||||
```
|
```
|
||||||
|
flyctl secrets set \
|
||||||
|
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 \
|
||||||
|
GITEA_BASE=https://git.bookageek.ch \
|
||||||
|
GITEA_OWNER=OWNER \
|
||||||
|
GITEA_REPO=REPO \
|
||||||
|
GITEA_TOKEN=PAT \
|
||||||
|
GIT_BRANCH=main \
|
||||||
|
SESSION_SECRET=RANDOM \
|
||||||
|
CSRF_SECRET=RANDOM
|
||||||
|
```
|
||||||
|
- Optional: `PUBLIC_BASE_URL=https://gallus-pub.ch` setzen.
|
||||||
|
|
||||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
## Wichtige Pfad‑Konvention
|
||||||
|
- Statische Assets immer unter `public/` ablegen (z. B. `public/images/...`).
|
||||||
|
- Die Admin‑Uploads schreiben automatisch nach `public/images/*`.
|
||||||
|
|
||||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
## Befehle
|
||||||
|
- `npm install` – Abhängigkeiten
|
||||||
|
- `npm run dev` – Standard Dev
|
||||||
|
- `npm run dev:local` – Dev mit `.env.local` (OAuth/Gitea)
|
||||||
|
- `npm run build` – Produktion builden
|
||||||
|
- `npm run preview` – Build lokal testen
|
||||||
|
|
||||||
Any static assets, like images, can be placed in the `public/` directory.
|
## Sicherheit
|
||||||
|
- Kein PAT im Browser – nur serverseitig in Secrets
|
||||||
## 🧞 Commands
|
- CSRF‑Schutz und Pfad‑Allowlist
|
||||||
|
- Optional nutzerbasierte Zulassung: `OAUTH_ALLOWED_USERS` (Komma‑Liste)
|
||||||
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).
|
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
// @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' }),
|
||||||
|
});
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
# 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
|
|
||||||
10
backend/.gitignore
vendored
@ -1,10 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.env
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
/tmp
|
|
||||||
/data
|
|
||||||
*.db
|
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
# Deployment Guide
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
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
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
# 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! 🎉
|
|
||||||
@ -1,217 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# Fly.io configuration for Gallus CMS Backend
|
|
||||||
app = "gallus-cms-backend"
|
|
||||||
primary_region = "ams"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
|
|
||||||
[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"
|
|
||||||
|
|
||||||
[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"
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fastify/cookie": "^9.3.1",
|
|
||||||
"@fastify/cors": "^9.0.1",
|
|
||||||
"@fastify/jwt": "^8.0.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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import * as schema from '../db/schema.js';
|
|
||||||
import { env } from './env.js';
|
|
||||||
|
|
||||||
if (!env.DATABASE_PATH) {
|
|
||||||
throw new Error('DATABASE_PATH environment variable is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
// 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(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
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())`),
|
|
||||||
});
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
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 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
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();
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
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: 'lax',
|
|
||||||
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
|
|
||||||
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
|
||||||
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
|
|
||||||
reply.setCookie('token', token, {
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: !!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;
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
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) => {
|
|
||||||
// List all events (by displayOrder)
|
|
||||||
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;
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
// 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) => {
|
|
||||||
|
|
||||||
// List all gallery images
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,239 +0,0 @@
|
|||||||
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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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:
|
|
||||||
17
fly.toml
@ -4,14 +4,11 @@ kill_signal = "SIGINT"
|
|||||||
kill_timeout = 5
|
kill_timeout = 5
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
dockerfile = "Dockerfile.fly"
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "3000" # Caddy (serves frontend + proxies /api/*)
|
PORT = "3000"
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
BACKEND_PORT = "8080" # Fastify backend will listen here
|
|
||||||
DATABASE_PATH = "/app/data/gallus_cms.db"
|
|
||||||
GIT_WORKSPACE_DIR = "/app/workspace"
|
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3000
|
internal_port = 3000
|
||||||
@ -42,12 +39,4 @@ kill_timeout = 5
|
|||||||
[[vm]]
|
[[vm]]
|
||||||
memory = "512MB"
|
memory = "512MB"
|
||||||
cpu_kind = "shared"
|
cpu_kind = "shared"
|
||||||
cpus = 1
|
cpus = 1
|
||||||
|
|
||||||
[[mounts]]
|
|
||||||
source = "gallus_data"
|
|
||||||
destination = "/app/data"
|
|
||||||
|
|
||||||
[[mounts]]
|
|
||||||
source = "gallus_workspace"
|
|
||||||
destination = "/app/workspace"
|
|
||||||
1020
package-lock.json
generated
10
package.json
@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "Gallus Pub Site",
|
"name": "gallus-pub",
|
||||||
"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.0"
|
"astro": "^5.15.4",
|
||||||
|
"@astrojs/node": "^9.0.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv-cli": "^7.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3114
pnpm-lock.yaml
generated
@ -1,3 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- esbuild
|
|
||||||
- sharp
|
|
||||||
BIN
public/images/Event1.png
Normal file
|
After Width: | Height: | Size: 47 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 |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 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/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
|
<img src="/images/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/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
|
<img src="/images/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/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
<img src="/images/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,13 +19,17 @@ import "../styles/components/EventsGrid.css";
|
|||||||
<h2 class="section-title">Events</h2>
|
<h2 class="section-title">Events</h2>
|
||||||
<section id={id} class="events-gird container">
|
<section id={id} class="events-gird container">
|
||||||
{
|
{
|
||||||
events.map((event: Event) => (
|
events.length === 0 ? (
|
||||||
<HoverCard
|
<p style="text-align:center; width:100%; opacity:0.7;">Keine Events vorhanden. Bitte im Admin-Bereich hinzufügen.</p>
|
||||||
title={event.title}
|
) : (
|
||||||
date={event.date}
|
events.map((event: Event) => (
|
||||||
description={event.description}
|
<HoverCard
|
||||||
image={event.image}
|
title={event.title}
|
||||||
/>
|
date={event.date}
|
||||||
))
|
description={event.description}
|
||||||
|
image={event.image}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
---
|
---
|
||||||
// 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" id="footer">
|
<footer class="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>
|
||||||
@ -22,7 +20,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><a href="tel:0772322770">077 232 27 70</a></p>
|
<p>Email:</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>
|
||||||
|
|
||||||
@ -30,9 +28,11 @@ 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="#welcome" class="button">Aktuelles ↓</a>
|
<a href="#" 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 as Node)) {
|
if (window.innerWidth <= 768 && !card.contains(e.target)) {
|
||||||
card.classList.remove('active');
|
card.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,35 +12,41 @@ const { images = [], id } = Astro.props as { images: Image[], id?: string };
|
|||||||
|
|
||||||
<section id={id} class="image-carousel-container">
|
<section id={id} class="image-carousel-container">
|
||||||
<h2 class="section-title">Galerie</h2>
|
<h2 class="section-title">Galerie</h2>
|
||||||
<div class="image-carousel">
|
{images.length === 0 ? (
|
||||||
<button class="nav-button prev-button" aria-label="Previous image">
|
<p style="text-align:center; width:100%; opacity:0.7;">Keine Bilder vorhanden. Bitte im Admin-Bereich hinzufügen.</p>
|
||||||
<span class="arrow">❮</span>
|
) : (
|
||||||
</button>
|
<>
|
||||||
|
<div class="image-carousel">
|
||||||
<div class="carousel-images">
|
<button class="nav-button prev-button" aria-label="Previous image">
|
||||||
<div class="carousel-track">
|
<span class="arrow">❮</span>
|
||||||
{images.map((image, index) => (
|
</button>
|
||||||
<div class="carousel-slide" data-index={index}>
|
|
||||||
<img src={image.src} alt={image.alt} class="carousel-image" />
|
<div class="carousel-images">
|
||||||
|
<div class="carousel-track">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<div class="carousel-slide" data-index={index}>
|
||||||
|
<img src={image.src} alt={image.alt} class="carousel-image" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nav-button next-button" aria-label="Next image">
|
||||||
|
<span class="arrow">❯</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel-indicators">
|
||||||
|
{images.map((_, index) => (
|
||||||
|
<button
|
||||||
|
class="indicator-dot"
|
||||||
|
data-index={index}
|
||||||
|
aria-label={`Go to slide ${index + 1}`}
|
||||||
|
></button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
<button class="nav-button next-button" aria-label="Next image">
|
|
||||||
<span class="arrow">❯</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="carousel-indicators">
|
|
||||||
{images.map((_, index) => (
|
|
||||||
<button
|
|
||||||
class="indicator-dot"
|
|
||||||
data-index={index}
|
|
||||||
aria-label={`Go to slide ${index + 1}`}
|
|
||||||
></button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -1,58 +1,46 @@
|
|||||||
---
|
---
|
||||||
// src/components/Welcome.astro
|
// src/components/Welcome.astro
|
||||||
import "../styles/components/Welcome.css"
|
import "../styles/components/Welcome.css"
|
||||||
|
import content from "../content/texts.json";
|
||||||
const { id } = Astro.props;
|
const { id } = Astro.props;
|
||||||
|
const welcome = (content as any).welcome || {};
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={id} class="welcome container">
|
<section id={id} class="welcome container">
|
||||||
|
|
||||||
<div class="welcome-text">
|
<div class="welcome-text">
|
||||||
|
|
||||||
<h2>Herzlich willkommen im</h2>
|
{(welcome.titleLines || ["Herzlich willkommen im","Gallus Pub!"]).map((line: string) => (
|
||||||
<h2>Gallus Pub!</h2>
|
<h2>{line}</h2>
|
||||||
|
))}
|
||||||
|
|
||||||
<p>
|
{(welcome.paragraphs || [
|
||||||
Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung
|
"Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du selbst. Unsere Türen stehen offen für alle, die Spass haben wollen und gute Gesellschaft suchen!"
|
||||||
oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du
|
]).map((p: string) => (
|
||||||
selbst. Unsere Türen stehen offen für alle, die Spass haben wollen
|
<p>{p}</p>
|
||||||
und gute Gesellschaft suchen!
|
))}
|
||||||
</p>
|
|
||||||
|
|
||||||
<p><b>Unsere Highlights:</b></p>
|
<p><b>Unsere Highlights:</b></p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
{(welcome.highlights || [
|
||||||
<b>Karaoke:</b> Von Mittwoch bis Samstag kannst du deine
|
{ title: "Karaoke", text: "Von Mittwoch bis Samstag kannst du deine Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat mieten." },
|
||||||
Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht
|
{ title: "Pub Quiz", text: "Jeden Freitag ab 20:00 Uhr testet ihr euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues Thema und einen neuen Champion." },
|
||||||
nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst
|
{ title: "Getränke", text: "Geniesst frisches Guinness, Smithwicks, Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot." }
|
||||||
gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat
|
]).map((h: any) => (
|
||||||
mieten.
|
<li>
|
||||||
</li>
|
<b>{h.title}:</b> {h.text}
|
||||||
|
</li>
|
||||||
<li>
|
))}
|
||||||
<b>Pub Quiz:</b> Jeden Freitag ab 20:00 Uhr testet ihr
|
|
||||||
euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues
|
|
||||||
Thema und einen neuen Champion.
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<b>Getränke:</b> Geniesst frisches Guinness, Smithwicks,
|
|
||||||
Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber
|
|
||||||
haben wir erlesene Sorten aus Schottland und Irland im Angebot.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>{welcome.closing || "Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael"}</p>
|
||||||
Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst
|
|
||||||
uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="welcome-image">
|
<div class="welcome-image">
|
||||||
<img src="/images/Welcome.png" alt="Welcome background image" />
|
<img src={welcome.image || "/images/Welcome.png"} alt="Welcome background image" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
32
src/content/events.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"image": "/images/Event1.png",
|
||||||
|
"title": "Karaoke auf 2 Etagen",
|
||||||
|
"date": "Mittwoch – Samstag",
|
||||||
|
"description": "Bei uns gibt's Karaoke auf gleich zwei Etagen! Ob Solo, Duett oder ganze Crew – Hauptsache Spass. Den 2. OG kannst du auch privat mieten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/Event2.png",
|
||||||
|
"title": "Pub Quiz",
|
||||||
|
"date": "Jeden Freitag, Start 20:00 Uhr",
|
||||||
|
"description": "Teste dein Wissen in mehreren Runden – jede Woche ein neues Thema und ein neuer Champion. Schnapp dir deine Freunde und macht mit!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/MonthlyHit.png",
|
||||||
|
"title": "Monthly Hit",
|
||||||
|
"date": "Einmal pro Monat",
|
||||||
|
"description": "Unser Special des Monats – wechselnde Highlights, Aktionen und Überraschungen. Folge uns, um das nächste Datum nicht zu verpassen!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/Event3.png",
|
||||||
|
"title": "Live Music & Open Stage",
|
||||||
|
"date": "Regelmässig – Daten auf Socials",
|
||||||
|
"description": "Lokale Künstlerinnen und Künstler live im Gallus Pub. Offene Bühne für alle, die Musik lieben – meldet euch bei uns!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/Event4.png",
|
||||||
|
"title": "Special Nights",
|
||||||
|
"date": "Variiert",
|
||||||
|
"description": "Themenabende, Game Nights, Tastings und mehr. Schaut in der Galerie vorbei oder fragt unser Team nach den nächsten Specials."
|
||||||
|
}
|
||||||
|
]
|
||||||
11
src/content/gallery.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{ "src": "/images/Gallery1.png", "alt": "Galerie Bild 1" },
|
||||||
|
{ "src": "/images/Gallery2.png", "alt": "Galerie Bild 2" },
|
||||||
|
{ "src": "/images/Gallery3.png", "alt": "Galerie Bild 3" },
|
||||||
|
{ "src": "/images/Gallery4.png", "alt": "Galerie Bild 4" },
|
||||||
|
{ "src": "/images/Gallery5.png", "alt": "Galerie Bild 5" },
|
||||||
|
{ "src": "/images/Gallery6.png", "alt": "Galerie Bild 6" },
|
||||||
|
{ "src": "/images/Gallery7.png", "alt": "Galerie Bild 7" },
|
||||||
|
{ "src": "/images/Gallery8.png", "alt": "Galerie Bild 8" },
|
||||||
|
{ "src": "/images/Gallery9.png", "alt": "Galerie Bild 9" }
|
||||||
|
]
|
||||||
27
src/content/texts.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"welcome": {
|
||||||
|
"titleLines": [
|
||||||
|
"Herzlich willkommen im",
|
||||||
|
"Gallus Pub!"
|
||||||
|
],
|
||||||
|
"paragraphs": [
|
||||||
|
"Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du selbst. Unsere Türen stehen offen für alle, die Spass haben wollen und gute Gesellschaft suchen!"
|
||||||
|
],
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"title": "Karaoke",
|
||||||
|
"text": "Von Mittwoch bis Samstag kannst du deine Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat mieten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pub Quiz",
|
||||||
|
"text": "Jeden Freitag ab 20:00 Uhr testet ihr euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues Thema und einen neuen Champion."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Getränke",
|
||||||
|
"text": "Geniesst frisches Guinness, Smithwicks, Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"closing": "Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael",
|
||||||
|
"image": "/images/Welcome.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/lib/csrf.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export function createCsrfToken(): string {
|
||||||
|
const raw = crypto.randomBytes(16).toString('base64url');
|
||||||
|
const sig = sign(raw);
|
||||||
|
return `${raw}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyCsrfToken(token: string | null | undefined): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const [raw, sig] = token.split('.');
|
||||||
|
if (!raw || !sig) return false;
|
||||||
|
const expected = sign(raw);
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(input: string): string {
|
||||||
|
if (!env.CSRF_SECRET) throw new Error('CSRF_SECRET missing');
|
||||||
|
return crypto.createHmac('sha256', env.CSRF_SECRET).update(input).digest('hex');
|
||||||
|
}
|
||||||
29
src/lib/env.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export const env = {
|
||||||
|
OAUTH_PROVIDER: process.env.OAUTH_PROVIDER,
|
||||||
|
OAUTH_CLIENT_ID: process.env.OAUTH_CLIENT_ID,
|
||||||
|
OAUTH_CLIENT_SECRET: process.env.OAUTH_CLIENT_SECRET,
|
||||||
|
OAUTH_AUTHORIZE_URL: process.env.OAUTH_AUTHORIZE_URL,
|
||||||
|
OAUTH_TOKEN_URL: process.env.OAUTH_TOKEN_URL,
|
||||||
|
OAUTH_USERINFO_URL: process.env.OAUTH_USERINFO_URL,
|
||||||
|
OAUTH_ALLOWED_USERS: process.env.OAUTH_ALLOWED_USERS,
|
||||||
|
OAUTH_ALLOWED_ORG: process.env.OAUTH_ALLOWED_ORG,
|
||||||
|
PUBLIC_BASE_URL: process.env.PUBLIC_BASE_URL,
|
||||||
|
GITEA_BASE: process.env.GITEA_BASE,
|
||||||
|
GITEA_OWNER: process.env.GITEA_OWNER,
|
||||||
|
GITEA_REPO: process.env.GITEA_REPO,
|
||||||
|
GITEA_TOKEN: process.env.GITEA_TOKEN,
|
||||||
|
GIT_BRANCH: process.env.GIT_BRANCH || 'main',
|
||||||
|
SESSION_SECRET: process.env.SESSION_SECRET,
|
||||||
|
CSRF_SECRET: process.env.CSRF_SECRET,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBaseUrlFromRequest(req: Request): string {
|
||||||
|
try {
|
||||||
|
if (env.PUBLIC_BASE_URL) return new URL(env.PUBLIC_BASE_URL).toString().replace(/\/$/, '');
|
||||||
|
} catch {}
|
||||||
|
const url = new URL(req.url);
|
||||||
|
url.pathname = '';
|
||||||
|
url.search = '';
|
||||||
|
url.hash = '';
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
87
src/lib/session.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'gp_session';
|
||||||
|
const STATE_COOKIE = 'oauth_state';
|
||||||
|
const CSRF_COOKIE = 'gp_csrf';
|
||||||
|
|
||||||
|
export type SessionData = {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function hmac(value: string) {
|
||||||
|
if (!env.SESSION_SECRET) throw new Error('SESSION_SECRET missing');
|
||||||
|
return crypto.createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSession(data: SessionData): string {
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
const b64 = Buffer.from(json, 'utf-8').toString('base64url');
|
||||||
|
const sig = hmac(b64);
|
||||||
|
return `${b64}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeSession(token?: string | null): SessionData | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const [b64, sig] = parts;
|
||||||
|
const expected = hmac(b64);
|
||||||
|
// timing-safe compare
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(b64, 'base64url').toString('utf-8')) as SessionData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cookieAttrs({ httpOnly = true, maxAge, path = '/', sameSite = 'Lax', secure }: { httpOnly?: boolean; maxAge?: number; path?: string; sameSite?: 'Lax'|'Strict'|'None'; secure?: boolean } = {}) {
|
||||||
|
const attrs = [`Path=${path}`, `SameSite=${sameSite}`];
|
||||||
|
if (httpOnly) attrs.push('HttpOnly');
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
const useSecure = secure ?? isProd;
|
||||||
|
if (useSecure) attrs.push('Secure');
|
||||||
|
if (typeof maxAge === 'number') attrs.push(`Max-Age=${maxAge}`);
|
||||||
|
return attrs.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(data: SessionData, maxAgeDays = 7): string {
|
||||||
|
const token = encodeSession(data);
|
||||||
|
const maxAge = maxAgeDays * 24 * 60 * 60;
|
||||||
|
return `${SESSION_COOKIE}=${token}; ${cookieAttrs({ maxAge })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(): string {
|
||||||
|
return `${SESSION_COOKIE}=; ${cookieAttrs({})}; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionFromRequest(req: Request): SessionData | null {
|
||||||
|
const cookie = parseCookies(req.headers.get('cookie'))[SESSION_COOKIE];
|
||||||
|
return decodeSession(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTempCookie(name: string, value: string, maxAgeSeconds = 600): string {
|
||||||
|
return `${name}=${value}; ${cookieAttrs({ maxAge: maxAgeSeconds })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCookie(name: string): string {
|
||||||
|
return `${name}=; ${cookieAttrs({})}; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCookies(header: string | null | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const [k, ...rest] = part.trim().split('=');
|
||||||
|
out[k] = decodeURIComponent(rest.join('='));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SESSION_COOKIE, STATE_COOKIE, CSRF_COOKIE };
|
||||||
8
src/middleware.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { MiddlewareHandler } from 'astro';
|
||||||
|
import { getSessionFromRequest } from './lib/session';
|
||||||
|
|
||||||
|
export const onRequest: MiddlewareHandler = async (context, next) => {
|
||||||
|
const session = getSessionFromRequest(context.request);
|
||||||
|
(context.locals as any).user = session?.user || null;
|
||||||
|
return next();
|
||||||
|
};
|
||||||
@ -1,291 +0,0 @@
|
|||||||
---
|
|
||||||
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="/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>
|
|
||||||
const api = async (path, opts = {}) => {
|
|
||||||
const res = await fetch(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/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/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/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>
|
|
||||||
111
src/pages/admin/index.astro
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../components/Layout.astro";
|
||||||
|
const user = Astro.locals.user as any;
|
||||||
|
import events from "../../content/events.json";
|
||||||
|
import images from "../../content/gallery.json";
|
||||||
|
import texts from "../../content/texts.json";
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Guard: if not logged in, show login link only -->
|
||||||
|
{!user && (
|
||||||
|
<Layout>
|
||||||
|
<section style="padding:2rem; text-align:center">
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>Bitte mit Gitea anmelden, um Inhalte zu bearbeiten.</p>
|
||||||
|
<p><a class="button" href="/api/auth/login">Mit Gitea anmelden</a></p>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<Layout>
|
||||||
|
<section class="admin" style="padding:2rem;">
|
||||||
|
<h1>Admin Bereich</h1>
|
||||||
|
<p>Eingeloggt als <b>{user.username}</b></p>
|
||||||
|
<form id="editor">
|
||||||
|
<h2>Welcome/Textbausteine (JSON)</h2>
|
||||||
|
<textarea id="texts" style="width:100%;height:240px">{JSON.stringify(texts, null, 2)}</textarea>
|
||||||
|
|
||||||
|
<h2>Events (JSON)</h2>
|
||||||
|
<textarea id="events" style="width:100%;height:220px">{JSON.stringify(events, null, 2)}</textarea>
|
||||||
|
|
||||||
|
<h2>Gallerie (JSON)</h2>
|
||||||
|
<textarea id="gallery" style="width:100%;height:160px">{JSON.stringify(images, null, 2)}</textarea>
|
||||||
|
|
||||||
|
<h2>Bild hochladen</h2>
|
||||||
|
<input id="imageInput" type="file" accept="image/*" multiple />
|
||||||
|
|
||||||
|
<div style="margin-top:1rem; display:flex; gap:1rem;">
|
||||||
|
<button type="button" id="saveBtn">Speichern</button>
|
||||||
|
<button type="button" id="logoutBtn">Logout</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function getCsrf() {
|
||||||
|
const m = document.cookie.match(/(?:^|; )gp_csrf=([^;]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const files = [];
|
||||||
|
try {
|
||||||
|
const textsText = (document.getElementById('texts')).value;
|
||||||
|
const textsJson = JSON.stringify(JSON.parse(textsText), null, 2);
|
||||||
|
files.push({ path: 'src/content/texts.json', content: textsJson, encoding: 'utf8' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Texts JSON ist ungültig: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const eventsText = (document.getElementById('events')).value;
|
||||||
|
const eventsJson = JSON.stringify(JSON.parse(eventsText), null, 2);
|
||||||
|
files.push({ path: 'src/content/events.json', content: eventsJson, encoding: 'utf8' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Events JSON ist ungültig: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const galleryText = (document.getElementById('gallery')).value;
|
||||||
|
const galleryJson = JSON.stringify(JSON.parse(galleryText), null, 2);
|
||||||
|
files.push({ path: 'src/content/gallery.json', content: galleryJson, encoding: 'utf8' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Galerie JSON ist ungültig: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle image uploads
|
||||||
|
const input = document.getElementById('imageInput');
|
||||||
|
const toUpload = Array.from(input.files || []);
|
||||||
|
for (const f of toUpload) {
|
||||||
|
const buf = await f.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||||
|
files.push({ path: `public/images/${f.name}`, content: base64, encoding: 'base64' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF': await getCsrf(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: 'Admin content update', files })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
alert('Fehler beim Speichern: ' + t);
|
||||||
|
} else {
|
||||||
|
alert('Änderungen gespeichert. Build/Deploy wird ausgelöst.');
|
||||||
|
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>
|
||||||
|
)}
|
||||||
83
src/pages/api/auth/callback.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env, getBaseUrlFromRequest } from '../../../lib/env';
|
||||||
|
import { STATE_COOKIE, parseCookies, clearCookie, setSessionCookie, CSRF_COOKIE } from '../../../lib/session';
|
||||||
|
import { createCsrfToken } from '../../../lib/csrf';
|
||||||
|
|
||||||
|
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 cookies = parseCookies(request.headers.get('cookie'));
|
||||||
|
|
||||||
|
if (!code || !state || cookies[STATE_COOKIE] !== state) {
|
||||||
|
return new Response('Invalid OAuth state', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_CLIENT_SECRET || !env.OAUTH_TOKEN_URL || !env.OAUTH_USERINFO_URL) {
|
||||||
|
return new Response('OAuth not fully configured', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${getBaseUrlFromRequest(request)}/api/auth/callback`;
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
let token: string;
|
||||||
|
try {
|
||||||
|
const res = await fetch(env.OAUTH_TOKEN_URL!, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: env.OAUTH_CLIENT_ID!,
|
||||||
|
client_secret: env.OAUTH_CLIENT_SECRET!,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
return new Response(`Token exchange failed: ${res.status} ${txt}`, { status: 500 });
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(async () => {
|
||||||
|
// Some providers may return querystring
|
||||||
|
const text = await res.text();
|
||||||
|
const params = new URLSearchParams(text);
|
||||||
|
return { access_token: params.get('access_token') };
|
||||||
|
});
|
||||||
|
token = data.access_token;
|
||||||
|
if (!token) throw new Error('No access_token');
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response(`Token error: ${e?.message || e}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const userRes = await fetch(env.OAUTH_USERINFO_URL!, {
|
||||||
|
headers: { Authorization: `token ${token}` },
|
||||||
|
});
|
||||||
|
if (!userRes.ok) {
|
||||||
|
const txt = await userRes.text();
|
||||||
|
return new Response(`Userinfo error: ${userRes.status} ${txt}`, { status: 500 });
|
||||||
|
}
|
||||||
|
const user = await userRes.json();
|
||||||
|
|
||||||
|
// Optional allow list
|
||||||
|
if (env.OAUTH_ALLOWED_USERS) {
|
||||||
|
const allowed = env.OAUTH_ALLOWED_USERS.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (!allowed.includes(user?.login || user?.username)) {
|
||||||
|
return new Response('forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session and CSRF
|
||||||
|
const sessionHeader = setSessionCookie({ user: { id: user.id, username: user.login || user.username, email: user.email, displayName: user.full_name || user.name } });
|
||||||
|
const csrf = createCsrfToken();
|
||||||
|
// CSRF cookie should NOT be HttpOnly so frontend can read it and send in header
|
||||||
|
const csrfHeader = `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax${process.env.NODE_ENV === 'production' ? '; Secure' : ''}`;
|
||||||
|
|
||||||
|
const headers = new Headers({ Location: '/admin' });
|
||||||
|
headers.append('Set-Cookie', clearCookie(STATE_COOKIE));
|
||||||
|
headers.append('Set-Cookie', sessionHeader);
|
||||||
|
headers.append('Set-Cookie', csrfHeader);
|
||||||
|
headers.append('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers });
|
||||||
|
};
|
||||||
45
src/pages/api/auth/login.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env, getBaseUrlFromRequest } from '../../../lib/env';
|
||||||
|
import { STATE_COOKIE, setTempCookie, cookieAttrs } from '../../../lib/session';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_AUTHORIZE_URL) {
|
||||||
|
return new Response('OAuth not configured. Set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL.', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = cryptoRandomString();
|
||||||
|
const base = getBaseUrlFromRequest(request);
|
||||||
|
const redirectUri = `${base}/api/auth/callback`;
|
||||||
|
|
||||||
|
let authUrl: URL;
|
||||||
|
try {
|
||||||
|
authUrl = new URL(env.OAUTH_AUTHORIZE_URL!);
|
||||||
|
} catch {
|
||||||
|
return new Response('Invalid OAUTH_AUTHORIZE_URL', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
authUrl.searchParams.set('client_id', env.OAUTH_CLIENT_ID!);
|
||||||
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
|
authUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
const headers = new Headers({ Location: authUrl.toString() });
|
||||||
|
headers.append('Set-Cookie', setTempCookie(STATE_COOKIE, state, 600));
|
||||||
|
// also ensure any previous session cookies aren't cached
|
||||||
|
headers.append('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers });
|
||||||
|
};
|
||||||
|
|
||||||
|
function cryptoRandomString(len = 24) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(len));
|
||||||
|
return Buffer.from(bytes).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure Web Crypto for Node
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
if (!(globalThis as any).crypto?.getRandomValues) {
|
||||||
|
(globalThis as any).crypto = {
|
||||||
|
getRandomValues: (arr: Uint8Array) => (crypto.webcrypto.getRandomValues(arr))
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
9
src/pages/api/auth/logout.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { clearSessionCookie, CSRF_COOKIE, clearCookie } from '../../../lib/session';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async () => {
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.append('Set-Cookie', clearSessionCookie());
|
||||||
|
headers.append('Set-Cookie', clearCookie(CSRF_COOKIE));
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
||||||
|
};
|
||||||
87
src/pages/api/save.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env } from '../../lib/env';
|
||||||
|
import { verifyCsrfToken } from '../../lib/csrf';
|
||||||
|
import { parseCookies } from '../../lib/session';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const FileSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
encoding: z.enum(['utf8', 'base64']).default('utf8'),
|
||||||
|
});
|
||||||
|
const PayloadSchema = z.object({
|
||||||
|
message: z.string().min(1).default('Update content'),
|
||||||
|
files: z.array(FileSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isAllowedPath(p: string): boolean {
|
||||||
|
return (
|
||||||
|
p === 'src/content/events.json' ||
|
||||||
|
p === 'src/content/gallery.json' ||
|
||||||
|
p === 'src/content/texts.json' ||
|
||||||
|
p.startsWith('public/images/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
const user = (locals as any).user;
|
||||||
|
if (!user) return new Response('unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const csrf = request.headers.get('x-csrf');
|
||||||
|
const cookies = parseCookies(request.headers.get('cookie'));
|
||||||
|
if (!verifyCsrfToken(csrf || cookies['gp_csrf'])) {
|
||||||
|
return new Response('bad csrf', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: z.infer<typeof PayloadSchema>;
|
||||||
|
try {
|
||||||
|
const json = await request.json();
|
||||||
|
payload = PayloadSchema.parse(json);
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response('invalid payload: ' + (e?.message || e), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.GITEA_BASE || !env.GITEA_OWNER || !env.GITEA_REPO || !env.GITEA_TOKEN) {
|
||||||
|
return new Response('server not configured for Gitea', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of payload.files) {
|
||||||
|
if (!isAllowedPath(f.path)) {
|
||||||
|
return new Response('path not allowed: ' + f.path, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: any[] = [];
|
||||||
|
for (const f of payload.files) {
|
||||||
|
const url = `${env.GITEA_BASE}/api/v1/repos/${encodeURIComponent(env.GITEA_OWNER!)}/${encodeURIComponent(env.GITEA_REPO!)}/contents/${encodeURIComponent(f.path)}`;
|
||||||
|
const body: any = {
|
||||||
|
content: f.encoding === 'base64' ? f.content : Buffer.from(f.content, 'utf-8').toString('base64'),
|
||||||
|
message: payload.message,
|
||||||
|
branch: env.GIT_BRANCH || 'main',
|
||||||
|
author: {
|
||||||
|
name: user.displayName || user.username,
|
||||||
|
email: user.email || `${user.username}@users.noreply.local`,
|
||||||
|
},
|
||||||
|
committer: {
|
||||||
|
name: user.displayName || user.username,
|
||||||
|
email: user.email || `${user.username}@users.noreply.local`,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${env.GITEA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
return new Response(`Gitea error for ${f.path}: ${res.status} ${text}`, { status: 500 });
|
||||||
|
}
|
||||||
|
results.push(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
};
|
||||||
@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@ -5,82 +5,10 @@ import Welcome from "../components/Welcome.astro";
|
|||||||
import EventsGrid from "../components/EventsGrid.astro";
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
import Drinks from "../components/Drinks.astro";
|
import Drinks from "../components/Drinks.astro";
|
||||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||||
|
import Contact from "../components/Contact.astro";
|
||||||
const events = [
|
import About from "../components/About.astro";
|
||||||
{
|
import events from "../content/events.json";
|
||||||
image: "/images/events/event_karaoke.jpg",
|
import images from "../content/gallery.json";
|
||||||
title: "Karaoke",
|
|
||||||
date: "Mittwoch - Samstag",
|
|
||||||
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>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_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>
|
|
||||||
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
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_schlager-karaoke.jpeg",
|
|
||||||
title: "Schlager Hüttenzauber Karaoke",
|
|
||||||
date: "27. November - 19:00 Uhr",
|
|
||||||
description: `
|
|
||||||
Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_advents-kalender.jpeg",
|
|
||||||
title: "Adventskalender",
|
|
||||||
date: "03. Dezember - 20. Dezember 2025",
|
|
||||||
description: `
|
|
||||||
Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_santa_karaoke.jpeg",
|
|
||||||
title: "Santa Karaoke-Party",
|
|
||||||
date: "06. Dezember 2025",
|
|
||||||
description: `
|
|
||||||
🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻 `,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_ferien.jpeg",
|
|
||||||
title: "Weihnachtsferien",
|
|
||||||
date: "21. Dezember 2025 - 01. Januar 2026",
|
|
||||||
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
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_neujahrs-apero.jpeg",
|
|
||||||
title: "Neujahrs-Apero",
|
|
||||||
date: "02. Januar 2026 - 18:00-20:00 Uhr",
|
|
||||||
description: `
|
|
||||||
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
const images = [
|
|
||||||
{ src: "/images/gallery/Gallery7.png", alt: "Siebtes Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery8.png", alt: "Achtes Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery9.png", alt: "Neuntes Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery6.png", alt: "Sechstes Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery1.png", alt: "Erstes Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery2.png", alt: "Zweites Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery3.png", alt: "Drittes Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery4.png", alt: "Viertes Bild" },
|
|
||||||
{ src: "/images/gallery/Gallery5.png", alt: "Fünftes Bild" },
|
|
||||||
];
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.Drinks {
|
.Drinks {
|
||||||
font-family: var(--font-family-primary);
|
font-family: var(--font-family-primary), serif;
|
||||||
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: 0.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -25,7 +25,6 @@
|
|||||||
.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);
|
||||||
@ -69,6 +68,7 @@
|
|||||||
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: 35vh;
|
height: 9em;
|
||||||
width: 35vh;
|
width: 9em;
|
||||||
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,7 +94,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle:hover {
|
.circle:hover {
|
||||||
@ -110,25 +109,12 @@
|
|||||||
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;
|
||||||
@ -163,6 +149,10 @@
|
|||||||
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: 25rem;
|
width: 400px;
|
||||||
height: 25rem;
|
height: 400px;
|
||||||
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,28 +12,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover effects only for devices that support hover */
|
.hover-card:hover {
|
||||||
@media (hover: hover) and (pointer: fine) {
|
transform: translateY(-5px);
|
||||||
.hover-card:hover {
|
|
||||||
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 {
|
||||||
@ -104,12 +84,11 @@
|
|||||||
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
|
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active state for mobile tap functionality */
|
.hover-card:hover .hover-text {
|
||||||
.hover-card.active .hover-text {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-card.active .card-image {
|
.hover-card:hover .card-image {
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,34 +101,5 @@
|
|||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
74
src/utils/session.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||