26 Commits

Author SHA1 Message Date
cb43b4a7b5 Implement OAuth authentication and admin panel
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Introduced OAuth-based login flow with session management and CSRF protection.
- Added admin panel for managing events and gallery content with real-time editing functionality.
- Integrated Gitea API for saving files and updating repository content.
- Updated `.env.example` to include OAuth and Gitea-related configurations.
- Added example event and gallery JSON files for demonstration.
2025-11-08 16:12:33 +01:00
cbcb17a35c Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
# Conflicts:
#	Dockerfile
#	src/components/Footer.astro
#	src/pages/index.astro
#	src/styles/components/Drinks.css
#	src/styles/components/HoverCard.css
2025-11-08 16:01:56 +01:00
5922d5d274 Update dependencies and enhance package-lock.json
This commit updates multiple dependencies within `package-lock.json`, including upgrades for Astro, @astrojs packages, and various Sharp components. Additionally, it introduces new optional dependencies and enforces compatibility with Node 18 and higher in certain components.
2025-11-08 16:00:00 +01:00
k
96322a4776 Update styles and dependencies, improve Footer and Drinks components layout
- **HoverCard improvements**: Adjusted dimensions and removed unused title styles for cleaner design.
- **Footer layout**: Reorganized structure by repositioning the copyright section and updating its styles for better hierarchy.
- **Drinks component**: Expanded circle dimensions and refined font-family fallback to include `serif`.
- **Dependencies update**: Upgraded `astro` and related packages to their latest versions for security and performance enhancements.
2025-11-08 15:59:53 +01:00
a5bdf7b4f5 Update dependencies and enhance package-lock.json
This commit updates multiple dependencies within `package-lock.json`, including upgrades for Astro, @astrojs packages, and various Sharp components. Additionally, it introduces new optional dependencies and enforces compatibility with Node 18 and higher in certain components.
2025-11-08 15:59:10 +01:00
1f94bbca15 Remove redundant event timing and reservation details from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:58:43 +02:00
5ef15f0b5c Update event time and pricing details in index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:57:29 +02:00
020bfca731 Remove commented file reference from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:47:59 +02:00
ac864ba054 Update event details and assets in index.astro and public/images
- Swapped event titles, dates, and images for better accuracy.
- Replaced `kevin_mcflannigan.png` with `Event4.png`.
- Updated `Menu.pdf`.
2025-10-25 16:29:39 +02:00
e93ba5d29b Remove outdated event details from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-23 11:06:37 +02:00
k
feb137471d Update event details and images for improved content clarity and presentation.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-21 15:31:37 +02:00
k
0622d190d1 Remove comment.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-17 12:14:05 +02:00
k
2867678223 Remove tappable hint text from HoverCard styles. 2025-09-17 12:06:25 +02:00
k
096ac9f789 Update Getränkekarte PDF link to point to Getraenke_Gallus_2025.pdf.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-06 14:29:18 +02:00
k
3006ccd5a0 Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 15:12:38 +02:00
k
8a8bcc304a Enhance HoverCard behavior and styles for better mobile interactivity and accessibility:
- **Hover support refinement**: Limited hover effects to devices with pointer precision and hover capability.
- **Active state improvements**: Added visual feedback for tap and ensured consistent card toggling on mobile, including outside-click handling.
- **Styling additions**: Introduced a tappable hint for better user guidance and refined cursor styles.
- **Script update**: Prevented multiple active cards and ensured seamless closing on external clicks.
2025-08-05 15:12:26 +02:00
k
54c6f205e0 Rename Menu.pdf to Getraenke_Gallus_2025.pdf in the public/pdf directory.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:18:10 +02:00
k
48fddf7b15 Rename Menu.pdf to Getraenke_Gallus_2025.pdf in the public/pdf directory.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:04:09 +02:00
k
2733c2e7f4 Remove redundant whiskey circle styles and update menu PDF.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:01:31 +02:00
k
9502123b89 Remove Kevin McFlannigan details from events and update circle dimensions to use vh for better responsiveness.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 13:47:09 +02:00
ca2d724bd8 Update Drinks.css and index.astro for style adjustments and event details update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Replaced `em` with `rem` in `.circle` dimensions for consistent scaling.
- Revised event descriptions and titles in `index.astro` for clarity.
- Updated `Menu.pdf` file.
2025-08-04 14:17:28 +02:00
k
38229ac5e9 Refine Drinks section text and styling:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Content updates**: Adjusted description text for a more engaging and detailed presentation.
- **Styling changes**: Reduced title margin, enlarged circle dimensions for better visual balance, and added spacing to card links.
- **Layout improvements**: Removed redundant whiskey circle styles for cleaner CSS.
2025-08-02 15:54:09 +02:00
k
a11c838d2a Update gallery images and refine Drinks section:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Gallery updates**: Added `Gallery9.png` and reordered images for better organization.
- **Drinks section tweak**: Removed redundant label text in "Mate Vodka" circle.
2025-08-02 15:43:46 +02:00
k
f9fe914c32 Update images, enhance Drinks section, and adjust styles:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Image updates**: Replaced placeholder images with new gallery images (`Gallery1.png` to `Gallery8.png`) and added whiskey images (`Whiskey1.png` to `Whiskey3.png`).
- **Drinks section**: Added description, updated drink types, and included new images for Whiskey options and Monthly Special.
- **Circular design refinements**: Enlarged circle dimensions, ensured image fit with `object-fit: cover`, and added responsive adjustments.
- **Style improvements**: Introduced overflow handling, z-index management, and tailored sizes for Whiskey circles.
2025-08-02 15:36:14 +02:00
k
21e09f7155 Update images, enhance Drinks section, and adjust styles:
- **Image updates**: Replaced placeholder images with new gallery images (`Gallery1.png` to `Gallery8.png`) and added whiskey images (`Whiskey1.png` to `Whiskey3.png`).
- **Drinks section**: Added description, updated drink types, and included new images for Whiskey options and Monthly Special.
- **Circular design refinements**: Enlarged circle dimensions, ensured image fit with `object-fit: cover`, and added responsive adjustments.
- **Style improvements**: Introduced overflow handling, z-index management, and tailored sizes for Whiskey circles.
2025-08-02 15:32:51 +02:00
k
03671a4d3e Update styles and dependencies, improve Footer and Drinks components layout
- **HoverCard improvements**: Adjusted dimensions and removed unused title styles for cleaner design.
- **Footer layout**: Reorganized structure by repositioning the copyright section and updating its styles for better hierarchy.
- **Drinks component**: Expanded circle dimensions and refined font-family fallback to include `serif`.
- **Dependencies update**: Upgraded `astro` and related packages to their latest versions for security and performance enhancements.
2025-08-02 13:31:48 +02:00
42 changed files with 1177 additions and 542 deletions

31
.env.example Normal file
View File

@ -0,0 +1,31 @@
# Copy this file to .env.local for local development
# Then run: npm run dev:local
# Public base URL for your local dev server
PUBLIC_BASE_URL=http://localhost:4321
# OAuth (Gitea) settings for local development
# Create an OAuth2 Application in your Gitea with Redirect URI:
# http://localhost:4321/api/auth/callback
# Then paste the resulting Client ID/Secret below
OAUTH_PROVIDER=gitea
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
OAUTH_AUTHORIZE_URL=https://git.bookageek.ch/login/oauth/authorize
OAUTH_TOKEN_URL=https://git.bookageek.ch/login/oauth/access_token
OAUTH_USERINFO_URL=https://git.bookageek.ch/api/v1/user
# Optional access control
# OAUTH_ALLOWED_USERS=user1,user2
# OAUTH_ALLOWED_ORG=your-org
# Gitea API for committing content changes (service account PAT)
GITEA_BASE=https://git.bookageek.ch
GITEA_OWNER=
GITEA_REPO=
GITEA_TOKEN=
GIT_BRANCH=main
# Session and CSRF secrets (use random long strings in .env.local)
SESSION_SECRET=
CSRF_SECRET=

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ pnpm-debug.log*
# environment variables
.env
.env.production
.env.local
# macOS-specific files
.DS_Store

View File

@ -1,22 +1,29 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm install
COPY . .
# Ensure CSS variables are present
RUN mkdir -p public/styles
RUN cp -r styles/* public/styles/ || true
RUN npm run build
FROM node:20-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
RUN npm install -g serve
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
EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]
# Run Astro server entry (node adapter standalone)
CMD ["node", "dist/server/entry.mjs"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1

View File

@ -1,47 +1,55 @@
# Astro Starter Kit: Minimal
# Gallus Pub Website
This is the Gallus Pub website built with Astro. It includes an admin area at `/admin` for editing content (events, gallery, texts). Changes are committed back to the Git repository via the Gitea API which triggers your Woodpecker + Fly.io deployment pipeline.
## Local development
To run the site locally with OAuth login (Gitea):
1. Copy the example env file and fill values:
```bash
cp .env.example .env.local
```
- Create a Gitea OAuth application with Redirect URI: `http://localhost:4321/api/auth/callback`.
- Set `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` from Gitea.
- Set `GITEA_OWNER`, `GITEA_REPO`, and a `GITEA_TOKEN` (PAT) with write access to the repo.
- Generate random secrets for sessions/CSRF (e.g. `openssl rand -hex 32`).
2. Install dependencies:
```bash
npm install
```
3. Start dev server using your local env file:
```bash
npm run dev:local
```
The site runs at http://localhost:4321. Visit http://localhost:4321/admin to log in via Gitea OAuth.
Notes:
- If OAuth variables are missing or malformed, the auth endpoints return a clear 500 with guidance instead of crashing.
- Production secrets are configured on Fly.io; `.env.local` is ignored by Git.
## Project structure
```sh
npm create astro@latest -- --template minimal
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── public/ # static assets
├── src/
── pages/
│ └── index.astro
── 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
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
## Commands
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
- `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

View File

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

907
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,19 @@
{
"name": "",
"name": "gallus-pub",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"dev:local": "dotenv -e .env.local -- astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.12.0"
"astro": "^5.12.8",
"@astrojs/node": "^9.0.0"
},
"devDependencies": {
"dotenv-cli": "^7.4.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/images/Event4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
public/images/Gallery1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

BIN
public/images/Gallery2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

BIN
public/images/Gallery3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

BIN
public/images/Gallery4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

BIN
public/images/Gallery5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

BIN
public/images/Gallery6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
public/images/Gallery7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
public/images/Gallery8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
public/images/Gallery9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
public/images/Whiskey1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
public/images/Whiskey2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
public/images/Whiskey3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

Binary file not shown.

View File

@ -6,30 +6,39 @@ const { id } = Astro.props;
<section id={id} class="Drinks">
<h2 class="title">Drinks</h2>
<a href="/pdf/Menu.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<p class="note">
Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein hier kannst du in entspannter
Atmosphäre das Leben genießen. Natürlich dürfen auch Cocktails nicht fehlen. Vieles kreieren wir auch selber - Sláinte!
</p>
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka">
<div class="circle" title="Mate Vodka">
<span class="circle-label">Mate Vodka</span>
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>Mate Vodka</div>
</div>
<div class="circle-row">
<div class="circle" title="Bier">
<span class="circle-label">Bier</span>
</div>
<div class="circle" title="Wein">
<span class="circle-label">Wein</span>
</div>
<div class="circle" title="Cocktails">
<span class="circle-label">Cocktails</span>
</div>
</div>
<p class="note">
Wir bieten eine Auswahl an erlesenen Getränken für jeden Geschmack. Besuche uns und entdecke unsere saisonalen Spezialitäten und Klassiker.
Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.
</p>
<div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="/images/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="/images/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="/images/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
</section>

View File

@ -1,13 +1,11 @@
---
// src/components/Footer.astro
import "../styles/components/Footer.css"
import "/styles/components/Footer.css"
const currentYear = new Date().getFullYear();
---
<footer class="footer" id="footer">
<footer class="footer">
<div class="footer-content">
<div class="footer-sections">
<div class="footer-section">
<h3>Öffnungszeiten</h3>
@ -22,7 +20,7 @@ const currentYear = new Date().getFullYear();
<p>Gallus Pub</p>
<p>Metzgergasse 13</p>
<p>9000 St. Gallen</p>
<p><a href="tel:0772322770">077 232 27 70</a></p>
<p>Email:</p>
<p><a href="mailto:info@gallus-pub.ch">info@gallus-pub.ch</a></p>
</div>
@ -30,9 +28,11 @@ const currentYear = new Date().getFullYear();
<h3>Raumreservationen</h3>
<p>Du planst einen Event?</p>
<p>Der "St.Gallerruum" im 2.OG</p>
<p>kann gemietet werden.</p>
<p>Kann gemietet werden.</p>
<p>Reservierungen via Whatsapp</p>
<p><a href="tel:0772322770">077 232 27 70</a></p>
</div>
</div>
<div class="copyright">
&copy; {currentYear} Gallus Pub. Alle Rechte vorbehalten.

View File

@ -1,5 +1,4 @@
---
// src/components/HoverCard.astro
import "../styles/components/HoverCard.css";
const {title, description, image = "", date} = Astro.props;
---
@ -21,12 +20,29 @@ const {title, description, image = "", date} = Astro.props;
const hoverCards = document.querySelectorAll('.hover-card');
hoverCards.forEach(card => {
card.addEventListener('click', () => {
card.addEventListener('click', (e) => {
// Only toggle on mobile devices
if (window.innerWidth <= 768) {
e.preventDefault();
// Close all other active cards first
hoverCards.forEach(otherCard => {
if (otherCard !== card) {
otherCard.classList.remove('active');
}
});
// Toggle current card
card.classList.toggle('active');
}
});
// Close card when clicking outside (mobile only)
document.addEventListener('click', (e) => {
if (window.innerWidth <= 768 && !card.contains(e.target)) {
card.classList.remove('active');
}
});
});
});
</script>

26
src/content/events.json Normal file
View File

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

12
src/content/gallery.json Normal file
View File

@ -0,0 +1,12 @@
[
{ "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" }
]

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import type { APIRoute } from "astro";
import { clearCookieHeader } from "../../../utils/session";
export const POST: APIRoute = async () => {
return new Response(null, {
status: 204,
headers: {
"Set-Cookie": clearCookieHeader(),
},
});
};

97
src/pages/api/save.ts Normal file
View File

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

View File

@ -9,82 +9,9 @@ import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
const events = [
{
image: "/images/karaoke.jpg",
title: "Karaoke",
date: "Mittwoch - Samstag",
description: `
Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/pub_quiz.jpg",
title: "Pub Quiz",
date: "Jeden Freitag",
description: `
Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
Auch Einzelpersonen sind herzlich willkommen! <br>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
`,
},
{
image: "/images/Event1.png",
title: "Crepes Sucette <br /> Live Music im Gallus Pub!",
date: "Do, 04. September 2025",
description: `
<b>20:00 Uhr</b> <br>
<a href="Metzgergasse 13, 9000 St. Gallen">Metzgergasse 13, 9000 St. Gallen</a> <br>
Erlebt einen musikalischen Abend mit der Band <b>Crepes Sucette</b> <br>
Jetzt reservieren: <a href="tel:+41772322770">077 232 27 70</a>`,
},
{
image: "/images/Event3.png",
title: "Karaoke",
date: "Mittwoch - Samstag",
description: `
Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/Event2.png",
title: "Karaoke",
date: "Mittwoch - Samstag",
description: `
Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/kevin_mcflannigan.png",
title: "Kevin McFlannigan <br> Live Music im Gallus Pub!",
date: "Sa, 27. September 2025",
description: `
<b>ab 20:00 Uhr</b> <br>
Singer & Songwriter Kevin McFlannigan <br>
Eintritt ist Frei / Hutkollekte <br>
`,
},
];
const images = [
{ 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" },
];
// Inhalte aus Dateien laden (editierbar über Admin)
import events from "../content/events.json";
import images from "../content/gallery.json";
---
<Layout>

Binary file not shown.

View File

@ -1,5 +1,5 @@
.Drinks {
font-family: var(--font-family-primary);
font-family: var(--font-family-primary), serif;
display: flex;
flex-direction: column;
align-items: center;

View File

@ -1,7 +1,7 @@
.hover-card {
position: relative;
width: 25rem;
height: 25rem;
width: 400px;
height: 400px;
border-radius: var(--border-radius);
background-color: var(--color-accent-green);
box-shadow: var(--box-shadow);
@ -16,15 +16,6 @@
transform: translateY(-5px);
}
.card-title {
padding: 15px 15px 5px 15px;
margin: 0;
color: var(--color-accent-beige);
font-size: var(--font-size-medium);
text-align: center;
order: -2;
}
.card_date {
padding: 0 15px 15px 15px;
margin: 0;
@ -101,15 +92,6 @@
opacity: 0.1;
}
/* Active state for mobile click functionality */
.hover-card.active .hover-text {
opacity: 1;
}
.hover-card.active .card-image {
opacity: 0.1;
}
.hover-text p {
margin: 0;
padding: 0;
@ -119,8 +101,5 @@
.hover-card {
width: 100%;
max-width: 350px;
/* Maintain square aspect ratio */
aspect-ratio: 1 / 1;
height: auto;
}
}

74
src/utils/session.ts Normal file
View File

@ -0,0 +1,74 @@
import crypto from "node:crypto";
export type SessionData = {
user?: {
id: number;
login: string;
name?: string;
email?: string;
};
csrf?: string;
};
const COOKIE_NAME = "gp_session";
function b64url(input: Buffer | string) {
return Buffer.from(input)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function sign(payload: string, secret: string) {
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
}
export function createSessionCookie(data: SessionData, secret = process.env.SESSION_SECRET || "") {
const payload = b64url(JSON.stringify(data));
const sig = sign(payload, secret);
return `${payload}.${sig}`;
}
export function parseSessionCookie(cookieValue: string | undefined, secret = process.env.SESSION_SECRET || ""): SessionData | undefined {
if (!cookieValue) return undefined;
const [payload, sig] = cookieValue.split(".");
if (!payload || !sig) return undefined;
const expected = sign(payload, secret);
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return undefined;
try {
const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
return JSON.parse(json);
} catch {
return undefined;
}
}
export function clearCookieHeader(name = COOKIE_NAME) {
return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
}
export function sessionCookieHeader(value: string, name = COOKIE_NAME) {
// 7 days
const maxAge = 60 * 60 * 24 * 7;
return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
}
export function getSessionFromRequest(req: Request, secret = process.env.SESSION_SECRET || ""): SessionData | undefined {
const cookie = req.headers.get("cookie") || "";
const match = cookie.match(/(?:^|; )gp_session=([^;]+)/);
if (!match) return undefined;
return parseSessionCookie(match[1], secret);
}
export function randomToken(bytes = 32) {
return crypto.randomBytes(bytes).toString("hex");
}
export const COOKIE_NAME_STATE = "oauth_state";
export function setTempCookie(name: string, value: string) {
// short lived: 10 minutes
const maxAge = 60 * 10;
return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
}