feedc0de bfac12a5bb
Publish and deploy birthday invitation / Check JavaScript (push) Successful in 4s
Publish and deploy birthday invitation / Build and push (push) Successful in 1m5s
Publish and deploy birthday invitation / Deploy to Kubernetes (push) Successful in 1m12s
Allow admins to manage guest RSVPs
2026-10-14 21:19:04 +02:00
2026-09-08 18:30:41 +02:00
2026-09-08 18:30:41 +02:00
2026-10-14 21:19:04 +02:00
2026-10-14 21:19:04 +02:00
2026-09-08 18:44:51 +02:00
2026-09-08 17:20:12 +02:00
2026-10-14 21:19:04 +02:00
2026-10-14 21:19:04 +02:00

Einladung & RSVP

Eine responsive Einladung von Vicky und Daniel zu Daniels 30. Geburtstag. Jeder Gast erhält einen persönlichen Link, kann die Originaleinladung als PDF öffnen und eine von drei Antworten abgeben:

  • Ich komme nicht
  • Ich komme alleine
  • Ich komme mit jemandem (inklusive Name der Begleitung)

Der Server benötigt nur Node.js 22.5 oder neuer und keine zusätzlichen Pakete. Als Datenbank dient SQLite über Nodes eingebautes node:sqlite-Modul.

Lokal starten

ADMIN_KEY='ein-langes-geheimes-passwort' \
ORIGIN='http://localhost:3000' \
npm start

Danach läuft die Seite unter http://localhost:3000. Die Verwaltung ist unter http://localhost:3000/admin erreichbar. Ohne ADMIN_KEY oder vorgeschaltetes Authentik bleiben die Admin-Endpunkte aus Sicherheitsgründen deaktiviert.

Öffne /admin, melde dich mit dem ADMIN_KEY an und füge bis zu 200 Namen auf einmal ein – einen Namen pro Zeile. Die erzeugten Links können einzeln oder gesammelt kopiert werden.

Die Übersicht zeigt:

  • ob und wann ein Link erstmals geöffnet wurde
  • wann er zuletzt geöffnet wurde und wie oft
  • die aktuelle Zu- oder Absage
  • den Namen einer Begleitperson
  • wann die Antwort zuletzt gespeichert beziehungsweise geändert wurde
  • die erwartete Gesamtzahl der kommenden Gäste

Über „Antwort setzen“ beziehungsweise „Antwort ändern“ kann die Zu- oder Absage im Adminbereich stellvertretend für einen Gast gespeichert werden. Bei einer Zusage mit Begleitung wird auch deren Name erfasst.

Über „CSV herunterladen“ lässt sich die vollständige Liste für Excel, Numbers oder LibreOffice exportieren. Eine Öffnung wird gezählt, sobald das JavaScript der RSVP-Seite die Einladung abruft. Manche besonders aktive Link-Vorschau-Bots können theoretisch ebenfalls eine Öffnung erzeugen.

Am einfachsten direkt auf dem Server:

ORIGIN='https://30.brunner.ninja' npm run invite -- "Max Mustermann"

Die Ausgabe enthält den fertigen Link, zum Beispiel:

Max Mustermann: https://30.brunner.ninja/i/zufaelliger-sicherer-token

Alternativ können mehrere Links über die Admin-API erzeugt werden:

curl -X POST 'https://30.brunner.ninja/api/admin/invites' \
  -H 'Authorization: Bearer DEIN_ADMIN_KEY' \
  -H 'Content-Type: application/json' \
  --data '{"names":["Max Mustermann","Erika Musterfrau"]}'

Antworten exportieren

curl 'https://30.brunner.ninja/api/admin/responses.csv' \
  -H 'Authorization: Bearer DEIN_ADMIN_KEY' \
  -o zusagen.csv

Die CSV-Datei lässt sich direkt mit Excel, Numbers oder LibreOffice öffnen.

Daten und Betrieb

Antworten werden in data/invites.db gespeichert. SQLite läuft im WAL-Modus; das Verzeichnis muss im Produktionsbetrieb beschreibbar und persistent sein. Regelmäßige Backups der Datenbank werden empfohlen.

Für die öffentliche Domain sollte die App hinter einem HTTPS-Reverse-Proxy (beispielsweise Caddy oder nginx) laufen. Benötigte Umgebungsvariablen:

Variable Zweck Standard
PORT Interner HTTP-Port 3000
HOST Listen-Adresse 0.0.0.0
ORIGIN Öffentliche Basis-URL für erzeugte Links http://localhost:3000
DATABASE_FILE Pfad zur SQLite-Datenbank data/invites.db
ADMIN_KEY Geheimer Schlüssel für lokalen Admin-Zugriff keiner
TRUST_AUTHENTIK Akzeptiert Authentiks verifiziertes Benutzer-Header (true) false

Die RSVP-Seiten liegen unter /i/<token>; von dort kann die persönliche PDF-Einladung geöffnet werden.

Die Einladungsgrafik und PDF sind bewusst nicht im Git-Repository oder Container-Image enthalten. In Kubernetes werden sie aus dem Secret 30-birthday-invitation schreibgeschützt eingebunden und ausschließlich über einen gültigen Gast-Token ausgeliefert. Direkte öffentliche Asset-URLs existieren nicht.

Kubernetes, Traefik und Authentik

Ein vollständiges Beispiel liegt in k8s/invitation.yaml. Es verwendet:

  • genau einen Pod (SQLite ist hier bewusst nicht horizontal skaliert)
  • einen 128Mi-rook-ceph-block-PVC für /data/invites.db
  • die Domain 30.brunner.ninja
  • ausschließlich Traefiks EntryPoint websecure
  • das bestehende default-authentik@kubernetescrd-Middleware nur für /admin* und /api/admin/*
  • öffentliche Gast- und Asset-Routen ohne Authentik
  • Recreate-Updates mit einer kurzen Unterbrechung, damit nur ein SQLite-Prozess den Block-PVC verwendet

Zuerst das Image bauen und in die eigene Registry pushen:

docker build -t registry.brunner.ninja/feedc0de/30-birthday:latest .
docker push registry.brunner.ninja/feedc0de/30-birthday:latest

Die Gitea Action in .gitea/workflows/container.yml übernimmt Build, Push und Deployment automatisch. Sie benötigt diese Repository-Secrets:

  • QUAY_USERNAME: feedc0de+birthday30
  • QUAY_TOKEN: Token dieses Robot-Accounts
  • KUBE_CONFIG_BASE64: Kubeconfig des eingeschränkten 30-birthday-deployer-ServiceAccounts

Workload und CI-Berechtigungen werden einmalig mit einem Cluster-Administrator angelegt:

./install.sh
./k8s/create-ci-kubeconfig.sh

Die Action baut registry.brunner.ninja/feedc0de/30-birthday, versieht jedes Image mit dem unveränderlichen Commit-SHA und aktualisiert auf main ausschließlich das bestehende default/30-birthday-Deployment. Ihre Kubernetes-Rolle kann keine Secrets oder anderen Deployments lesen oder verändern.

Der Cluster stellt Authentiks /outpost.goauthentik.io/* bereits zentral bereit. Der App-Service ist nur als ClusterIP erreichbar; daher kann ein externer Client Authentiks Identitätsheader nicht direkt am App-Port einschleusen.

S
Description
Geburtstagskarte zum 30. Geburtstag mit Zu- oder Absagendatenbank
Readme GPL-3.0
873 KiB
Languages
JavaScript 54.6%
CSS 23.1%
HTML 17.8%
Shell 3.4%
Dockerfile 0.8%
Other 0.3%