Gallus Pub: Ein modernes Headless CMS für Gastronomie-Websites
Ein vollständiges Headless CMS mit Git-basiertem Publishing-Workflow, OAuth2-Authentifizierung und dynamischer Content-Generierung für eine Irish Pub Website.
Gallus Pub: Ein modernes Headless CMS für Gastronomie-Websites
Projektübersicht
Gallus Pub ist eine vollständige Web-Anwendung für einen Irish Pub, bestehend aus einer statisch generierten öffentlichen Website und einem leistungsstarken Admin-Portal zur Content-Verwaltung. Das Projekt demonstriert moderne Webentwicklungspraktiken mit einem Headless CMS-Ansatz, der Git-basierte Publishing-Workflows, OAuth2-Authentifizierung und dynamische Content-Generierung vereint.
Live-Demo:
- Public Website:
https://gallus-pub.ch - CMS Backend:
https://cms.gallus-pub.ch
Projektart: Full-Stack Web-Anwendung mit Headless CMS
Inhaltsverzeichnis
- Technologie-Stack
- Architektur-Übersicht
- Das Admin-Portal
- Backend-Architektur
- Frontend-Architektur
- Besondere Features
- Deployment & Infrastructure
- Lessons Learned & Highlights
Technologie-Stack
Backend-Technologien
Runtime & Framework:
- Node.js mit TypeScript für typsichere Server-Entwicklung
- Fastify 4.26 als High-Performance Web-Framework (schneller als Express)
- tsx für TypeScript-Entwicklung, tsc für Production Builds
Datenbank & ORM:
- SQLite mit better-sqlite3 für eingebettete, dateibasierte Persistenz
- Drizzle ORM für typsichere Datenbankoperationen
- WAL-Modus für verbesserte Concurrent-Access-Performance
Authentifizierung & Sicherheit:
- Gitea OAuth2 für sichere Benutzer-Authentifizierung
- @fastify/jwt für JSON Web Token-basierte Sessions
- HttpOnly Cookies mit SameSite-Protection gegen CSRF
- Benutzer-Allowlist für eingeschränkten Admin-Zugriff
- State-basierte CSRF-Protektion im OAuth-Flow
Image Processing:
- Sharp für High-Performance-Bildoptimierung
- Automatisches EXIF-basiertes Rotieren
- Responsive Bildgrößen (max. 1600px Breite)
- WebP-Konvertierung mit 82% Qualität
- Fallback auf Originalformat bei Konvertierungsfehlern
Git-Integration:
- simple-git für programmatische Git-Operationen
- Automatisches Clone, Add, Commit, Push zu Gitea
- Reset-on-Error für sichere Publish-Workflows
Weitere Backend-Dependencies:
- @fastify/cors für Cross-Origin-Requests
- @fastify/multipart für File-Uploads (5MB Limit)
- Zod für Runtime-Validierung und Type-Safety
- dotenv für Environment-Variable-Management
Frontend-Technologien
Framework & Build-Tools:
- Astro 5.12 als Static Site Generator
- Component-basierte Architektur
- Zero-JS by Default (nur wo nötig)
- TypeScript-Support
Styling:
- Pure CSS mit CSS Custom Properties (CSS Variables)
- Component-Scoped Styles
- Responsive Design mit Mobile-First-Ansatz
- Keine CSS-Framework-Abhängigkeiten (maßgeschneiderte Lösung)
JavaScript:
- Vanilla JavaScript für interaktive Komponenten
- Drag-and-Drop API für Content-Reordering
- Fetch API für Backend-Kommunikation
- Client-Side State-Management für Carousel und Admin-UI
Infrastructure & DevOps
Hosting & Deployment:
- Fly.io für Backend und Frontend (separate Apps)
- Docker & Docker Compose für lokale Entwicklung
- Caddy als Reverse Proxy (Development)
- Fly Volumes für persistente Datenbank- und Git-Workspace-Storage
CI/CD:
- Woodpecker CI für automatisierte Build-Pipelines
- Git-basierte Deployment-Trigger
- Automatische Static-Site-Regeneration nach Publish
Version Control:
- Git mit Gitea als Self-Hosted Git-Server
- OAuth-Integration für Authentifizierung
- Commit-basierte Publishing-History
Architektur-Übersicht
Headless CMS-Ansatz
Die Anwendung folgt einem modernen Headless CMS-Architekturmuster:
┌─────────────────────────────────────────────────────────────┐
│ Benutzer-Workflow │
└─────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Admin Portal (admin.astro) │
│ - Events verwalten │
│ - Gallery bearbeiten │
│ - Banner erstellen │
│ - Content-Sections anpassen │
└────────────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ Backend API (Fastify) │
│ cms.gallus-pub.ch/api │
│ │
│ - SQLite-Datenbank │
│ - Image Processing │
│ - OAuth2 Auth │
└───────────────────────────────┘
│
(Publish Button)
▼
┌────────────────────────────────────┐
│ File Generation Service │
│ - Generiere index.astro │
│ - Generiere Hero.astro │
│ - Generiere Welcome.astro │
│ - Generiere Drinks.astro │
└────────────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ Git Service (simple-git) │
│ - Add files │
│ - Commit mit Message │
│ - Push zu Gitea │
└───────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Woodpecker CI │
│ - Build Astro Site │
│ - Deploy zu Fly.io │
└─────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ Public Website │
│ gallus-pub.ch │
│ - Static HTML │
│ - Optimierte Bilder │
│ - Schnelle Ladezeiten │
└───────────────────────────────┘
Warum Headless CMS?
Vorteile dieser Architektur:
- Sicherheit: Die öffentliche Website ist vollständig statisch - keine Datenbank-Angriffsfläche
- Performance: Pre-rendered HTML mit CDN-Auslieferung bedeutet extrem schnelle Ladezeiten
- Versionierung: Alle Änderungen sind Git-Commits - vollständige Audit-Trail und Rollback-Möglichkeit
- Skalierbarkeit: Static Assets können einfach gecacht und über CDN ausgeliefert werden
- Entwickler-Experience: Moderne TypeScript-basierte Tools für Frontend und Backend
Das Admin-Portal
Das Admin-Portal (src/pages/admin.astro) ist das Herzstück des CMS und bietet eine intuitive, single-page Benutzeroberfläche für alle Content-Management-Aufgaben.
1. Authentifizierung
OAuth2-Integration mit Gitea:
Das Portal verwendet einen sicheren OAuth2-Flow mit einem selbst gehosteten Gitea-Server:
Ablauf:
1. User klickt "Mit Gitea anmelden"
2. Redirect zu Gitea OAuth-Autorisierungsseite
3. User genehmigt Zugriff
4. Gitea leitet zurück mit Authorization Code
5. Backend tauscht Code gegen Access Token
6. Backend holt User-Info von Gitea API
7. Prüfung gegen GITEA_ALLOWED_USERS Allowlist
8. JWT-Token wird generiert und als HttpOnly Cookie gesetzt
9. User wird zu Admin-Portal weitergeleitet
Sicherheitsfeatures:
- State-basierte CSRF-Protektion: Zufälliger State-Token verhindert Request-Forgery
- HttpOnly Cookies: JWT nicht per JavaScript auslesbar (XSS-Schutz)
- SameSite Cookie-Attribute: Cross-Site-Request-Schutz
- 24-Stunden Token-Expiry: Automatisches Session-Timeout
- User Allowlist: Nur explizit erlaubte Gitea-User erhalten Zugriff
UI-Features:
- Login/Logout-Buttons mit Status-Anzeige
- Anzeige des aktuellen Gitea-Benutzernamens
- Re-Login-Funktion bei abgelaufenen Sessions
2. Event-Management
Kernfunktionalitäten:
Events sind das zentrale Content-Element der Pub-Website (Konzerte, Quiz-Abende, Sport-Events, etc.)
Create Event:
- Titel (z.B., "St. Patrick's Day Feier")
- Datum (YYYY-MM-DD Format)
- Beschreibung (Markdown-Support)
- Bild-Upload (Drag & Drop oder File Picker)
→ Automatische Optimierung zu WebP
→ Resize auf max. 1600px Breite
→ EXIF-basiertes Auto-Rotate
Event-Liste:
- Grid-Ansicht mit Thumbnails
- Standard-Sortierung: Neueste Events zuerst (nach Datum)
- Jeder Event-Eintrag zeigt:
- Thumbnail
- Titel und Datum
- Beschreibung (gekürzt)
- Delete-Button
Reorder-Modus:
- Toggle zwischen "Normal" und "Reorder" Mode
- Im Reorder-Modus:
- Drag-and-Drop zum Umsortieren
- Visuelle Drag-Indicators
- Batch-Update per API (
PUT /api/events/reorder) - Display-Order wird in Datenbank persistiert
Technische Details:
// Drag-and-Drop Implementation (Vanilla JS)
element.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
draggedElement = element;
});
element.addEventListener('dragover', (e) => {
e.preventDefault();
// Visuelles Feedback während Drag
});
element.addEventListener('drop', async (e) => {
e.preventDefault();
// Neuordnung der DOM-Elemente
// API-Call mit neuer Order
await fetch('/api/events/reorder', {
method: 'PUT',
body: JSON.stringify(newOrder),
credentials: 'include'
});
});
3. Gallery-Management
Ähnliche Features wie Event-Management:
Die Gallery verwaltet Bilder für einen Carousel auf der Homepage.
Upload-Workflow:
- Bild auswählen (Drag & Drop oder File Picker)
- Alt-Text eingeben (Accessibility)
- Upload zu
/api/gallery/upload - Automatische Optimierung mit Sharp
- Display in Gallery-Grid
Reordering:
- Identisches Drag-and-Drop-System wie bei Events
- Batch-Update über
/api/gallery/reorder - Display-Order bestimmt Carousel-Reihenfolge
Delete-Funktion:
- Löscht sowohl DB-Eintrag als auch physische Datei
- Confirmation-Dialog (Browser-native)
4. Banner-Management
Banners sind zeitlich begrenzte Ankündigungen (z.B., "Happy Hour heute 17-19 Uhr" oder "Geschlossen am 25. Dezember").
Banner erstellen:
- Text (z.B., "🎉 50% auf alle Cocktails von 17-19 Uhr!")
- Start-Datum (YYYY-MM-DD)
- End-Datum (YYYY-MM-DD)
- Active-Toggle (manuelles Ein-/Ausschalten)
Smart Display Logic:
- Backend prüft automatisch, ob aktuelles Datum innerhalb Start/End liegt
- Public Endpoint:
/api/banners/active - Nur aktive Banner innerhalb des Datumsfensters werden angezeigt
- Frontend-Komponente
Banner.astrofetcht aktiven Banner client-side
Banner-Liste im Admin:
- Alle Banner mit Status-Anzeige
- Edit und Delete für jedes Banner
- Sortiert nach Creation-Date
5. Content-Sections (Hero, Welcome, Drinks)
Dynamische Content-Generierung:
Diese Sections werden nicht direkt bearbeitet, sondern sind im Backend als JSON-Objekte in der contentSections-Tabelle gespeichert. Das Admin-Portal könnte künftig einen visuellen Editor dafür bieten.
Aktuell im Datenbank-Schema:
// Hero Section
{
"sectionName": "hero",
"contentJson": {
"heading1": "Willkommen im",
"heading2": "Gallus Pub",
"backgroundImage": "/images/hero-bg.jpg"
}
}
// Welcome Section
{
"sectionName": "welcome",
"contentJson": {
"heading": "Dein Irish Pub im Herzen von St. Gallen",
"paragraph": "Erlebe authentische irische Gastfreundschaft...",
"highlights": [
"Live-Musik jeden Freitag",
"Über 30 Whiskey-Sorten",
"Guinness vom Fass"
],
"backgroundImage": "/images/welcome-bg.jpg"
}
}
// Drinks Section
{
"sectionName": "drinks",
"contentJson": {
"heading": "Unsere Getränke",
"monthlySpecial": "Jameson Caskmates",
"featuredWhiskeys": [
{
"name": "Tullamore Dew",
"image": "/images/tullamore.jpg"
},
// ...
],
"menuPdfUrl": "/pdf/drinks-menu.pdf"
}
}
File-Generation-Process:
Beim Publish generiert der FileGeneratorService Astro-Komponenten aus diesem JSON:
// Beispiel: Hero.astro wird generiert
const heroContent = await contentService.getSection('hero');
const heroTemplate = `
---
const { heading1, heading2, backgroundImage } = ${JSON.stringify(heroContent)};
---
<section class="hero" style="background-image: url({backgroundImage})">
<h1>{heading1}</h1>
<h2>{heading2}</h2>
</section>
`;
await fs.writeFile('src/components/Hero.astro', heroTemplate);
6. Publishing-System
Der Publish-Workflow:
Dies ist das innovativste Feature des CMS - ein Ein-Klick-Deployment mit vollständiger Git-Integration.
UI:
- Commit-Message-Eingabefeld
- "Publish All Changes"-Button
- Letzte 20 Publishes mit Details (User, Zeit, Commit-Hash, Message)
Backend-Workflow bei Publish:
// Pseudo-Code des Publish-Prozesses
async function publishChanges(commitMessage: string, userId: string) {
try {
// 1. Repository Pull (neueste Änderungen)
await gitService.pullRepository();
// 2. Generiere index.astro mit Events & Gallery
const events = await eventsService.getAllPublished();
const gallery = await galleryService.getAllPublished();
await fileGenerator.generateIndexAstro(events, gallery);
// 3. Generiere Component-Files
const heroContent = await contentService.getSection('hero');
await fileGenerator.generateHeroComponent(heroContent);
const welcomeContent = await contentService.getSection('welcome');
await fileGenerator.generateWelcomeComponent(welcomeContent);
const drinksContent = await contentService.getSection('drinks');
await fileGenerator.generateDrinksComponent(drinksContent);
// 4. Git Add
await gitService.addAll();
// 5. Git Commit
const commitHash = await gitService.commit(commitMessage);
// 6. Git Push zu Gitea
await gitService.push();
// 7. Record in Publish-History
await publishHistoryService.create({
userId,
commitHash,
commitMessage,
publishedAt: new Date()
});
return { success: true, commitHash };
} catch (error) {
// Error Recovery: Git Reset
await gitService.reset();
throw error;
}
}
Vorteile dieses Ansatzes:
- Audit Trail: Jede Änderung ist ein Git-Commit mit User-Attribution
- Rollback-Möglichkeit: Bei Problemen kann zu früheren Commits zurückgekehrt werden
- CI/CD-Trigger: Woodpecker CI erkennt Push und deployt automatisch
- Version Control: Alle Content-Änderungen sind versioniert
- Collaboration-Ready: Mehrere Admins können gleichzeitig arbeiten (Git Merge)
Admin-Portal UI/UX-Details
Design-Prinzipien:
- Single-Page-Anwendung ohne Framework (Vanilla JS)
- Section-basiertes Layout (Auth, Events, Gallery, Banners, Publish)
- Responsive Design für Mobile und Desktop
- Real-Time Feedback bei API-Calls
- Error-Handling mit User-Friendly Messages
Performance-Optimierungen:
- Lazy-Loading von Event-/Gallery-Bildern
- Debouncing bei Drag-and-Drop-Updates
- Optimistic UI-Updates (sofortige visuelle Änderung, dann API-Call)
Backend-Architektur
API-Design
RESTful API mit Fastify:
Das Backend folgt REST-Konventionen mit klarer Ressourcen-Struktur:
/api
├── /auth
│ ├── GET /gitea → OAuth Flow Start
│ ├── GET /callback → OAuth Callback Handler
│ ├── GET /me → Current User Info (protected)
│ └── POST /logout → Logout & Clear Cookie (protected)
├── /events
│ ├── GET /public → Published Events (public)
│ ├── GET / → All Events (protected)
│ ├── GET /:id → Single Event (protected)
│ ├── POST / → Create Event (protected)
│ ├── PUT /:id → Update Event (protected)
│ ├── DELETE /:id → Delete Event (protected)
│ ├── POST /upload → Upload Event Image (protected)
│ └── PUT /reorder → Batch Reorder (protected)
├── /gallery
│ ├── GET /public → Published Images (public)
│ ├── GET / → All Images (protected)
│ ├── POST / → Create Entry (protected)
│ ├── PUT /:id → Update Entry (protected)
│ ├── DELETE /:id → Delete Entry (protected)
│ ├── POST /upload → Upload Image (protected)
│ └── PUT /reorder → Batch Reorder (protected)
├── /banners
│ ├── GET /active → Current Active Banner (public)
│ ├── GET / → All Banners (protected)
│ ├── POST / → Create Banner (protected)
│ ├── PUT /:id → Update Banner (protected)
│ └── DELETE /:id → Delete Banner (protected)
├── /content
│ ├── GET / → List All Sections (protected)
│ ├── GET /:section → Get Section Content (protected)
│ └── PUT /:section → Update Section (protected)
├── /settings
│ ├── GET / → All Settings (protected)
│ ├── GET /:key → Single Setting (protected)
│ ├── PUT /:key → Update Setting (protected)
│ └── DELETE /:key → Delete Setting (protected)
├── /publish
│ ├── POST / → Publish Changes (protected)
│ └── GET /history → Last 20 Publishes (protected)
└── /health → Service Health Check (public)
Authentication Middleware:
// auth.middleware.ts
export async function authenticateHandler(
request: FastifyRequest,
reply: FastifyReply
) {
try {
// Verify JWT from cookie
await request.jwtVerify();
// Attach user to request
const payload = request.user as JWTPayload;
request.userId = payload.userId;
} catch (error) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
});
}
}
// Usage in routes
fastify.get('/events', {
preHandler: [fastify.authenticate]
}, async (request, reply) => {
// Protected route logic
});
Datenbank-Schema
SQLite mit Drizzle ORM:
// schema.ts - Vollständiges Schema
// Users Table
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
giteaId: text('gitea_id').notNull().unique(),
giteaUsername: text('gitea_username').notNull(),
giteaEmail: text('gitea_email'),
displayName: text('display_name'),
avatarUrl: text('avatar_url'),
role: text('role').default('admin'),
createdAt: integer('created_at', { mode: 'timestamp' })
.$defaultFn(() => new Date()),
lastLogin: integer('last_login', { mode: 'timestamp' })
});
// Events Table
export const events = sqliteTable('events', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
date: text('date').notNull(), // ISO 8601 format
description: text('description').notNull(),
imageUrl: text('image_url').notNull(),
displayOrder: integer('display_order').default(0),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' })
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.$defaultFn(() => new Date())
});
// Gallery Images Table
export const galleryImages = sqliteTable('gallery_images', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
imageUrl: text('image_url').notNull(),
altText: text('alt_text').notNull(),
displayOrder: integer('display_order').default(0),
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' })
.$defaultFn(() => new Date())
});
// Content Sections (Hero, Welcome, Drinks)
export const contentSections = sqliteTable('content_sections', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
sectionName: text('section_name').notNull().unique(), // 'hero', 'welcome', 'drinks'
contentJson: text('content_json', { mode: 'json' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.$defaultFn(() => new Date())
});
// Site Settings (Key-Value)
export const siteSettings = sqliteTable('site_settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.$defaultFn(() => new Date())
});
// Publish History (Audit Log)
export const publishHistory = sqliteTable('publish_history', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull().references(() => users.id),
commitHash: text('commit_hash').notNull(),
commitMessage: text('commit_message').notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' })
.$defaultFn(() => new Date())
});
// Banners Table
export const banners = sqliteTable('banners', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
text: text('text').notNull(),
startDate: text('start_date').notNull(), // YYYY-MM-DD
endDate: text('end_date').notNull(), // YYYY-MM-DD
isActive: integer('is_active', { mode: 'boolean' }).default(true),
createdAt: integer('created_at', { mode: 'timestamp' })
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.$defaultFn(() => new Date())
});
Warum SQLite?
Für dieses Projekt ist SQLite die ideale Wahl:
- Einfachheit: Keine separate Datenbank-Server-Installation
- Portabilität: Eine einzige Datei, einfach zu backupen
- Performance: Ausreichend schnell für CMS-Operationen (nicht high-traffic)
- Deployment: Fly.io Volumes für Persistenz, keine Managed Database nötig
- Cost-Efficiency: Keine zusätzlichen Datenbank-Kosten
WAL-Modus für Concurrency:
// database.ts
const db = new Database(DATABASE_PATH);
db.pragma('journal_mode = WAL'); // Write-Ahead Logging
WAL-Modus ermöglicht gleichzeitige Reads während Writes, wichtig für Admin-Zugriffe.
Services-Schicht
Service-Oriented Architecture:
Das Backend ist in wiederverwendbare Services aufgeteilt:
-
GitService (
git.service.ts)- Repository-Cloning mit OAuth Token
- Pull, Add, Commit, Push-Operationen
- Reset-on-Error für sichere Rollbacks
-
GiteaService (
gitea.service.ts)- OAuth2-Token-Exchange
- User-Info-Fetching von Gitea API
- User-Allowlist-Validation
-
MediaService (
media.service.ts)- Image-Upload-Handling
- Sharp-basierte Optimierung
- File-System-Management (Save/Delete)
-
FileGeneratorService (
file-generator.service.ts)- Dynamische Astro-Component-Generierung
- Template-String-Escaping
- index.astro-Regeneration mit Events/Gallery
Beispiel: FileGeneratorService
// Vereinfachter Code
class FileGeneratorService {
async generateIndexAstro(events: Event[], gallery: GalleryImage[]) {
const template = `
---
import Layout from '../components/Layout.astro';
import Hero from '../components/Hero.astro';
import Banner from '../components/Banner.astro';
import Welcome from '../components/Welcome.astro';
import EventsGrid from '../components/EventsGrid.astro';
import ImageCarousel from '../components/ImageCarousel.astro';
import Drinks from '../components/Drinks.astro';
import Contact from '../components/Contact.astro';
const events = ${JSON.stringify(events)};
const galleryImages = ${JSON.stringify(gallery)};
---
<Layout title="Gallus Pub - Irish Pub St. Gallen">
<Hero />
<Banner />
<Welcome />
<EventsGrid events={events} />
<ImageCarousel images={galleryImages} />
<Drinks />
<Contact />
</Layout>
`;
await fs.writeFile(
path.join(GIT_WORKSPACE_DIR, 'src/pages/index.astro'),
template
);
}
async generateHeroComponent(content: HeroContent) {
// Escape quotes in content
const escaped = this.escapeTemplateString(JSON.stringify(content));
const template = `
---
const content = ${escaped};
---
<section class="hero" style={\`background-image: url(\${content.backgroundImage})\`}>
<div class="hero-content">
<h1>{content.heading1}</h1>
<h2>{content.heading2}</h2>
</div>
</section>
`;
await fs.writeFile(
path.join(GIT_WORKSPACE_DIR, 'src/components/Hero.astro'),
template
);
}
private escapeTemplateString(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
}
}
Image Processing Pipeline
Sharp-basierte Optimierung:
// media.service.ts
async function optimizeImage(buffer: Buffer): Promise<Buffer> {
try {
return await sharp(buffer)
.rotate() // Auto-rotate basierend auf EXIF
.resize(1600, null, {
withoutEnlargement: true, // Kein Upscaling
fit: 'inside' // Aspect Ratio beibehalten
})
.webp({ quality: 82 }) // WebP Konvertierung
.toBuffer();
} catch (error) {
// Fallback: Original zurückgeben
console.warn('WebP conversion failed, using original', error);
return buffer;
}
}
Warum WebP?
- 25-35% kleinere Dateigrößen vs. JPEG
- Transparenz-Support (vs. JPEG)
- Breite Browser-Unterstützung (95%+)
- Fallback auf Original bei Conversion-Failure
Frontend-Architektur
Astro Static Site Generator
Warum Astro?
Astro ist perfekt für Content-fokussierte Websites:
- Zero-JS by Default: Sendet kein JavaScript, es sei denn explizit benötigt
- Component Islands: Nur interaktive Komponenten sind hydratiert
- Framework-Agnostic: Kann React, Vue, Svelte-Komponenten mixen (hier: Pure Astro)
- Performance-First: Automatische Image-Optimierung, Lazy-Loading
- TypeScript-Native: Type-Safety auch im Frontend
Component-Architektur
Haupt-Layout:
---
// Layout.astro
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<link rel="stylesheet" href="/styles/index.css">
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>
Event-Grid Komponente:
---
// EventsGrid.astro
import HoverCard from './HoverCard.astro';
const { events } = Astro.props;
---
<section class="events-section">
<h2>Kommende Events</h2>
<div class="events-grid">
{events.map(event => (
<HoverCard
title={event.title}
date={event.date}
description={event.description}
imageUrl={event.imageUrl}
/>
))}
</div>
</section>
<style>
.events-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
padding: 2rem;
}
</style>
Image Carousel mit Client-Side JavaScript:
---
// ImageCarousel.astro
const { images } = Astro.props;
---
<div class="carousel">
<div class="carousel-slides">
{images.map((img, index) => (
<img
src={img.imageUrl}
alt={img.altText}
class={index === 0 ? 'active' : ''}
loading="lazy"
/>
))}
</div>
<button class="carousel-prev">‹</button>
<button class="carousel-next">›</button>
<div class="carousel-dots"></div>
</div>
<script>
// Vanilla JS für Carousel-Logik
let currentSlide = 0;
const slides = document.querySelectorAll('.carousel-slides img');
const dots = document.querySelector('.carousel-dots');
// Initialize dots
slides.forEach((_, i) => {
const dot = document.createElement('span');
dot.className = i === 0 ? 'dot active' : 'dot';
dot.addEventListener('click', () => goToSlide(i));
dots.appendChild(dot);
});
function goToSlide(n) {
slides[currentSlide].classList.remove('active');
dots.children[currentSlide].classList.remove('active');
currentSlide = n;
slides[currentSlide].classList.add('active');
dots.children[currentSlide].classList.add('active');
}
document.querySelector('.carousel-prev').addEventListener('click', () => {
goToSlide((currentSlide - 1 + slides.length) % slides.length);
});
document.querySelector('.carousel-next').addEventListener('click', () => {
goToSlide((currentSlide + 1) % slides.length);
});
</script>
<style>
.carousel { position: relative; }
.carousel-slides img {
display: none;
width: 100%;
height: auto;
}
.carousel-slides img.active { display: block; }
/* ... weitere Styles ... */
</style>
CSS-Architektur
Design-System mit CSS Custom Properties:
/* variables.css */
:root {
/* Colors */
--color-primary: #1a472a; /* Irish Green */
--color-secondary: #ff7f00; /* Whiskey Orange */
--color-dark: #1a1a1a;
--color-light: #f5f5f5;
--color-accent: #8b4513; /* Leather Brown */
/* Typography */
--font-primary: 'Georgia', serif;
--font-secondary: 'Helvetica Neue', sans-serif;
--font-size-base: 16px;
--font-size-large: 24px;
--font-size-xlarge: 36px;
/* Spacing */
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 2rem;
--spacing-lg: 4rem;
/* Layout */
--max-width: 1200px;
--border-radius: 8px;
--transition: all 0.3s ease;
}
Component-Scoped Styles:
Astro's <style> Tags sind automatisch component-scoped (ähnlich CSS Modules):
<style>
.hero {
/* Diese Styles gelten nur für Hero.astro */
background-size: cover;
background-position: center;
min-height: 60vh;
}
</style>
Responsive Design:
/* Mobile-First Approach */
.events-grid {
grid-template-columns: 1fr; /* Mobile: 1 Spalte */
}
@media (min-width: 768px) {
.events-grid {
grid-template-columns: repeat(2, 1fr); /* Tablet: 2 Spalten */
}
}
@media (min-width: 1024px) {
.events-grid {
grid-template-columns: repeat(3, 1fr); /* Desktop: 3 Spalten */
}
}
Besondere Features
1. Git-basiertes Publishing
Innovative Lösung für Deployment:
Anstatt nur die Datenbank zu aktualisieren, committed das CMS alle Änderungen direkt in Git. Das hat mehrere Vorteile:
Vorteile:
- Version Control: Jede Content-Änderung ist nachvollziehbar
- Rollback: Kann zu jedem früheren Zustand zurückkehren
- Collaboration: Mehrere Admins können gleichzeitig arbeiten
- CI/CD Integration: Woodpecker erkennt Commits automatisch
- Audit Trail: Wer hat was wann geändert?
Implementation:
// git.service.ts
class GitService {
async pullRepository(): Promise<void> {
const git = simpleGit(GIT_WORKSPACE_DIR);
await git.pull('origin', 'main');
}
async commitAndPush(message: string): Promise<string> {
const git = simpleGit(GIT_WORKSPACE_DIR);
// Configure user
await git.addConfig('user.name', GIT_USER_NAME);
await git.addConfig('user.email', GIT_USER_EMAIL);
// Stage all changes
await git.add('.');
// Commit
const commit = await git.commit(message);
// Push with OAuth token
const remoteUrl = `https://oauth2:${GIT_TOKEN}@${GIT_REPO_URL.replace('https://', '')}`;
await git.push(remoteUrl, 'main');
return commit.commit; // Hash
}
async reset(): Promise<void> {
const git = simpleGit(GIT_WORKSPACE_DIR);
await git.reset(['--hard', 'HEAD']);
}
}
2. Dynamische File-Generierung
Problem: Astro ist ein Static Site Generator - es gibt keine Datenbank zur Build-Zeit.
Lösung: Das Backend generiert Astro-Component-Dateien mit eingebetteten Daten.
Beispiel-Workflow:
1. Admin fügt Event hinzu → Speichert in SQLite
2. Admin klickt "Publish"
3. Backend liest Events aus Datenbank
4. Backend generiert index.astro:
---
const events = [
{ title: "Quiz Night", date: "2026-02-15", ... },
{ title: "Live Music", date: "2026-02-20", ... }
];
---
<EventsGrid events={events} />
5. Git Commit & Push
6. Woodpecker CI buildet Astro-Site
7. Events sind jetzt im statischen HTML
Template-String-Escaping:
Ein kritisches Detail - Content kann Quotes und Backticks enthalten:
function escapeForTemplate(content: any): string {
const json = JSON.stringify(content);
return json
.replace(/\\/g, '\\\\') // Escape backslashes
.replace(/`/g, '\\`') // Escape backticks
.replace(/\$/g, '\\$'); // Escape template literals
}
// Verwendung
const template = `const events = ${escapeForTemplate(eventsArray)};`;
3. OAuth2-Integration mit Gitea
Self-Hosted Authentication:
Anstatt Drittanbieter wie Auth0 oder Firebase zu verwenden, nutzt das Projekt den eigenen Gitea-Server für OAuth.
Vorteile:
- Kostenfrei: Keine externen Auth-Provider-Kosten
- Privacy: User-Daten bleiben auf eigenen Servern
- Single Sign-On: Admins sind bereits Gitea-User
- Custom Allowlist: Einfache User-Kontrolle via ENV
OAuth2-Flow (vereinfacht):
// 1. Start OAuth Flow
fastify.get('/auth/gitea', async (request, reply) => {
const state = crypto.randomUUID();
reply.setCookie('oauth_state', state, {
httpOnly: true,
sameSite: 'lax',
maxAge: 600 // 10 Minuten
});
const authUrl = `${GITEA_URL}/login/oauth/authorize?` +
`client_id=${GITEA_CLIENT_ID}` +
`&redirect_uri=${GITEA_REDIRECT_URI}` +
`&response_type=code` +
`&state=${state}`;
reply.redirect(authUrl);
});
// 2. Callback Handler
fastify.get('/auth/callback', async (request, reply) => {
const { code, state } = request.query;
const storedState = request.cookies.oauth_state;
// CSRF Check
if (state !== storedState) {
throw new Error('State mismatch');
}
// Exchange code for token
const tokenResponse = await fetch(`${GITEA_URL}/login/oauth/access_token`, {
method: 'POST',
body: JSON.stringify({
client_id: GITEA_CLIENT_ID,
client_secret: GITEA_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: GITEA_REDIRECT_URI
})
});
const { access_token } = await tokenResponse.json();
// Get user info
const userResponse = await fetch(`${GITEA_URL}/api/v1/user`, {
headers: { Authorization: `token ${access_token}` }
});
const giteaUser = await userResponse.json();
// Check allowlist
if (GITEA_ALLOWED_USERS && !GITEA_ALLOWED_USERS.includes(giteaUser.login)) {
throw new Error('User not allowed');
}
// Find or create local user
let user = await db.query.users.findFirst({
where: eq(users.giteaId, String(giteaUser.id))
});
if (!user) {
user = await db.insert(users).values({
giteaId: String(giteaUser.id),
giteaUsername: giteaUser.login,
giteaEmail: giteaUser.email,
displayName: giteaUser.full_name,
avatarUrl: giteaUser.avatar_url
}).returning();
}
// Sign JWT
const token = fastify.jwt.sign({
userId: user.id,
giteaUsername: user.giteaUsername
});
// Set HttpOnly cookie
reply.setCookie('token', token, {
httpOnly: true,
secure: NODE_ENV === 'production',
sameSite: NODE_ENV === 'production' ? 'none' : 'lax',
maxAge: 86400 // 24 Stunden
});
reply.redirect(`${FRONTEND_URL}/admin`);
});
4. Image-Optimierung mit Sharp
High-Performance Image Processing:
Sharp ist eine der schnellsten Image-Processing-Libraries für Node.js (nutzt libvips).
Optimierungen:
-
EXIF-basiertes Auto-Rotate:
- Smartphones speichern Rotation in EXIF-Daten
.rotate()wendet diese automatisch an
-
Responsive Sizing:
- Max. 1600px Breite für Desktop-Displays
withoutEnlargement: trueverhindert Upscaling (Qualitätsverlust)
-
WebP-Konvertierung:
- 82% Qualität ist Sweet Spot (gute Qualität, große Einsparung)
- Fallback auf Original bei Conversion-Fehlern
-
File-Size Reduktion:
- Typisch: 1-3MB JPEGs → 200-500KB WebP
- 60-80% Bandwidth-Einsparung
Implementation:
async function processUpload(file: MultipartFile): Promise<string> {
// Read file buffer
const buffer = await file.toBuffer();
// Process with Sharp
const optimized = await sharp(buffer)
.rotate() // EXIF auto-rotate
.resize(1600, null, {
withoutEnlargement: true,
fit: 'inside'
})
.webp({ quality: 82 })
.toBuffer();
// Generate unique filename
const filename = `${crypto.randomUUID()}.webp`;
const filepath = path.join(IMAGES_DIR, filename);
// Save to disk
await fs.writeFile(filepath, optimized);
return `/static/images/${filename}`;
}
5. Drag-and-Drop Reordering
Native Drag-and-Drop API:
Anstatt einer Library wie react-beautiful-dnd, nutzt das Admin-Portal die native HTML Drag-and-Drop API.
Implementation (Vanilla JS):
// Simplified code from admin.astro
let draggedElement = null;
function makeDraggable(element) {
element.draggable = true;
element.addEventListener('dragstart', (e) => {
draggedElement = element;
element.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
element.addEventListener('dragend', (e) => {
element.classList.remove('dragging');
});
element.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Visual feedback
const afterElement = getDragAfterElement(e.clientY);
if (afterElement == null) {
element.parentElement.appendChild(draggedElement);
} else {
element.parentElement.insertBefore(draggedElement, afterElement);
}
});
element.addEventListener('drop', async (e) => {
e.preventDefault();
// Collect new order
const items = Array.from(element.parentElement.children);
const newOrder = items.map((el, index) => ({
id: el.dataset.id,
displayOrder: index
}));
// Send to backend
await fetch('/api/events/reorder', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(newOrder)
});
});
}
function getDragAfterElement(y) {
const draggableElements = [
...document.querySelectorAll('.draggable:not(.dragging)')
];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
Backend-Endpoint:
// PUT /api/events/reorder
fastify.put('/reorder', {
preHandler: [fastify.authenticate]
}, async (request, reply) => {
const updates = request.body as Array<{ id: string, displayOrder: number }>;
// Batch update in transaction
await db.transaction(async (tx) => {
for (const { id, displayOrder } of updates) {
await tx.update(events)
.set({ displayOrder })
.where(eq(events.id, id));
}
});
return { success: true };
});
Deployment & Infrastructure
Fly.io-Architektur
Zwei separate Apps:
-
Backend (
cms-gallus-pub):- Node.js/Fastify-App
- Dockerfile-basiertes Build
- Persistent Volume für SQLite & Git-Workspace
- Environment Variables via Fly Secrets
-
Frontend (
gallus-pub):- Astro Static Build
- Serve via Node.js HTTP-Server
- Separate Dockerfile
- No persistent storage needed
Backend fly.toml:
app = "cms-gallus-pub"
primary_region = "fra"
[build]
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
PORT = "8080"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[http_service.checks]]
interval = "10s"
timeout = "2s"
grace_period = "5s"
method = "GET"
path = "/api/health"
[mounts]
source = "gallus_cms_data"
destination = "/app/data"
initial_size = "1GB"
Volume für Persistenz:
# Create volume
fly volumes create gallus_cms_data --size 1 --region fra
# Volume enthält:
# /app/data/gallus_cms.db → SQLite Database
# /app/data/workspace/ → Git Repository Checkout
# /app/data/public/images/ → Uploaded Images
Docker Setup
Backend Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY backend/package*.json ./
RUN npm ci --only=production
# Copy source
COPY backend/ .
# Build TypeScript
RUN npm run build
# Production image
FROM node:20-alpine
WORKDIR /app
# Copy built files & dependencies
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# Create data directory
RUN mkdir -p /app/data/public/images
# Expose port
EXPOSE 8080
# Start server
CMD ["node", "dist/index.js"]
Frontend Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Build Astro
RUN npm run build
# Production image
FROM node:20-alpine
WORKDIR /app
# Install serve
RUN npm install -g serve
# Copy built files
COPY --from=builder /app/dist ./dist
# Expose port
EXPOSE 3000
# Serve static files
CMD ["serve", "-s", "dist", "-l", "3000"]
Lokale Entwicklung mit Docker Compose
docker-compose.yml:
version: '3.8'
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_PATH=/app/data/gallus_cms.db
- GIT_WORKSPACE_DIR=/app/data/workspace
- CORS_ORIGIN=http://localhost:5173
- FRONTEND_URL=http://localhost:5173
env_file:
- backend/.env.local
volumes:
- ./backend/src:/app/src
- backend-data:/app/data
command: npm run dev
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "5173:5173"
volumes:
- ./src:/app/src
- ./public:/app/public
command: npm run dev
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
volumes:
backend-data:
caddy-data:
caddy-config:
Caddyfile (Reverse Proxy):
localhost {
# Frontend
route /* {
reverse_proxy frontend:5173
}
# Backend API
route /api/* {
reverse_proxy backend:3000
}
# Static files (images)
route /static/* {
reverse_proxy backend:3000
}
}
CI/CD mit Woodpecker
.woodpecker.yml:
pipeline:
build-frontend:
image: node:20-alpine
commands:
- npm ci
- npm run build
when:
branch: main
path: "src/**"
deploy-frontend:
image: flyio/flyctl
secrets: [ fly_api_token ]
commands:
- flyctl deploy --config fly.toml
when:
branch: main
event: push
build-backend:
image: node:20-alpine
commands:
- cd backend
- npm ci
- npm run build
when:
branch: main
path: "backend/**"
deploy-backend:
image: flyio/flyctl
secrets: [ fly_api_token ]
commands:
- flyctl deploy --config backend/fly.toml
when:
branch: main
event: push
Environment Variables
Backend (.env.local):
# Gitea OAuth
GITEA_URL=https://git.bookageek.ch
GITEA_CLIENT_ID=abc123...
GITEA_CLIENT_SECRET=xyz789...
GITEA_REDIRECT_URI=https://cms.gallus-pub.ch/api/auth/callback
GITEA_ALLOWED_USERS=sabrina,raphael
# Git Integration
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub.git
GIT_TOKEN=gitea_personal_access_token
GIT_USER_NAME=Gallus CMS
GIT_USER_EMAIL=cms@galluspub.ch
GIT_WORKSPACE_DIR=/app/data/workspace
# Database
DATABASE_PATH=/app/data/gallus_cms.db
# JWT & Session
JWT_SECRET=your-random-base64-secret-here
SESSION_SECRET=another-random-base64-secret
# Server
PORT=8080
NODE_ENV=production
CORS_ORIGIN=https://gallus-pub.ch
FRONTEND_URL=https://gallus-pub.ch
# File Upload
MAX_FILE_SIZE=5242880
Fly Secrets setzen:
# Backend
fly secrets set \
GITEA_CLIENT_SECRET=xyz789... \
GIT_TOKEN=gitea_token... \
JWT_SECRET=random_base64... \
SESSION_SECRET=random_base64... \
--app cms-gallus-pub
# Frontend (keine Secrets benötigt)
Lessons Learned & Highlights
Technische Highlights
-
Headless CMS mit Git-Integration
- Innovative Kombination von CMS und Version Control
- Ermöglicht Content-Versionierung und Rollbacks
- Audit Trail für alle Änderungen
-
Typsichere Full-Stack-TypeScript-Architektur
- Shared Types zwischen Frontend und Backend
- Reduzierung von Runtime-Errors durch Compile-Time-Checks
- Bessere Developer Experience mit Autocomplete
-
Performance-Optimierung
- Statische Site-Generierung für schnellste Ladezeiten
- WebP-Konvertierung für 60-80% kleinere Bilder
- Zero-JS-by-Default mit Astro
-
Self-Hosted Infrastructure
- Keine Abhängigkeit von Drittanbieter-Auth-Services
- Kostenkontrolle durch SQLite statt Managed Database
- Privacy-freundlich (alle Daten auf eigenen Servern)
-
Developer-Friendly Workflows
- Hot-Reload in Development
- Docker Compose für einfaches lokales Setup
- Klare Service-Separation
Herausforderungen & Lösungen
Challenge 1: Static Site Generator + Dynamic Content
Problem: Astro generiert HTML zur Build-Zeit, aber CMS-Content ändert sich dynamisch.
Lösung: Backend generiert Astro-Component-Files mit eingebetteten Daten, committed zu Git, CI/CD rebuildet automatisch.
Challenge 2: Cross-Domain Authentication
Problem: Frontend (gallus-pub.ch) muss mit Backend (cms.gallus-pub.ch) kommunizieren, Browser blockieren Third-Party Cookies.
Lösung:
SameSite=NonemitSecure=truefür Productioncredentials: 'include'in Fetch-Requests- CORS-Konfiguration mit
credentials: true
Challenge 3: Image Upload & Optimization
Problem: User-Uploads können mehrere MB groß sein, langsame Ladezeiten.
Lösung:
- Sharp-Pipeline: Resize + WebP-Konvertierung
- Automatisches EXIF-Rotate für korrekte Orientierung
- Fallback auf Original bei Conversion-Fehlern
Challenge 4: Drag-and-Drop ohne Framework
Problem: React Beautiful DnD wäre Overkill für ein Astro-Projekt.
Lösung: Native HTML Drag-and-Drop API mit Vanilla JavaScript, Batch-Update per Single API-Call.
Challenge 5: Secure OAuth ohne External Provider
Problem: Auth0/Firebase sind teuer oder mit Privacy-Problemen behaftet.
Lösung: Self-Hosted Gitea OAuth2, User-Allowlist, State-basierte CSRF-Protection.
Mögliche Erweiterungen
Kurzfristig:
- Visual Editor für Content Sections (Hero, Welcome, Drinks)
- Image-Cropping im Upload-Dialog
- Bulk-Delete für Events/Gallery
- Search & Filter im Admin-Portal
- Publish-Preview (vor Git-Commit)
Mittelfristig:
- Multi-Language Support (DE/EN)
- Role-Based Access Control (Editor vs. Admin)
- Email-Benachrichtigungen bei Publishes
- Analytics-Integration (Privacy-friendly)
- PDF-Menü-Generator aus CMS
Langfristig:
- Mobile App für Admin-Portal
- Reservierungs-System für Events
- Newsletter-Integration
- Social Media Auto-Posting
- A/B-Testing für Content
Fazit
Das Gallus Pub CMS demonstriert moderne Full-Stack-Entwicklung mit einem einzigartigen Ansatz: Die Kombination von Git-basiertem Publishing, Headless CMS-Architektur und statischer Site-Generierung schafft eine Lösung, die gleichzeitig performant, sicher und wartbar ist.
Kernkompetenzen gezeigt:
- Full-Stack TypeScript (Fastify + Astro)
- OAuth2-Authentifizierung & JWT-Sessions
- SQLite-Datenbank-Design mit Drizzle ORM
- Git-Integration & CI/CD-Workflows
- Image Processing mit Sharp
- Responsive UI/UX ohne Frameworks
- Docker & Fly.io Deployment
- RESTful API-Design
Besonderheit: Dieses Projekt zeigt ein tiefes Verständnis für Web-Architektur - die Entscheidung für statische Site-Generierung + Headless CMS ist nicht die "einfache" Lösung, aber die richtige für Performance und Sicherheit. Die Git-basierte Publishing-Pipeline ist eine innovative Lösung, die in kommerziellen CMS selten zu finden ist.
Produktionsreif:
Die Anwendung ist live auf gallus-pub.ch und wird aktiv genutzt, mit echten Events, Gallery-Bildern und regelmäßigen Content-Updates.
Links & Ressourcen
- Live Website: https://gallus-pub.ch
- CMS Backend: https://cms.gallus-pub.ch
- Git Repository: https://git.bookageek.ch/Kenzo/Gallus_Pub
Technologie-Dokumentation:
Entwickelt mit ❤️ für authentische irische Pub-Atmosphäre in St. Gallen.