Compare commits
2 Commits
main-backu
...
48bae59264
| Author | SHA1 | Date | |
|---|---|---|---|
| 48bae59264 | |||
| 761ab5d5b5 |
20
.env.example
20
.env.example
@ -1,31 +1,25 @@
|
|||||||
# Copy this file to .env.local for local development
|
# Local development configuration for OAuth + Gitea
|
||||||
# Then run: npm run dev:local
|
# Copy this file to .env.local and fill in values, then run: npm run dev:local
|
||||||
|
|
||||||
# Public base URL for your local dev server
|
|
||||||
PUBLIC_BASE_URL=http://localhost:4321
|
PUBLIC_BASE_URL=http://localhost:4321
|
||||||
|
|
||||||
# OAuth (Gitea) settings for local development
|
# Gitea OAuth app created with redirect URI: http://localhost:4321/api/auth/callback
|
||||||
# Create an OAuth2 Application in your Gitea with Redirect URI:
|
|
||||||
# http://localhost:4321/api/auth/callback
|
|
||||||
# Then paste the resulting Client ID/Secret below
|
|
||||||
OAUTH_PROVIDER=gitea
|
OAUTH_PROVIDER=gitea
|
||||||
OAUTH_CLIENT_ID=
|
OAUTH_CLIENT_ID=
|
||||||
OAUTH_CLIENT_SECRET=
|
OAUTH_CLIENT_SECRET=
|
||||||
OAUTH_AUTHORIZE_URL=https://git.bookageek.ch/login/oauth/authorize
|
OAUTH_AUTHORIZE_URL=https://git.bookageek.ch/login/oauth/authorize
|
||||||
OAUTH_TOKEN_URL=https://git.bookageek.ch/login/oauth/access_token
|
OAUTH_TOKEN_URL=https://git.bookageek.ch/login/oauth/access_token
|
||||||
OAUTH_USERINFO_URL=https://git.bookageek.ch/api/v1/user
|
OAUTH_USERINFO_URL=https://git.bookageek.ch/api/v1/user
|
||||||
|
# Optional allow-list (comma separated usernames)
|
||||||
|
# OAUTH_ALLOWED_USERS=
|
||||||
|
|
||||||
# Optional access control
|
# Gitea API for commits (service account PAT must have write:repository)
|
||||||
# OAUTH_ALLOWED_USERS=user1,user2
|
|
||||||
# OAUTH_ALLOWED_ORG=your-org
|
|
||||||
|
|
||||||
# Gitea API for committing content changes (service account PAT)
|
|
||||||
GITEA_BASE=https://git.bookageek.ch
|
GITEA_BASE=https://git.bookageek.ch
|
||||||
GITEA_OWNER=
|
GITEA_OWNER=
|
||||||
GITEA_REPO=
|
GITEA_REPO=
|
||||||
GITEA_TOKEN=
|
GITEA_TOKEN=
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
|
|
||||||
# Session and CSRF secrets (use random long strings in .env.local)
|
# Secrets (use long random strings)
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
CSRF_SECRET=
|
CSRF_SECRET=
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@ -1,29 +1,20 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
# Copy built app and minimal runtime files
|
# install minimal deps to run node if needed (alpine already has node)
|
||||||
COPY --from=build /app/dist /app/dist
|
|
||||||
COPY --from=build /app/package*.json /app/
|
|
||||||
|
|
||||||
RUN npm pkg delete devDependencies || true
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|
||||||
# Run Astro server entry (node adapter standalone)
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD ["node", "dist/server/entry.mjs"]
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||||
104
README.md
104
README.md
@ -1,55 +1,65 @@
|
|||||||
# Gallus Pub Website
|
# Gallus Pub Website – Admin mit Gitea OAuth und Git-Commits
|
||||||
|
|
||||||
This is the Gallus Pub website built with Astro. It includes an admin area at `/admin` for editing content (events, gallery, texts). Changes are committed back to the Git repository via the Gitea API which triggers your Woodpecker + Fly.io deployment pipeline.
|
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.
|
||||||
|
|
||||||
## Local development
|
## 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.
|
||||||
|
|
||||||
To run the site locally with OAuth login (Gitea):
|
## Admin & Auth
|
||||||
|
- Admin‑Seite: `https://<domain>/admin` (kein Link im UI, nur direkter Pfad)
|
||||||
1. Copy the example env file and fill values:
|
- Login via Gitea OAuth:
|
||||||
```bash
|
- `/api/auth/login` → Gitea → `/api/auth/callback`
|
||||||
cp .env.example .env.local
|
- Session als HttpOnly‑Cookie, CSRF‑Cookie für POSTs
|
||||||
```
|
- Speichern: `/api/save` validiert und committet die Dateien via Gitea‑API
|
||||||
- Create a Gitea OAuth application with Redirect URI: `http://localhost:4321/api/auth/callback`.
|
|
||||||
- Set `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` from Gitea.
|
|
||||||
- Set `GITEA_OWNER`, `GITEA_REPO`, and a `GITEA_TOKEN` (PAT) with write access to the repo.
|
|
||||||
- Generate random secrets for sessions/CSRF (e.g. `openssl rand -hex 32`).
|
|
||||||
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start dev server using your local env file:
|
|
||||||
```bash
|
|
||||||
npm run dev:local
|
|
||||||
```
|
|
||||||
The site runs at http://localhost:4321. Visit http://localhost:4321/admin to log in via Gitea OAuth.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- If OAuth variables are missing or malformed, the auth endpoints return a clear 500 with guidance instead of crashing.
|
|
||||||
- Production secrets are configured on Fly.io; `.env.local` is ignored by Git.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
├── public/ # static assets
|
|
||||||
├── src/
|
Hinweis: Für lokales HTTP sind Cookies ohne `Secure` gesetzt. In Produktion werden Cookies automatisch als `Secure` markiert.
|
||||||
│ ├── content/ # editable JSON content (events, gallery)
|
|
||||||
│ ├── pages/ # Astro pages, includes /admin and API routes
|
## Produktion (Fly.io)
|
||||||
│ ├── components/ # UI components
|
- Dockerfile baut Astro als SSR und startet `node dist/server/entry.mjs` auf Port 3000.
|
||||||
│ └── utils/ # session helpers
|
- Secrets auf Fly.io setzen (Beispiele, Werte anpassen):
|
||||||
├── .env.example # template for local env
|
|
||||||
├── fly.toml # Fly.io config
|
|
||||||
├── Dockerfile
|
|
||||||
└── 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.
|
||||||
|
|
||||||
## Commands
|
## Wichtige Pfad‑Konvention
|
||||||
|
- Statische Assets immer unter `public/` ablegen (z. B. `public/images/...`).
|
||||||
|
- Die Admin‑Uploads schreiben automatisch nach `public/images/*`.
|
||||||
|
|
||||||
- `npm install` – install deps
|
## Befehle
|
||||||
- `npm run dev` – dev server without loading .env.local (expects env to be present in the shell)
|
- `npm install` – Abhängigkeiten
|
||||||
- `npm run dev:local` – dev server loading `.env.local` via dotenv-cli
|
- `npm run dev` – Standard Dev
|
||||||
- `npm run build` – production build (SSR via @astrojs/node)
|
- `npm run dev:local` – Dev mit `.env.local` (OAuth/Gitea)
|
||||||
- `npm run preview` – preview the production build
|
- `npm run build` – Produktion builden
|
||||||
|
- `npm run preview` – Build lokal testen
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
- Kein PAT im Browser – nur serverseitig in Secrets
|
||||||
|
- CSRF‑Schutz und Pfad‑Allowlist
|
||||||
|
- Optional nutzerbasierte Zulassung: `OAUTH_ALLOWED_USERS` (Komma‑Liste)
|
||||||
|
|||||||
@ -5,5 +5,5 @@ import node from '@astrojs/node';
|
|||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: 'server',
|
||||||
adapter: node({ mode: 'standalone' })
|
adapter: node({ mode: 'standalone' }),
|
||||||
});
|
});
|
||||||
|
|||||||
126
package-lock.json
generated
126
package-lock.json
generated
@ -9,7 +9,11 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.0.0",
|
"@astrojs/node": "^9.0.0",
|
||||||
"astro": "^5.12.8"
|
"astro": "^5.15.4",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv-cli": "^7.4.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@astrojs/compiler": {
|
"node_modules/@astrojs/compiler": {
|
||||||
@ -1953,6 +1957,21 @@
|
|||||||
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
|
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crossws": {
|
"node_modules/crossws": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
|
||||||
@ -2109,6 +2128,45 @@
|
|||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv-cli": {
|
||||||
|
"version": "7.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.4.tgz",
|
||||||
|
"integrity": "sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"dotenv": "^16.3.0",
|
||||||
|
"dotenv-expand": "^10.0.0",
|
||||||
|
"minimist": "^1.2.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"dotenv": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv-expand": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dset": {
|
"node_modules/dset": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
|
||||||
@ -2688,6 +2746,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
@ -3561,6 +3626,16 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@ -3768,6 +3843,16 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -4246,6 +4331,29 @@
|
|||||||
"@img/sharp-win32-x64": "0.34.5"
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shiki": {
|
"node_modules/shiki": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz",
|
||||||
@ -4904,6 +5012,22 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which-pm-runs": {
|
"node_modules/which-pm-runs": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
|
||||||
|
|||||||
@ -10,10 +10,11 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.12.8",
|
"astro": "^5.15.4",
|
||||||
"@astrojs/node": "^9.0.0"
|
"@astrojs/node": "^9.0.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"dotenv-cli": "^7.4.1"
|
"dotenv-cli": "^7.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@ -19,6 +19,9 @@ 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.length === 0 ? (
|
||||||
|
<p style="text-align:center; width:100%; opacity:0.7;">Keine Events vorhanden. Bitte im Admin-Bereich hinzufügen.</p>
|
||||||
|
) : (
|
||||||
events.map((event: Event) => (
|
events.map((event: Event) => (
|
||||||
<HoverCard
|
<HoverCard
|
||||||
title={event.title}
|
title={event.title}
|
||||||
@ -27,5 +30,6 @@ import "../styles/components/EventsGrid.css";
|
|||||||
image={event.image}
|
image={event.image}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -12,6 +12,10 @@ 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>
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<p style="text-align:center; width:100%; opacity:0.7;">Keine Bilder vorhanden. Bitte im Admin-Bereich hinzufügen.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div class="image-carousel">
|
<div class="image-carousel">
|
||||||
<button class="nav-button prev-button" aria-label="Previous image">
|
<button class="nav-button prev-button" aria-label="Previous image">
|
||||||
<span class="arrow">❮</span>
|
<span class="arrow">❮</span>
|
||||||
@ -41,6 +45,8 @@ const { images = [], id } = Astro.props as { images: Image[], id?: string };
|
|||||||
></button>
|
></button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
{(welcome.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." }
|
||||||
|
]).map((h: any) => (
|
||||||
<li>
|
<li>
|
||||||
<b>Karaoke:</b> Von Mittwoch bis Samstag kannst du deine
|
<b>{h.title}:</b> {h.text}
|
||||||
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.
|
|
||||||
</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>
|
</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>
|
||||||
|
|||||||
@ -1,26 +1,32 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"image": "/images/karaoke.jpg",
|
"image": "/images/Event1.png",
|
||||||
"title": "Karaoke",
|
"title": "Karaoke auf 2 Etagen",
|
||||||
"date": "Mittwoch - Samstag",
|
"date": "Mittwoch – Samstag",
|
||||||
"description": "Bei uns gibt es Karaoke Mi-Sa!! <br>\nSeid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>\nReserviere am besten gleich per Whatsapp <a href=\"tel:+41772322770\">077 232 27 70</a>"
|
"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/pub_quiz.jpg",
|
"image": "/images/Event2.png",
|
||||||
"title": "Pub Quiz",
|
"title": "Pub Quiz",
|
||||||
"date": "Jeden Freitag",
|
"date": "Jeden Freitag, Start 20:00 Uhr",
|
||||||
"description": "Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>\nJede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>\nAuch Einzelpersonen sind herzlich willkommen! <br>\n*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF"
|
"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/crepes_sucette.jpg",
|
"image": "/images/MonthlyHit.png",
|
||||||
"title": "Crepes Sucette <br /> Live Music im Gallus Pub!",
|
"title": "Monthly Hit",
|
||||||
"date": "Do, 04. September 2025",
|
"date": "Einmal pro Monat",
|
||||||
"description": "<b>20:00 Uhr</b> <br>\n<a href=\"Metzgergasse 13, 9000 St. Gallen\">Metzgergasse 13, 9000 St. Gallen</a> <br>\nErlebt einen musikalischen Abend mit der Band <b>Crepes Sucette</b> <br>\nJetzt reservieren: <a href=\"tel:+41772322770\">077 232 27 70</a>"
|
"description": "Unser Special des Monats – wechselnde Highlights, Aktionen und Überraschungen. Folge uns, um das nächste Datum nicht zu verpassen!"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"image": "/images/kevin_mcflannigan.jpeg",
|
"image": "/images/Event3.png",
|
||||||
"title": "Kevin McFlannigan <br> Live Music im Gallus Pub!",
|
"title": "Live Music & Open Stage",
|
||||||
"date": "Sa, 27. September 2025",
|
"date": "Regelmässig – Daten auf Socials",
|
||||||
"description": "<b>ab 20:00 Uhr</b> <br>\nSinger & Songwriter Kevin McFlannigan <br>\nEintritt ist Frei / Hutkollekte <br>"
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -1,12 +1,11 @@
|
|||||||
[
|
[
|
||||||
{ "src": "/images/Logo.png", "alt": "Erstes Bild" },
|
{ "src": "/images/Gallery1.png", "alt": "Galerie Bild 1" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Zweites Bild" },
|
{ "src": "/images/Gallery2.png", "alt": "Galerie Bild 2" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Drittes Bild" },
|
{ "src": "/images/Gallery3.png", "alt": "Galerie Bild 3" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Viertes Bild" },
|
{ "src": "/images/Gallery4.png", "alt": "Galerie Bild 4" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Fünftes Bild" },
|
{ "src": "/images/Gallery5.png", "alt": "Galerie Bild 5" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Sechstes Bild" },
|
{ "src": "/images/Gallery6.png", "alt": "Galerie Bild 6" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Siebtes Bild" },
|
{ "src": "/images/Gallery7.png", "alt": "Galerie Bild 7" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Achtes Bild" },
|
{ "src": "/images/Gallery8.png", "alt": "Galerie Bild 8" },
|
||||||
{ "src": "/images/Logo.png", "alt": "Neuntes Bild" },
|
{ "src": "/images/Gallery9.png", "alt": "Galerie Bild 9" }
|
||||||
{ "src": "/images/Logo.png", "alt": "Zehntes Bild" }
|
|
||||||
]
|
]
|
||||||
27
src/content/texts.json
Normal file
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
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
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
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
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,89 +1,105 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../components/Layout.astro";
|
import Layout from "../../components/Layout.astro";
|
||||||
import eventsData from "../../content/events.json";
|
const user = Astro.locals.user as any;
|
||||||
import imagesData from "../../content/gallery.json";
|
import events from "../../content/events.json";
|
||||||
import { getSessionFromRequest } from "../../utils/session";
|
import images from "../../content/gallery.json";
|
||||||
|
import texts from "../../content/texts.json";
|
||||||
const session = getSessionFromRequest(Astro.request);
|
|
||||||
if (!session?.user) {
|
|
||||||
// Not logged in: redirect to OAuth login
|
|
||||||
return Astro.redirect("/api/auth/login");
|
|
||||||
}
|
|
||||||
const csrf = session.csrf;
|
|
||||||
const events = eventsData;
|
|
||||||
const images = imagesData;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<!-- Guard: if not logged in, show login link only -->
|
||||||
<section>
|
{!user && (
|
||||||
<h1>Admin</h1>
|
<Layout>
|
||||||
<p>Eingeloggt als {session.user.login}</p>
|
<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">
|
<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>
|
<h2>Events (JSON)</h2>
|
||||||
<textarea id="events" name="events" rows="16" style="width:100%;font-family:monospace;">{JSON.stringify(events, null, 2)}</textarea>
|
<textarea id="events" style="width:100%;height:220px">{JSON.stringify(events, null, 2)}</textarea>
|
||||||
|
|
||||||
<h2>Galerie (JSON)</h2>
|
<h2>Gallerie (JSON)</h2>
|
||||||
<textarea id="images" name="images" rows="10" style="width:100%;font-family:monospace;">{JSON.stringify(images, null, 2)}</textarea>
|
<textarea id="gallery" style="width:100%;height:160px">{JSON.stringify(images, null, 2)}</textarea>
|
||||||
|
|
||||||
<h2>Bilder hochladen</h2>
|
<h2>Bild hochladen</h2>
|
||||||
<input type="file" id="fileInput" multiple accept="image/*" />
|
<input id="imageInput" type="file" accept="image/*" multiple />
|
||||||
|
|
||||||
<div style="margin-top:1rem;display:flex;gap:.5rem;">
|
<div style="margin-top:1rem; display:flex; gap:1rem;">
|
||||||
<button id="saveBtn" type="button">Speichern</button>
|
<button type="button" id="saveBtn">Speichern</button>
|
||||||
<button id="logoutBtn" type="button">Logout</button>
|
<button type="button" id="logoutBtn">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<meta name="csrf" content={csrf} />
|
<script>
|
||||||
<script type="module">
|
async function getCsrf() {
|
||||||
const csrf = document.querySelector('meta[name="csrf"]').content;
|
const m = document.cookie.match(/(?:^|; )gp_csrf=([^;]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
async function uploadFiles(files){
|
|
||||||
const uploads = [];
|
|
||||||
for (const file of files){
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
|
||||||
uploads.push({ path: `public/images/${file.name}`, content: base64 });
|
|
||||||
}
|
|
||||||
return uploads;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(){
|
async function save() {
|
||||||
let events, images;
|
const files = [];
|
||||||
try{
|
try {
|
||||||
events = JSON.parse(document.getElementById('events').value);
|
const textsText = (document.getElementById('texts')).value;
|
||||||
images = JSON.parse(document.getElementById('images').value);
|
const textsJson = JSON.stringify(JSON.parse(textsText), null, 2);
|
||||||
}catch(e){
|
files.push({ path: 'src/content/texts.json', content: textsJson, encoding: 'utf8' });
|
||||||
alert('JSON fehlerhaft: ' + e.message);
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = [
|
// handle image uploads
|
||||||
{ path: 'src/content/events.json', content: JSON.stringify(events, null, 2) },
|
const input = document.getElementById('imageInput');
|
||||||
{ path: 'src/content/gallery.json', content: JSON.stringify(images, null, 2) },
|
const toUpload = Array.from(input.files || []);
|
||||||
];
|
for (const f of toUpload) {
|
||||||
|
const buf = await f.arrayBuffer();
|
||||||
const input = document.getElementById('fileInput');
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||||
if (input.files && input.files.length){
|
files.push({ path: `public/images/${f.name}`, content: base64, encoding: 'base64' });
|
||||||
const imageFiles = await uploadFiles(input.files);
|
|
||||||
files.push(...imageFiles);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/save', {
|
const res = await fetch('/api/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf },
|
headers: {
|
||||||
body: JSON.stringify({ files, message: 'Admin-Inhalte aktualisiert' })
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF': await getCsrf(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: 'Admin content update', files })
|
||||||
});
|
});
|
||||||
if (!res.ok){
|
if (!res.ok) {
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
alert('Fehler beim Speichern: ' + t);
|
alert('Fehler beim Speichern: ' + t);
|
||||||
return;
|
} else {
|
||||||
}
|
alert('Änderungen gespeichert. Build/Deploy wird ausgelöst.');
|
||||||
alert('Gespeichert! Build wird gestartet.');
|
|
||||||
// optional: Seite neu laden
|
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('saveBtn').addEventListener('click', save);
|
document.getElementById('saveBtn').addEventListener('click', save);
|
||||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||||
@ -91,4 +107,5 @@ const images = imagesData;
|
|||||||
location.href = '/';
|
location.href = '/';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
)}
|
||||||
|
|||||||
@ -1,116 +1,83 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from 'astro';
|
||||||
import { createSessionCookie, sessionCookieHeader, randomToken } from "../../../utils/session";
|
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 }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get('code');
|
||||||
const state = url.searchParams.get("state");
|
const state = url.searchParams.get('state');
|
||||||
const cookie = request.headers.get("cookie") || "";
|
const cookies = parseCookies(request.headers.get('cookie'));
|
||||||
const stateCookie = cookie.match(/(?:^|; )oauth_state=([^;]+)/)?.[1];
|
|
||||||
|
|
||||||
if (!code || !state || !stateCookie || stateCookie !== state) {
|
if (!code || !state || cookies[STATE_COOKIE] !== state) {
|
||||||
return new Response("Invalid OAuth state", { status: 400 });
|
return new Response('Invalid OAuth state', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_CLIENT_SECRET || !env.OAUTH_TOKEN_URL || !env.OAUTH_USERINFO_URL) {
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
return new Response('OAuth not fully configured', { status: 500 });
|
||||||
const tokenUrlRaw = process.env.OAUTH_TOKEN_URL;
|
|
||||||
const userinfoUrl = process.env.OAUTH_USERINFO_URL;
|
|
||||||
if (!clientId || !clientSecret || !tokenUrlRaw || !userinfoUrl) {
|
|
||||||
return new Response("OAuth not fully configured. Please set OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_TOKEN_URL, OAUTH_USERINFO_URL.", { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute redirect_uri consistent with login, robust against invalid PUBLIC_BASE_URL
|
const redirectUri = `${getBaseUrlFromRequest(request)}/api/auth/callback`;
|
||||||
let redirectUri: string;
|
|
||||||
try {
|
|
||||||
if (process.env.PUBLIC_BASE_URL) {
|
|
||||||
const base = new URL(process.env.PUBLIC_BASE_URL);
|
|
||||||
redirectUri = new URL("/api/auth/callback", base).toString();
|
|
||||||
} else {
|
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate token URL
|
|
||||||
let tokenUrl: URL;
|
|
||||||
try {
|
|
||||||
tokenUrl = new URL(tokenUrlRaw);
|
|
||||||
} catch {
|
|
||||||
return new Response("Invalid OAUTH_TOKEN_URL", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange code for token
|
// Exchange code for token
|
||||||
const params = new URLSearchParams();
|
let token: string;
|
||||||
params.set("client_id", clientId);
|
try {
|
||||||
params.set("client_secret", clientSecret);
|
const res = await fetch(env.OAUTH_TOKEN_URL!, {
|
||||||
params.set("code", code);
|
method: 'POST',
|
||||||
params.set("grant_type", "authorization_code");
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
params.set("redirect_uri", redirectUri);
|
body: new URLSearchParams({
|
||||||
|
client_id: env.OAUTH_CLIENT_ID!,
|
||||||
const tokenRes = await fetch(tokenUrl, {
|
client_secret: env.OAUTH_CLIENT_SECRET!,
|
||||||
method: "POST",
|
code,
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
grant_type: 'authorization_code',
|
||||||
body: params.toString(),
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
if (!tokenRes.ok) {
|
const txt = await res.text();
|
||||||
const t = await tokenRes.text();
|
return new Response(`Token exchange failed: ${res.status} ${txt}`, { status: 500 });
|
||||||
return new Response(`OAuth token error: ${tokenRes.status} ${t}`, { 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = await tokenRes.json().catch(async () => {
|
// Fetch user info
|
||||||
// Some Gitea versions return application/x-www-form-urlencoded
|
const userRes = await fetch(env.OAUTH_USERINFO_URL!, {
|
||||||
const text = await tokenRes.text();
|
headers: { Authorization: `token ${token}` },
|
||||||
const usp = new URLSearchParams(text);
|
|
||||||
return Object.fromEntries(usp.entries());
|
|
||||||
});
|
|
||||||
const accessToken = tokenData.access_token || tokenData["access_token"];
|
|
||||||
if (!accessToken) {
|
|
||||||
return new Response("No access token", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRes = await fetch(userinfoUrl, {
|
|
||||||
headers: { Authorization: `token ${accessToken}` },
|
|
||||||
});
|
});
|
||||||
if (!userRes.ok) {
|
if (!userRes.ok) {
|
||||||
const t = await userRes.text();
|
const txt = await userRes.text();
|
||||||
return new Response(`Userinfo error: ${userRes.status} ${t}`, { status: 500 });
|
return new Response(`Userinfo error: ${userRes.status} ${txt}`, { status: 500 });
|
||||||
}
|
}
|
||||||
const user = await userRes.json();
|
const user = await userRes.json();
|
||||||
|
|
||||||
// Optional allowlist
|
// Optional allow list
|
||||||
const allowedUsers = (process.env.OAUTH_ALLOWED_USERS || "").split(/[\,\s]+/).filter(Boolean);
|
if (env.OAUTH_ALLOWED_USERS) {
|
||||||
const allowedOrg = process.env.OAUTH_ALLOWED_ORG;
|
const allowed = env.OAUTH_ALLOWED_USERS.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
if (allowedUsers.length && !allowedUsers.includes(user.login)) {
|
if (!allowed.includes(user?.login || user?.username)) {
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response('forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
if (allowedOrg) {
|
|
||||||
// Best-effort org check
|
|
||||||
try {
|
|
||||||
const orgsRes = await fetch(process.env.GITEA_BASE + "/api/v1/user/orgs", {
|
|
||||||
headers: { Authorization: `token ${accessToken}` },
|
|
||||||
});
|
|
||||||
if (orgsRes.ok) {
|
|
||||||
const orgs = await orgsRes.json();
|
|
||||||
const inOrg = Array.isArray(orgs) && orgs.some((o: any) => o.username === allowedOrg || o.login === allowedOrg || o.name === allowedOrg);
|
|
||||||
if (!inOrg) return new Response("Forbidden (org)", { status: 403 });
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrf = randomToken(16);
|
// Create session and CSRF
|
||||||
const sessionValue = createSessionCookie({
|
const sessionHeader = setSessionCookie({ user: { id: user.id, username: user.login || user.username, email: user.email, displayName: user.full_name || user.name } });
|
||||||
user: { id: user.id, login: user.login, name: user.full_name || user.username || user.login, email: user.email },
|
const csrf = createCsrfToken();
|
||||||
csrf,
|
// 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');
|
||||||
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set("Set-Cookie", sessionCookieHeader(sessionValue));
|
|
||||||
headers.append("Set-Cookie", "oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");
|
|
||||||
headers.set("Location", "/admin");
|
|
||||||
return new Response(null, { status: 302, headers });
|
return new Response(null, { status: 302, headers });
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,53 +1,45 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from 'astro';
|
||||||
import { randomToken, setTempCookie } from "../../../utils/session";
|
import { env, getBaseUrlFromRequest } from '../../../lib/env';
|
||||||
|
import { STATE_COOKIE, setTempCookie, cookieAttrs } from '../../../lib/session';
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_AUTHORIZE_URL) {
|
||||||
const authorizeUrlRaw = process.env.OAUTH_AUTHORIZE_URL;
|
return new Response('OAuth not configured. Set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL.', { status: 500 });
|
||||||
if (!clientId || !authorizeUrlRaw) {
|
|
||||||
return new Response(
|
|
||||||
"OAuth not configured. Please set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL (and related secrets) for local dev.",
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine callback URL
|
const state = cryptoRandomString();
|
||||||
let finalRedirect: string;
|
const base = getBaseUrlFromRequest(request);
|
||||||
|
const redirectUri = `${base}/api/auth/callback`;
|
||||||
|
|
||||||
|
let authUrl: URL;
|
||||||
try {
|
try {
|
||||||
if (process.env.PUBLIC_BASE_URL) {
|
authUrl = new URL(env.OAUTH_AUTHORIZE_URL!);
|
||||||
// Ensure PUBLIC_BASE_URL is an absolute URL
|
|
||||||
const base = new URL(process.env.PUBLIC_BASE_URL);
|
|
||||||
finalRedirect = new URL("/api/auth/callback", base).toString();
|
|
||||||
} else {
|
|
||||||
// Fallback to current request origin
|
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// As a last resort, use request URL
|
return new Response('Invalid OAUTH_AUTHORIZE_URL', { status: 500 });
|
||||||
const reqUrl = new URL(request.url);
|
|
||||||
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate authorize URL
|
authUrl.searchParams.set('client_id', env.OAUTH_CLIENT_ID!);
|
||||||
let authorizeUrl: URL;
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
try {
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
authorizeUrl = new URL(authorizeUrlRaw);
|
authUrl.searchParams.set('state', state);
|
||||||
} catch {
|
|
||||||
return new Response("Invalid OAUTH_AUTHORIZE_URL", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = randomToken(16);
|
const headers = new Headers({ Location: authUrl.toString() });
|
||||||
authorizeUrl.searchParams.set("client_id", clientId);
|
headers.append('Set-Cookie', setTempCookie(STATE_COOKIE, state, 600));
|
||||||
authorizeUrl.searchParams.set("redirect_uri", finalRedirect);
|
// also ensure any previous session cookies aren't cached
|
||||||
authorizeUrl.searchParams.set("response_type", "code");
|
headers.append('Cache-Control', 'no-store');
|
||||||
authorizeUrl.searchParams.set("state", state);
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, { status: 302, headers });
|
||||||
status: 302,
|
|
||||||
headers: {
|
|
||||||
Location: authorizeUrl.toString(),
|
|
||||||
"Set-Cookie": setTempCookie("oauth_state", state),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from 'astro';
|
||||||
import { clearCookieHeader } from "../../../utils/session";
|
import { clearSessionCookie, CSRF_COOKIE, clearCookie } from '../../../lib/session';
|
||||||
|
|
||||||
export const POST: APIRoute = async () => {
|
export const POST: APIRoute = async () => {
|
||||||
return new Response(null, {
|
const headers = new Headers();
|
||||||
status: 204,
|
headers.append('Set-Cookie', clearSessionCookie());
|
||||||
headers: {
|
headers.append('Set-Cookie', clearCookie(CSRF_COOKIE));
|
||||||
"Set-Cookie": clearCookieHeader(),
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,97 +1,87 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from 'astro';
|
||||||
import { getSessionFromRequest } from "../../utils/session";
|
import { env } from '../../lib/env';
|
||||||
|
import { verifyCsrfToken } from '../../lib/csrf';
|
||||||
|
import { parseCookies } from '../../lib/session';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
const GITEA_BASE = process.env.GITEA_BASE!;
|
const FileSchema = z.object({
|
||||||
const GITEA_OWNER = process.env.GITEA_OWNER!;
|
path: z.string(),
|
||||||
const GITEA_REPO = process.env.GITEA_REPO!;
|
content: z.string(),
|
||||||
const GITEA_TOKEN = process.env.GITEA_TOKEN!;
|
encoding: z.enum(['utf8', 'base64']).default('utf8'),
|
||||||
const DEFAULT_BRANCH = process.env.GIT_BRANCH || "main";
|
});
|
||||||
|
const PayloadSchema = z.object({
|
||||||
|
message: z.string().min(1).default('Update content'),
|
||||||
|
files: z.array(FileSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
function isAllowedPath(path: string) {
|
function isAllowedPath(p: string): boolean {
|
||||||
if (path === "src/content/events.json") return true;
|
return (
|
||||||
if (path === "src/content/gallery.json") return true;
|
p === 'src/content/events.json' ||
|
||||||
if (path.startsWith("public/images/")) {
|
p === 'src/content/gallery.json' ||
|
||||||
return /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(path);
|
p === 'src/content/texts.json' ||
|
||||||
}
|
p.startsWith('public/images/')
|
||||||
return false;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getShaIfExists(path: string): Promise<string | undefined> {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}?ref=${encodeURIComponent(DEFAULT_BRANCH)}`;
|
const user = (locals as any).user;
|
||||||
const res = await fetch(url, {
|
if (!user) return new Response('unauthorized', { status: 401 });
|
||||||
headers: { Authorization: `token ${GITEA_TOKEN}` },
|
|
||||||
});
|
|
||||||
if (res.status === 404) return undefined;
|
|
||||||
if (!res.ok) throw new Error(`Gitea get sha error ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return data.sha;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
const csrf = request.headers.get('x-csrf');
|
||||||
const session = getSessionFromRequest(request);
|
const cookies = parseCookies(request.headers.get('cookie'));
|
||||||
if (!session?.user) return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
if (!verifyCsrfToken(csrf || cookies['gp_csrf'])) {
|
||||||
|
return new Response('bad csrf', { status: 403 });
|
||||||
// CSRF header required
|
|
||||||
const csrfHeader = request.headers.get("x-csrf") || request.headers.get("X-CSRF");
|
|
||||||
if (!csrfHeader || csrfHeader !== session.csrf) {
|
|
||||||
return new Response(JSON.stringify({ error: "invalid csrf" }), { status: 403 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: any;
|
let payload: z.infer<typeof PayloadSchema>;
|
||||||
try {
|
try {
|
||||||
payload = await request.json();
|
const json = await request.json();
|
||||||
} catch {
|
payload = PayloadSchema.parse(json);
|
||||||
return new Response(JSON.stringify({ error: "invalid json" }), { status: 400 });
|
} 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 });
|
||||||
}
|
}
|
||||||
if (!payload || !Array.isArray(payload.files)) {
|
|
||||||
return new Response(JSON.stringify({ error: "missing files" }), { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
for (const file of payload.files) {
|
for (const f of payload.files) {
|
||||||
const path = String(file.path || "");
|
const url = `${env.GITEA_BASE}/api/v1/repos/${encodeURIComponent(env.GITEA_OWNER!)}/${encodeURIComponent(env.GITEA_REPO!)}/contents/${encodeURIComponent(f.path)}`;
|
||||||
if (!isAllowedPath(path)) {
|
|
||||||
return new Response(JSON.stringify({ error: `path not allowed: ${path}` }), { status: 400 });
|
|
||||||
}
|
|
||||||
let contentBase64: string;
|
|
||||||
if (path.startsWith("public/images/")) {
|
|
||||||
// Expect already base64 string of binary
|
|
||||||
contentBase64 = String(file.content || "");
|
|
||||||
// Remove possible data URL prefix
|
|
||||||
const match = contentBase64.match(/^data:[^;]+;base64,(.*)$/);
|
|
||||||
if (match) contentBase64 = match[1];
|
|
||||||
} else {
|
|
||||||
// Text file
|
|
||||||
contentBase64 = Buffer.from(String(file.content ?? ""), "utf-8").toString("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha = await getShaIfExists(path).catch(() => undefined);
|
|
||||||
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}`;
|
|
||||||
const body: any = {
|
const body: any = {
|
||||||
content: contentBase64,
|
content: f.encoding === 'base64' ? f.content : Buffer.from(f.content, 'utf-8').toString('base64'),
|
||||||
message: payload.message || `Update ${path}`,
|
message: payload.message,
|
||||||
branch: DEFAULT_BRANCH,
|
branch: env.GIT_BRANCH || 'main',
|
||||||
};
|
author: {
|
||||||
if (sha) body.sha = sha;
|
name: user.displayName || user.username,
|
||||||
if (session.user) {
|
email: user.email || `${user.username}@users.noreply.local`,
|
||||||
body.author = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
|
},
|
||||||
body.committer = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
|
committer: {
|
||||||
|
name: user.displayName || user.username,
|
||||||
|
email: user.email || `${user.username}@users.noreply.local`,
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "PUT",
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${GITEA_TOKEN}`,
|
Authorization: `token ${env.GITEA_TOKEN}`,
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text();
|
const text = await res.text();
|
||||||
return new Response(JSON.stringify({ error: `Gitea error for ${path}: ${res.status} ${t}` }), { status: 500 });
|
return new Response(`Gitea error for ${f.path}: ${res.status} ${text}`, { status: 500 });
|
||||||
}
|
}
|
||||||
results.push(await res.json());
|
results.push(await res.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { "Content-Type": "application/json" } });
|
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
// src/pages/index.astro
|
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Hero from "../components/Hero.astro";
|
import Hero from "../components/Hero.astro";
|
||||||
import Welcome from "../components/Welcome.astro";
|
import Welcome from "../components/Welcome.astro";
|
||||||
@ -8,8 +7,6 @@ import Drinks from "../components/Drinks.astro";
|
|||||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||||
import Contact from "../components/Contact.astro";
|
import Contact from "../components/Contact.astro";
|
||||||
import About from "../components/About.astro";
|
import About from "../components/About.astro";
|
||||||
|
|
||||||
// Inhalte aus Dateien laden (editierbar über Admin)
|
|
||||||
import events from "../content/events.json";
|
import events from "../content/events.json";
|
||||||
import images from "../content/gallery.json";
|
import images from "../content/gallery.json";
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user