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.
This commit is contained in:
2025-11-08 17:02:51 +01:00
parent 1f94bbca15
commit 761ab5d5b5
24 changed files with 1374 additions and 549 deletions

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>
<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" />
{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>
))}
</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>

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

@ -0,0 +1,32 @@
[
{
"image": "/images/Event1.png",
"title": "Karaoke auf 2 Etagen",
"date": "Mittwoch Samstag",
"description": "Bei uns gibt's Karaoke auf gleich zwei Etagen! Ob Solo, Duett oder ganze Crew Hauptsache Spass. Den 2. OG kannst du auch privat mieten."
},
{
"image": "/images/Event2.png",
"title": "Pub Quiz",
"date": "Jeden Freitag, Start 20:00 Uhr",
"description": "Teste dein Wissen in mehreren Runden jede Woche ein neues Thema und ein neuer Champion. Schnapp dir deine Freunde und macht mit!"
},
{
"image": "/images/MonthlyHit.png",
"title": "Monthly Hit",
"date": "Einmal pro Monat",
"description": "Unser Special des Monats wechselnde Highlights, Aktionen und Überraschungen. Folge uns, um das nächste Datum nicht zu verpassen!"
},
{
"image": "/images/Event3.png",
"title": "Live Music & Open Stage",
"date": "Regelmässig Daten auf Socials",
"description": "Lokale Künstlerinnen und Künstler live im Gallus Pub. Offene Bühne für alle, die Musik lieben meldet euch bei uns!"
},
{
"image": "/images/Event4.png",
"title": "Special Nights",
"date": "Variiert",
"description": "Themenabende, Game Nights, Tastings und mehr. Schaut in der Galerie vorbei oder fragt unser Team nach den nächsten Specials."
}
]

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

@ -0,0 +1,11 @@
[
{ "src": "/images/Gallery1.png", "alt": "Galerie Bild 1" },
{ "src": "/images/Gallery2.png", "alt": "Galerie Bild 2" },
{ "src": "/images/Gallery3.png", "alt": "Galerie Bild 3" },
{ "src": "/images/Gallery4.png", "alt": "Galerie Bild 4" },
{ "src": "/images/Gallery5.png", "alt": "Galerie Bild 5" },
{ "src": "/images/Gallery6.png", "alt": "Galerie Bild 6" },
{ "src": "/images/Gallery7.png", "alt": "Galerie Bild 7" },
{ "src": "/images/Gallery8.png", "alt": "Galerie Bild 8" },
{ "src": "/images/Gallery9.png", "alt": "Galerie Bild 9" }
]

27
src/content/texts.json Normal file
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();
};

111
src/pages/admin/index.astro Normal file
View File

@ -0,0 +1,111 @@
---
import Layout from "../../components/Layout.astro";
const user = Astro.locals.user as any;
import events from "../../content/events.json";
import images from "../../content/gallery.json";
import texts from "../../content/texts.json";
---
<!-- Guard: if not logged in, show login link only -->
{!user && (
<Layout>
<section style="padding:2rem; text-align:center">
<h1>Admin Login</h1>
<p>Bitte mit Gitea anmelden, um Inhalte zu bearbeiten.</p>
<p><a class="button" href="/api/auth/login">Mit Gitea anmelden</a></p>
</section>
</Layout>
)}
{user && (
<Layout>
<section class="admin" style="padding:2rem;">
<h1>Admin Bereich</h1>
<p>Eingeloggt als <b>{user.username}</b></p>
<form id="editor">
<h2>Welcome/Textbausteine (JSON)</h2>
<textarea id="texts" style="width:100%;height:240px">{JSON.stringify(texts, null, 2)}</textarea>
<h2>Events (JSON)</h2>
<textarea id="events" style="width:100%;height:220px">{JSON.stringify(events, null, 2)}</textarea>
<h2>Gallerie (JSON)</h2>
<textarea id="gallery" style="width:100%;height:160px">{JSON.stringify(images, null, 2)}</textarea>
<h2>Bild hochladen</h2>
<input id="imageInput" type="file" accept="image/*" multiple />
<div style="margin-top:1rem; display:flex; gap:1rem;">
<button type="button" id="saveBtn">Speichern</button>
<button type="button" id="logoutBtn">Logout</button>
</div>
</form>
</section>
<script>
async function getCsrf() {
const m = document.cookie.match(/(?:^|; )gp_csrf=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
async function save() {
const files = [];
try {
const textsText = (document.getElementById('texts')).value;
const textsJson = JSON.stringify(JSON.parse(textsText), null, 2);
files.push({ path: 'src/content/texts.json', content: textsJson, encoding: 'utf8' });
} catch (e) {
alert('Texts JSON ist ungültig: ' + e.message);
return;
}
try {
const eventsText = (document.getElementById('events')).value;
const eventsJson = JSON.stringify(JSON.parse(eventsText), null, 2);
files.push({ path: 'src/content/events.json', content: eventsJson, encoding: 'utf8' });
} catch (e) {
alert('Events JSON ist ungültig: ' + e.message);
return;
}
try {
const galleryText = (document.getElementById('gallery')).value;
const galleryJson = JSON.stringify(JSON.parse(galleryText), null, 2);
files.push({ path: 'src/content/gallery.json', content: galleryJson, encoding: 'utf8' });
} catch (e) {
alert('Galerie JSON ist ungültig: ' + e.message);
return;
}
// handle image uploads
const input = document.getElementById('imageInput');
const toUpload = Array.from(input.files || []);
for (const f of toUpload) {
const buf = await f.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
files.push({ path: `public/images/${f.name}`, content: base64, encoding: 'base64' });
}
const res = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF': await getCsrf(),
},
body: JSON.stringify({ message: 'Admin content update', files })
});
if (!res.ok) {
const t = await res.text();
alert('Fehler beim Speichern: ' + t);
} else {
alert('Änderungen gespeichert. Build/Deploy wird ausgelöst.');
location.reload();
}
}
document.getElementById('saveBtn').addEventListener('click', save);
document.getElementById('logoutBtn').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
location.href = '/';
});
</script>
</Layout>
)}

View File

@ -0,0 +1,83 @@
import type { APIRoute } from 'astro';
import { env, getBaseUrlFromRequest } from '../../../lib/env';
import { STATE_COOKIE, parseCookies, clearCookie, setSessionCookie, CSRF_COOKIE } from '../../../lib/session';
import { createCsrfToken } from '../../../lib/csrf';
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const cookies = parseCookies(request.headers.get('cookie'));
if (!code || !state || cookies[STATE_COOKIE] !== state) {
return new Response('Invalid OAuth state', { status: 400 });
}
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_CLIENT_SECRET || !env.OAUTH_TOKEN_URL || !env.OAUTH_USERINFO_URL) {
return new Response('OAuth not fully configured', { status: 500 });
}
const redirectUri = `${getBaseUrlFromRequest(request)}/api/auth/callback`;
// Exchange code for token
let token: string;
try {
const res = await fetch(env.OAUTH_TOKEN_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env.OAUTH_CLIENT_ID!,
client_secret: env.OAUTH_CLIENT_SECRET!,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
}),
});
if (!res.ok) {
const txt = await res.text();
return new Response(`Token exchange failed: ${res.status} ${txt}`, { status: 500 });
}
const data = await res.json().catch(async () => {
// Some providers may return querystring
const text = await res.text();
const params = new URLSearchParams(text);
return { access_token: params.get('access_token') };
});
token = data.access_token;
if (!token) throw new Error('No access_token');
} catch (e: any) {
return new Response(`Token error: ${e?.message || e}`, { status: 500 });
}
// Fetch user info
const userRes = await fetch(env.OAUTH_USERINFO_URL!, {
headers: { Authorization: `token ${token}` },
});
if (!userRes.ok) {
const txt = await userRes.text();
return new Response(`Userinfo error: ${userRes.status} ${txt}`, { status: 500 });
}
const user = await userRes.json();
// Optional allow list
if (env.OAUTH_ALLOWED_USERS) {
const allowed = env.OAUTH_ALLOWED_USERS.split(',').map((s) => s.trim()).filter(Boolean);
if (!allowed.includes(user?.login || user?.username)) {
return new Response('forbidden', { status: 403 });
}
}
// Create session and CSRF
const sessionHeader = setSessionCookie({ user: { id: user.id, username: user.login || user.username, email: user.email, displayName: user.full_name || user.name } });
const csrf = createCsrfToken();
// CSRF cookie should NOT be HttpOnly so frontend can read it and send in header
const csrfHeader = `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax${process.env.NODE_ENV === 'production' ? '; Secure' : ''}`;
const headers = new Headers({ Location: '/admin' });
headers.append('Set-Cookie', clearCookie(STATE_COOKIE));
headers.append('Set-Cookie', sessionHeader);
headers.append('Set-Cookie', csrfHeader);
headers.append('Cache-Control', 'no-store');
return new Response(null, { status: 302, headers });
};

View File

@ -0,0 +1,45 @@
import type { APIRoute } from 'astro';
import { env, getBaseUrlFromRequest } from '../../../lib/env';
import { STATE_COOKIE, setTempCookie, cookieAttrs } from '../../../lib/session';
export const GET: APIRoute = async ({ request }) => {
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_AUTHORIZE_URL) {
return new Response('OAuth not configured. Set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL.', { status: 500 });
}
const state = cryptoRandomString();
const base = getBaseUrlFromRequest(request);
const redirectUri = `${base}/api/auth/callback`;
let authUrl: URL;
try {
authUrl = new URL(env.OAUTH_AUTHORIZE_URL!);
} catch {
return new Response('Invalid OAUTH_AUTHORIZE_URL', { status: 500 });
}
authUrl.searchParams.set('client_id', env.OAUTH_CLIENT_ID!);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('state', state);
const headers = new Headers({ Location: authUrl.toString() });
headers.append('Set-Cookie', setTempCookie(STATE_COOKIE, state, 600));
// also ensure any previous session cookies aren't cached
headers.append('Cache-Control', 'no-store');
return new Response(null, { status: 302, headers });
};
function cryptoRandomString(len = 24) {
const bytes = crypto.getRandomValues(new Uint8Array(len));
return Buffer.from(bytes).toString('base64url');
}
// ensure Web Crypto for Node
import crypto from 'node:crypto';
if (!(globalThis as any).crypto?.getRandomValues) {
(globalThis as any).crypto = {
getRandomValues: (arr: Uint8Array) => (crypto.webcrypto.getRandomValues(arr))
} as any;
}

View File

@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { clearSessionCookie, CSRF_COOKIE, clearCookie } from '../../../lib/session';
export const POST: APIRoute = async () => {
const headers = new Headers();
headers.append('Set-Cookie', clearSessionCookie());
headers.append('Set-Cookie', clearCookie(CSRF_COOKIE));
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
};

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

@ -0,0 +1,87 @@
import type { APIRoute } from 'astro';
import { env } from '../../lib/env';
import { verifyCsrfToken } from '../../lib/csrf';
import { parseCookies } from '../../lib/session';
import { z } from 'zod';
const FileSchema = z.object({
path: z.string(),
content: z.string(),
encoding: z.enum(['utf8', 'base64']).default('utf8'),
});
const PayloadSchema = z.object({
message: z.string().min(1).default('Update content'),
files: z.array(FileSchema).min(1),
});
function isAllowedPath(p: string): boolean {
return (
p === 'src/content/events.json' ||
p === 'src/content/gallery.json' ||
p === 'src/content/texts.json' ||
p.startsWith('public/images/')
);
}
export const POST: APIRoute = async ({ request, locals }) => {
const user = (locals as any).user;
if (!user) return new Response('unauthorized', { status: 401 });
const csrf = request.headers.get('x-csrf');
const cookies = parseCookies(request.headers.get('cookie'));
if (!verifyCsrfToken(csrf || cookies['gp_csrf'])) {
return new Response('bad csrf', { status: 403 });
}
let payload: z.infer<typeof PayloadSchema>;
try {
const json = await request.json();
payload = PayloadSchema.parse(json);
} catch (e: any) {
return new Response('invalid payload: ' + (e?.message || e), { status: 400 });
}
if (!env.GITEA_BASE || !env.GITEA_OWNER || !env.GITEA_REPO || !env.GITEA_TOKEN) {
return new Response('server not configured for Gitea', { status: 500 });
}
for (const f of payload.files) {
if (!isAllowedPath(f.path)) {
return new Response('path not allowed: ' + f.path, { status: 400 });
}
}
const results: any[] = [];
for (const f of payload.files) {
const url = `${env.GITEA_BASE}/api/v1/repos/${encodeURIComponent(env.GITEA_OWNER!)}/${encodeURIComponent(env.GITEA_REPO!)}/contents/${encodeURIComponent(f.path)}`;
const body: any = {
content: f.encoding === 'base64' ? f.content : Buffer.from(f.content, 'utf-8').toString('base64'),
message: payload.message,
branch: env.GIT_BRANCH || 'main',
author: {
name: user.displayName || user.username,
email: user.email || `${user.username}@users.noreply.local`,
},
committer: {
name: user.displayName || user.username,
email: user.email || `${user.username}@users.noreply.local`,
}
};
const res = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
return new Response(`Gitea error for ${f.path}: ${res.status} ${text}`, { status: 500 });
}
results.push(await res.json());
}
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};

View File

@ -7,76 +7,8 @@ import Drinks from "../components/Drinks.astro";
import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
const events = [
{
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/Event3.png",
title: "Karaoke tes",
date: "Mittwoch - Samstag",
description: `
`,
},
{
image: "/images/Event2.png",
title: "Karaoke test",
date: "Mittwoch - Samstag",
description: `
`,
},
{
image: "/images/Event1.png",
title: "Crepes Sucette <br /> Live Music im Gallus Pub!",
date: "Do, 04. September 2025",
description: `
<b>ab 19 Uhr gehts los, bis max. 21.30 Uhr</b> <br>
Kosten? CHF 10 pro Spielgast
Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/Event4.png",
title: "Kevin McFlannigan <br> Live Music im Gallus Pub!",
date: "Sa, 27. September 2025",
description: `
<b>ab 20:00 Uhr</b> <br>
Eintritt ist Frei / Hutkollekte <br>
Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
`,
},
];
const images = [
{ src: "/images/Gallery7.png", alt: "Siebtes Bild" },
{ src: "/images/Gallery8.png", alt: "Achtes Bild" },
{ src: "/images/Gallery9.png", alt: "Neuntes Bild" },
{ src: "/images/Gallery6.png", alt: "Sechstes Bild" },
{ src: "/images/Gallery1.png", alt: "Erstes Bild" },
{ src: "/images/Gallery2.png", alt: "Zweites Bild" },
{ src: "/images/Gallery3.png", alt: "Drittes Bild" },
{ src: "/images/Gallery4.png", alt: "Viertes Bild" },
{ src: "/images/Gallery5.png", alt: "Fünftes Bild" },
];
import events from "../content/events.json";
import images from "../content/gallery.json";
---
<Layout>