2 Commits

Author SHA1 Message Date
48bae59264 Merge remote-tracking branch 'origin/main'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
# Conflicts:
#	.env.example
#	Dockerfile
#	README.md
#	astro.config.mjs
#	package-lock.json
#	package.json
#	src/content/events.json
#	src/content/gallery.json
#	src/pages/admin/index.astro
#	src/pages/api/auth/callback.ts
#	src/pages/api/auth/login.ts
#	src/pages/api/auth/logout.ts
#	src/pages/api/save.ts
#	src/pages/index.astro
2025-11-08 17:12:07 +01:00
761ab5d5b5 Refactor content structure and add basic authentication utilities
- Moved event and gallery data to JSON files for cleaner content management.
- Added session management utilities with CSRF protection.
- Integrated OAuth-based login and logout APIs.
- Updated dependencies, including Astro and introduced dotenv-cli.
- Enhanced package.json with local environment support.
2025-11-08 17:02:51 +01:00
23 changed files with 731 additions and 471 deletions

View File

@ -1,31 +1,25 @@
# Copy this file to .env.local for local development
# Then run: npm run dev:local
# 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 for your local dev server
PUBLIC_BASE_URL=http://localhost:4321
# OAuth (Gitea) settings for local development
# Create an OAuth2 Application in your Gitea with Redirect URI:
# http://localhost:4321/api/auth/callback
# Then paste the resulting Client ID/Secret below
# 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=
# Optional access control
# OAUTH_ALLOWED_USERS=user1,user2
# OAUTH_ALLOWED_ORG=your-org
# Gitea API for committing content changes (service account PAT)
# 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
# Session and CSRF secrets (use random long strings in .env.local)
# Secrets (use long random strings)
SESSION_SECRET=
CSRF_SECRET=

View File

@ -1,29 +1,20 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
# Copy built app and minimal runtime files
COPY --from=build /app/dist /app/dist
COPY --from=build /app/package*.json /app/
RUN npm pkg delete devDependencies || true
# install minimal deps to run node if needed (alpine already has node)
EXPOSE 3000
CMD ["node", "./dist/server/entry.mjs"]
# Run Astro server entry (node adapter standalone)
CMD ["node", "dist/server/entry.mjs"]
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

104
README.md
View File

@ -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 AstroSeite bereit und enthält eine AdminOberfläche unter `/admin`, mit der Inhalte (Events, Galerie und Bilder) ohne Datenbank gepflegt werden können. Änderungen werden als Commits direkt in das GiteaRepository geschrieben. Woodpecker baut daraufhin und Fly.io deployt.
## Local development
## Inhalte (Headless, Gitbasiert)
- 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):
1. Copy the example env file and fill values:
```bash
cp .env.example .env.local
```
- Create a Gitea OAuth application with Redirect URI: `http://localhost:4321/api/auth/callback`.
- Set `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` from Gitea.
- Set `GITEA_OWNER`, `GITEA_REPO`, and a `GITEA_TOKEN` (PAT) with write access to the repo.
- Generate random secrets for sessions/CSRF (e.g. `openssl rand -hex 32`).
2. Install dependencies:
```bash
npm install
```
3. Start dev server using your local env file:
```bash
npm run dev:local
```
The site runs at http://localhost:4321. Visit http://localhost:4321/admin to log in via Gitea OAuth.
Notes:
- If OAuth variables are missing or malformed, the auth endpoints return a clear 500 with guidance instead of crashing.
- Production secrets are configured on Fly.io; `.env.local` is ignored by Git.
## Project structure
## Admin & Auth
- AdminSeite: `https://<domain>/admin` (kein Link im UI, nur direkter Pfad)
- Login via Gitea OAuth:
- `/api/auth/login` → Gitea → `/api/auth/callback`
- Session als HttpOnlyCookie, CSRFCookie für POSTs
- Speichern: `/api/save` validiert und committet die Dateien via GiteaAPI
## Lokale Entwicklung
1) `.env.example` nach `.env.local` kopieren und ausfüllen (Gitea OAuthApp mit Redirect `http://localhost:4321/api/auth/callback`).
2) Installieren und starten:
```bash
npm install
npm run dev:local
```
/
├── public/ # static assets
├── src/
│ ├── content/ # editable JSON content (events, gallery)
│ ├── pages/ # Astro pages, includes /admin and API routes
│ ├── components/ # UI components
│ └── utils/ # session helpers
├── .env.example # template for local env
├── fly.toml # Fly.io config
├── Dockerfile
└── package.json
3) Browser öffnen: `http://localhost:4321/admin` → Mit Gitea anmelden → Inhalte bearbeiten → Speichern.
Hinweis: Für lokales HTTP sind Cookies ohne `Secure` gesetzt. In Produktion werden Cookies automatisch als `Secure` markiert.
## Produktion (Fly.io)
- Dockerfile baut Astro als SSR und startet `node dist/server/entry.mjs` auf Port 3000.
- Secrets auf Fly.io setzen (Beispiele, Werte anpassen):
```
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 PfadKonvention
- Statische Assets immer unter `public/` ablegen (z.B. `public/images/...`).
- Die AdminUploads schreiben automatisch nach `public/images/*`.
- `npm install` install deps
- `npm run dev` dev server without loading .env.local (expects env to be present in the shell)
- `npm run dev:local` dev server loading `.env.local` via dotenv-cli
- `npm run build` production build (SSR via @astrojs/node)
- `npm run preview` preview the production build
## 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
## Sicherheit
- Kein PAT im Browser nur serverseitig in Secrets
- CSRFSchutz und PfadAllowlist
- Optional nutzerbasierte Zulassung: `OAUTH_ALLOWED_USERS` (KommaListe)

View File

@ -5,5 +5,5 @@ import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' })
adapter: node({ mode: 'standalone' }),
});

126
package-lock.json generated
View File

@ -9,7 +9,11 @@
"version": "0.0.1",
"dependencies": {
"@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": {
@ -1953,6 +1957,21 @@
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
"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": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
@ -2109,6 +2128,45 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"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": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
@ -2688,6 +2746,13 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@ -3561,6 +3626,16 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@ -3768,6 +3843,16 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -4246,6 +4331,29 @@
"@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": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz",
@ -4904,6 +5012,22 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",

View File

@ -10,10 +10,11 @@
"astro": "astro"
},
"dependencies": {
"astro": "^5.12.8",
"@astrojs/node": "^9.0.0"
"astro": "^5.15.4",
"@astrojs/node": "^9.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"dotenv-cli": "^7.4.1"
"dotenv-cli": "^7.4.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@ -19,13 +19,17 @@ import "../styles/components/EventsGrid.css";
<h2 class="section-title">Events</h2>
<section id={id} class="events-gird container">
{
events.map((event: Event) => (
<HoverCard
title={event.title}
date={event.date}
description={event.description}
image={event.image}
/>
))
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) => (
<HoverCard
title={event.title}
date={event.date}
description={event.description}
image={event.image}
/>
))
)
}
</section>

View File

@ -12,35 +12,41 @@ const { images = [], id } = Astro.props as { images: Image[], id?: string };
<section id={id} class="image-carousel-container">
<h2 class="section-title">Galerie</h2>
<div class="image-carousel">
<button class="nav-button prev-button" aria-label="Previous image">
<span class="arrow">&#10094;</span>
</button>
{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">
<button class="nav-button prev-button" aria-label="Previous image">
<span class="arrow">&#10094;</span>
</button>
<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 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>
<button class="nav-button next-button" aria-label="Next image">
<span class="arrow">&#10095;</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>
<button class="nav-button next-button" aria-label="Next image">
<span class="arrow">&#10095;</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>
<script>

View File

@ -1,58 +1,46 @@
---
// src/components/Welcome.astro
import "../styles/components/Welcome.css"
import content from "../content/texts.json";
const { id } = Astro.props;
const welcome = (content as any).welcome || {};
---
<section id={id} class="welcome container">
<div class="welcome-text">
<h2>Herzlich willkommen im</h2>
<h2>Gallus Pub!</h2>
{(welcome.titleLines || ["Herzlich willkommen im","Gallus Pub!"]).map((line: string) => (
<h2>{line}</h2>
))}
<p>
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!
</p>
{(welcome.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!"
]).map((p: string) => (
<p>{p}</p>
))}
<p><b>Unsere Highlights:</b></p>
<ul>
<li>
<b>Karaoke:</b> 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.
</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>
{(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>
<b>{h.title}:</b> {h.text}
</li>
))}
</ul>
<p>
Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst
uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael
</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>
</div>
<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>
</section>

View File

@ -1,26 +1,32 @@
[
{
"image": "/images/karaoke.jpg",
"title": "Karaoke",
"date": "Mittwoch - Samstag",
"description": "Bei uns gibt es Karaoke Mi-Sa!! <br>\nSeid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>\nReserviere am besten gleich per Whatsapp <a href=\"tel:+41772322770\">077 232 27 70</a>"
"image": "/images/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/pub_quiz.jpg",
"image": "/images/Event2.png",
"title": "Pub Quiz",
"date": "Jeden Freitag",
"description": "Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>\nJede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>\nAuch Einzelpersonen sind herzlich willkommen! <br>\n*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF"
"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/crepes_sucette.jpg",
"title": "Crepes Sucette <br /> Live Music im Gallus Pub!",
"date": "Do, 04. September 2025",
"description": "<b>20:00 Uhr</b> <br>\n<a href=\"Metzgergasse 13, 9000 St. Gallen\">Metzgergasse 13, 9000 St. Gallen</a> <br>\nErlebt einen musikalischen Abend mit der Band <b>Crepes Sucette</b> <br>\nJetzt reservieren: <a href=\"tel:+41772322770\">077 232 27 70</a>"
"image": "/images/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/kevin_mcflannigan.jpeg",
"title": "Kevin McFlannigan <br> Live Music im Gallus Pub!",
"date": "Sa, 27. September 2025",
"description": "<b>ab 20:00 Uhr</b> <br>\nSinger & Songwriter Kevin McFlannigan <br>\nEintritt ist Frei / Hutkollekte <br>"
"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."
}
]

View File

@ -1,12 +1,11 @@
[
{ "src": "/images/Logo.png", "alt": "Erstes Bild" },
{ "src": "/images/Logo.png", "alt": "Zweites Bild" },
{ "src": "/images/Logo.png", "alt": "Drittes Bild" },
{ "src": "/images/Logo.png", "alt": "Viertes Bild" },
{ "src": "/images/Logo.png", "alt": "Fünftes Bild" },
{ "src": "/images/Logo.png", "alt": "Sechstes Bild" },
{ "src": "/images/Logo.png", "alt": "Siebtes Bild" },
{ "src": "/images/Logo.png", "alt": "Achtes Bild" },
{ "src": "/images/Logo.png", "alt": "Neuntes Bild" },
{ "src": "/images/Logo.png", "alt": "Zehntes Bild" }
{ "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
View 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
View 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
View 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
View 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
View 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();
};

View File

@ -1,94 +1,111 @@
---
import Layout from "../../components/Layout.astro";
import eventsData from "../../content/events.json";
import imagesData from "../../content/gallery.json";
import { getSessionFromRequest } from "../../utils/session";
const session = getSessionFromRequest(Astro.request);
if (!session?.user) {
// Not logged in: redirect to OAuth login
return Astro.redirect("/api/auth/login");
}
const csrf = session.csrf;
const events = eventsData;
const images = imagesData;
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";
---
<Layout>
<section>
<h1>Admin</h1>
<p>Eingeloggt als {session.user.login}</p>
<form id="editor">
<h2>Events (JSON)</h2>
<textarea id="events" name="events" rows="16" style="width:100%;font-family:monospace;">{JSON.stringify(events, null, 2)}</textarea>
<!-- 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>
)}
<h2>Galerie (JSON)</h2>
<textarea id="images" name="images" rows="10" style="width:100%;font-family:monospace;">{JSON.stringify(images, null, 2)}</textarea>
{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>Bilder hochladen</h2>
<input type="file" id="fileInput" multiple accept="image/*" />
<h2>Events (JSON)</h2>
<textarea id="events" style="width:100%;height:220px">{JSON.stringify(events, null, 2)}</textarea>
<div style="margin-top:1rem;display:flex;gap:.5rem;">
<button id="saveBtn" type="button">Speichern</button>
<button id="logoutBtn" type="button">Logout</button>
</div>
</form>
</section>
<h2>Gallerie (JSON)</h2>
<textarea id="gallery" style="width:100%;height:160px">{JSON.stringify(images, null, 2)}</textarea>
<meta name="csrf" content={csrf} />
<script type="module">
const csrf = document.querySelector('meta[name="csrf"]').content;
<h2>Bild hochladen</h2>
<input id="imageInput" type="file" accept="image/*" multiple />
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;
}
<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>
async function save(){
let events, images;
try{
events = JSON.parse(document.getElementById('events').value);
images = JSON.parse(document.getElementById('images').value);
}catch(e){
alert('JSON fehlerhaft: ' + e.message);
return;
<script>
async function getCsrf() {
const m = document.cookie.match(/(?:^|; )gp_csrf=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
const files = [
{ path: 'src/content/events.json', content: JSON.stringify(events, null, 2) },
{ path: 'src/content/gallery.json', content: JSON.stringify(images, null, 2) },
];
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;
}
const input = document.getElementById('fileInput');
if (input.files && input.files.length){
const imageFiles = await uploadFiles(input.files);
files.push(...imageFiles);
// 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();
}
}
const res = await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf },
body: JSON.stringify({ files, message: 'Admin-Inhalte aktualisiert' })
document.getElementById('saveBtn').addEventListener('click', save);
document.getElementById('logoutBtn').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
location.href = '/';
});
if (!res.ok){
const t = await res.text();
alert('Fehler beim Speichern: ' + t);
return;
}
alert('Gespeichert! Build wird gestartet.');
// optional: Seite neu laden
location.reload();
}
document.getElementById('saveBtn').addEventListener('click', save);
document.getElementById('logoutBtn').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
location.href = '/';
});
</script>
</Layout>
</script>
</Layout>
)}

View File

@ -1,116 +1,83 @@
import type { APIRoute } from "astro";
import { createSessionCookie, sessionCookieHeader, randomToken } from "../../../utils/session";
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 cookie = request.headers.get("cookie") || "";
const stateCookie = cookie.match(/(?:^|; )oauth_state=([^;]+)/)?.[1];
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const cookies = parseCookies(request.headers.get('cookie'));
if (!code || !state || !stateCookie || stateCookie !== state) {
return new Response("Invalid OAuth state", { status: 400 });
if (!code || !state || cookies[STATE_COOKIE] !== state) {
return new Response('Invalid OAuth state', { status: 400 });
}
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
const tokenUrlRaw = process.env.OAUTH_TOKEN_URL;
const userinfoUrl = process.env.OAUTH_USERINFO_URL;
if (!clientId || !clientSecret || !tokenUrlRaw || !userinfoUrl) {
return new Response("OAuth not fully configured. Please set OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_TOKEN_URL, OAUTH_USERINFO_URL.", { status: 500 });
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 });
}
// Compute redirect_uri consistent with login, robust against invalid PUBLIC_BASE_URL
let redirectUri: string;
try {
if (process.env.PUBLIC_BASE_URL) {
const base = new URL(process.env.PUBLIC_BASE_URL);
redirectUri = new URL("/api/auth/callback", base).toString();
} else {
const reqUrl = new URL(request.url);
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
}
} catch {
const reqUrl = new URL(request.url);
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
}
// Validate token URL
let tokenUrl: URL;
try {
tokenUrl = new URL(tokenUrlRaw);
} catch {
return new Response("Invalid OAUTH_TOKEN_URL", { status: 500 });
}
const redirectUri = `${getBaseUrlFromRequest(request)}/api/auth/callback`;
// Exchange code for token
const params = new URLSearchParams();
params.set("client_id", clientId);
params.set("client_secret", clientSecret);
params.set("code", code);
params.set("grant_type", "authorization_code");
params.set("redirect_uri", redirectUri);
const tokenRes = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
if (!tokenRes.ok) {
const t = await tokenRes.text();
return new Response(`OAuth token error: ${tokenRes.status} ${t}`, { status: 500 });
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 });
}
const tokenData = await tokenRes.json().catch(async () => {
// Some Gitea versions return application/x-www-form-urlencoded
const text = await tokenRes.text();
const usp = new URLSearchParams(text);
return Object.fromEntries(usp.entries());
});
const accessToken = tokenData.access_token || tokenData["access_token"];
if (!accessToken) {
return new Response("No access token", { status: 500 });
}
const userRes = await fetch(userinfoUrl, {
headers: { Authorization: `token ${accessToken}` },
// Fetch user info
const userRes = await fetch(env.OAUTH_USERINFO_URL!, {
headers: { Authorization: `token ${token}` },
});
if (!userRes.ok) {
const t = await userRes.text();
return new Response(`Userinfo error: ${userRes.status} ${t}`, { status: 500 });
const txt = await userRes.text();
return new Response(`Userinfo error: ${userRes.status} ${txt}`, { status: 500 });
}
const user = await userRes.json();
// Optional allowlist
const allowedUsers = (process.env.OAUTH_ALLOWED_USERS || "").split(/[\,\s]+/).filter(Boolean);
const allowedOrg = process.env.OAUTH_ALLOWED_ORG;
if (allowedUsers.length && !allowedUsers.includes(user.login)) {
return new Response("Forbidden", { status: 403 });
}
if (allowedOrg) {
// Best-effort org check
try {
const orgsRes = await fetch(process.env.GITEA_BASE + "/api/v1/user/orgs", {
headers: { Authorization: `token ${accessToken}` },
});
if (orgsRes.ok) {
const orgs = await orgsRes.json();
const inOrg = Array.isArray(orgs) && orgs.some((o: any) => o.username === allowedOrg || o.login === allowedOrg || o.name === allowedOrg);
if (!inOrg) return new Response("Forbidden (org)", { status: 403 });
}
} catch {}
// 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 });
}
}
const csrf = randomToken(16);
const sessionValue = createSessionCookie({
user: { id: user.id, login: user.login, name: user.full_name || user.username || user.login, email: user.email },
csrf,
});
// 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');
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 });
};

View File

@ -1,53 +1,45 @@
import type { APIRoute } from "astro";
import { randomToken, setTempCookie } from "../../../utils/session";
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 }) => {
const clientId = process.env.OAUTH_CLIENT_ID;
const authorizeUrlRaw = process.env.OAUTH_AUTHORIZE_URL;
if (!clientId || !authorizeUrlRaw) {
return new Response(
"OAuth not configured. Please set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL (and related secrets) for local dev.",
{ status: 500 }
);
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 });
}
// Determine callback URL
let finalRedirect: string;
const state = cryptoRandomString();
const base = getBaseUrlFromRequest(request);
const redirectUri = `${base}/api/auth/callback`;
let authUrl: URL;
try {
if (process.env.PUBLIC_BASE_URL) {
// Ensure PUBLIC_BASE_URL is an absolute URL
const base = new URL(process.env.PUBLIC_BASE_URL);
finalRedirect = new URL("/api/auth/callback", base).toString();
} else {
// Fallback to current request origin
const reqUrl = new URL(request.url);
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
}
authUrl = new URL(env.OAUTH_AUTHORIZE_URL!);
} catch {
// As a last resort, use request URL
const reqUrl = new URL(request.url);
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
return new Response('Invalid OAUTH_AUTHORIZE_URL', { status: 500 });
}
// Validate authorize URL
let authorizeUrl: URL;
try {
authorizeUrl = new URL(authorizeUrlRaw);
} 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 state = randomToken(16);
authorizeUrl.searchParams.set("client_id", clientId);
authorizeUrl.searchParams.set("redirect_uri", finalRedirect);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("state", state);
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: {
Location: authorizeUrl.toString(),
"Set-Cookie": setTempCookie("oauth_state", state),
},
});
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;
}

View File

@ -1,11 +1,9 @@
import type { APIRoute } from "astro";
import { clearCookieHeader } from "../../../utils/session";
import type { APIRoute } from 'astro';
import { clearSessionCookie, CSRF_COOKIE, clearCookie } from '../../../lib/session';
export const POST: APIRoute = async () => {
return new Response(null, {
status: 204,
headers: {
"Set-Cookie": clearCookieHeader(),
},
});
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 });
};

View File

@ -1,97 +1,87 @@
import type { APIRoute } from "astro";
import { getSessionFromRequest } from "../../utils/session";
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 GITEA_BASE = process.env.GITEA_BASE!;
const GITEA_OWNER = process.env.GITEA_OWNER!;
const GITEA_REPO = process.env.GITEA_REPO!;
const GITEA_TOKEN = process.env.GITEA_TOKEN!;
const DEFAULT_BRANCH = process.env.GIT_BRANCH || "main";
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(path: string) {
if (path === "src/content/events.json") return true;
if (path === "src/content/gallery.json") return true;
if (path.startsWith("public/images/")) {
return /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(path);
}
return false;
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/')
);
}
async function getShaIfExists(path: string): Promise<string | undefined> {
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}?ref=${encodeURIComponent(DEFAULT_BRANCH)}`;
const res = await fetch(url, {
headers: { Authorization: `token ${GITEA_TOKEN}` },
});
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`Gitea get sha error ${res.status}`);
const data = await res.json();
return data.sha;
}
export const POST: APIRoute = async ({ request, locals }) => {
const user = (locals as any).user;
if (!user) return new Response('unauthorized', { status: 401 });
export const POST: APIRoute = async ({ request }) => {
const session = getSessionFromRequest(request);
if (!session?.user) return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
// CSRF header required
const csrfHeader = request.headers.get("x-csrf") || request.headers.get("X-CSRF");
if (!csrfHeader || csrfHeader !== session.csrf) {
return new Response(JSON.stringify({ error: "invalid csrf" }), { status: 403 });
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: any;
let payload: z.infer<typeof PayloadSchema>;
try {
payload = await request.json();
} catch {
return new Response(JSON.stringify({ error: "invalid json" }), { status: 400 });
const json = await request.json();
payload = PayloadSchema.parse(json);
} catch (e: any) {
return new Response('invalid payload: ' + (e?.message || e), { status: 400 });
}
if (!payload || !Array.isArray(payload.files)) {
return new Response(JSON.stringify({ error: "missing files" }), { 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 file of payload.files) {
const path = String(file.path || "");
if (!isAllowedPath(path)) {
return new Response(JSON.stringify({ error: `path not allowed: ${path}` }), { status: 400 });
}
let contentBase64: string;
if (path.startsWith("public/images/")) {
// Expect already base64 string of binary
contentBase64 = String(file.content || "");
// Remove possible data URL prefix
const match = contentBase64.match(/^data:[^;]+;base64,(.*)$/);
if (match) contentBase64 = match[1];
} else {
// Text file
contentBase64 = Buffer.from(String(file.content ?? ""), "utf-8").toString("base64");
}
const sha = await getShaIfExists(path).catch(() => undefined);
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}`;
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: contentBase64,
message: payload.message || `Update ${path}`,
branch: DEFAULT_BRANCH,
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`,
}
};
if (sha) body.sha = sha;
if (session.user) {
body.author = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
body.committer = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
}
const res = await fetch(url, {
method: "PUT",
method: 'PUT',
headers: {
Authorization: `token ${GITEA_TOKEN}`,
"Content-Type": "application/json",
Authorization: `token ${env.GITEA_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const t = await res.text();
return new Response(JSON.stringify({ error: `Gitea error for ${path}: ${res.status} ${t}` }), { status: 500 });
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" } });
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};

View File

@ -1,5 +1,4 @@
---
// src/pages/index.astro
import Layout from "../components/Layout.astro";
import Hero from "../components/Hero.astro";
import Welcome from "../components/Welcome.astro";
@ -8,8 +7,6 @@ import Drinks from "../components/Drinks.astro";
import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
// Inhalte aus Dateien laden (editierbar über Admin)
import events from "../content/events.json";
import images from "../content/gallery.json";
---