Files

7.7 KiB
Raw Permalink Blame History

GutscheinNest

Private Gutscheinverwaltung für einen oder mehrere Haushalte (Next.js + SQLite + Prisma). Gemeinsame und private Bestände, Anmeldung per Passwort oder SSO (OIDC), Erinnerungen per Mail/Telegram.

Rollen: Der erste Nutzer ist Server-Admin (wird beim Start aus APP_USER/APP_PASSWORD_HASH angelegt). Server-Admins verwalten Nutzer, Einladungen, Haushalte und Einstellungen; Haushalts-Admins verwalten ihren eigenen Haushalt; alle Mitglieder verwalten gemeinsam Gutscheine.

Funktionen

  • Private und Haushalts-Gutscheinbestände (strikte Trennung; ein Nutzer gehört höchstens einem Haushalt)
  • Einladungen per E-Mail (7 Tage gültig), Passwort-Login und OIDC/SSO (Authentik u. a.)
  • Erinnerungen per Mail/Telegram mit frei wählbaren Vorlauf-Tagen
  • Haushalts-Export als JSON (ohne Secrets) für Server- und Haushalts-Admins
  • Kontakte, Kategorien, Suche, Erinnerungsregeln, Backup-Endpunkt für Admins

Architektur

  • app/ – Next.js-App (App Router), API unter app/api/
  • lib/ – DB-Zugriff, Auth, Zugriffsregeln (access.ts), Scheduler
  • prisma/schema.prisma – SQLite-Schema; lib/db-migrate.ts legt beim Boot fehlende Tabellen/Spalten an (frische und alte DBs)
  • instrumentation.ts → lib/scheduler.ts – Erinnerungs-Cron alle 15 Minuten (intern über APP_INTERNAL_URL, kein Hairpin über den Proxy)
  • Reverse-Proxy: Caddy (automatische Let's-Encrypt-Zertifikate, HTTP→HTTPS)
  • Container: rootless – der App-Prozess läuft als UID:GID 10001:10001, nie als Root; Healthcheck gegen /api/health

Schnellstart: Deployment auf einem anderen Server (fertiges Image, empfohlen)

Voraussetzungen auf dem Zielserver: Docker mit Compose-Plugin, eine Domain mit A/AAAA-Record auf die Server-IP, offene Ports 80/443 (plus SSH). Port 3000 wird nicht geöffnet – die App ist nur im internen Compose-Netz erreichbar.

# 1) Dateien holen (nur Compose + Caddy + .env-Vorlage, kein Quellcode nötig):
#    aus dem Gitea-Repo: docker-compose.deploy.yml, Caddyfile, .env.example
cp .env.example .env   # Pflicht: DOMAIN, APP_URL=https://<DOMAIN>, APP_USER,
                       # APP_PASSWORD_HASH, SESSION_SECRET,
                       # SETTINGS_ENCRYPTION_KEY, CRON_SECRET

# 2) Image ziehen und starten (öffentlich, Login nur beim ersten Pull nötig):
docker login gitea.lokcal.de -u <gitea-benutzer>
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d

Das Image gitea.lokcal.de/loki/gutscheintracker:latest wird bei jedem Push auf main per Gitea-Action frisch gebaut (nach Tests, Typcheck und Build) und ist öffentlich. Für reproduzierbare Deployments einen Digest pinnen (steht in Gitea unter → Pakete → container/gutscheintracker).

Prüfen:

  • https://<DOMAIN>/login lädt mit gültigem Zertifikat, http:// leitet auf HTTPS um
  • curl http://127.0.0.1:3000/api/health auf dem Server scheitert nicht am Proxy, sondern zeigt, dass Port 3000 nicht offen ist (kein ports:-Mapping)
  • Einladungs-Links und Cron-Läufe funktionieren (Test-Mail in den Einstellungen)
  • docker exec <container> id → uid=10001(app) gid=10001(app)

Lokal testen geht mit derselben Datei: DOMAIN=localhost in .env (dann ohne TLS, Caddy macht automatisch HTTP).

Variante: lokal bauen (Entwicklung / eigener Build)

cp .env.example .env   # Werte ausfüllen (siehe Konfiguration)
docker compose up -d --build

App: https://<DOMAIN>/login (bzw. lokal http://localhost/login über Caddy).

Update

Deployment mit Registry-Image:

docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d

Lokaler Build:

git pull
docker compose up --build -d

Konfiguration (.env)

Variable Pflicht Beispiel Wozu
DOMAIN ja gutscheine.example.de Öffentliche Domain für Caddy/TLS
APP_URL ja https://gutscheine.example.de Exakte öffentliche Adresse (OIDC-Redirects, Einladungs-Links)
APP_INTERNAL_URL nein http://app:3000 Interner Scheduler-Zugriff (Standard passt zu Compose)
APP_USER / APP_PASSWORD_HASH ja haushalt / $2b$10$… Erster Server-Admin (bcrypt-Hash, z. B. node -e "console.log(require('bcryptjs').hashSync('…',10))")
ADMIN_EMAIL nein admin@beispiel.de Kontaktadresse
SESSION_SECRET / SETTINGS_ENCRYPTION_KEY / CRON_SECRET ja je 32+ Zufallszeichen Sessions, Einstellungs-Verschlüsselung, Cron-Schutz
SMTP_* / MAIL_FROM für Mail – Einladungen und Erinnerungen per Mail
TELEGRAM_BOT_TOKEN für Telegram – Erinnerungen per Telegram
ALLOW_PASSWORD_LOGIN nein true false: Mitglieder brauchen SSO (Admin behält Passwort-Notfallzugang)
TZ nein Europe/Berlin Zeitzone für Erinnerungen
OIDC für SSO – Bevorzugt in der UI (Einstellungen → SSO); OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET auch als Env möglich

In Authentik als Redirect-URI eintragen: https://<DOMAIN>/api/auth/oidc/callback.

Mitglieder einladen

Admins laden unter Einstellungen → Nutzer per E-Mail ein (Link 7 Tage gültig). Eingeladene richten ein Passwort ein oder melden sich per SSO an, sofern die OIDC-E-Mail zur Einladung passt. Mit ALLOW_PASSWORD_LOGIN=false brauchen Mitglieder SSO; der Admin behält zusätzlich den Passwort-Notfallzugang.

Backup & Wiederherstellung

Die SQLite-Datei liegt im Volume gutschein-data unter /data/gutscheine.db:

# Sichern (Dateiname enthält den Compose-Projektnamen, ggf. anpassen):
docker cp gutscheintracker-app-1:/data/gutscheine.db ./gutscheine-backup.db
# oder Volume direkt kopieren:
docker run --rm -v gutschein-data:/data -v "$PWD":/backup alpine cp /data/gutscheine.db /backup/gutscheine-backup.db

Zusätzlich: Haushalts-Export als JSON in der UI (Einstellungen → Haushalt → „Haushaltsdaten exportieren") – enthält Gutscheine, Kontakte und Mitglieder, aber keine Passwörter oder Secrets. Der Voll-Backup-Endpunkt (/api/settings/backup) bleibt Server-Admins vorbehalten.

Wiederherstellung: Container stoppen, Datei nach /data/gutscheine.db zurückkopieren (Eigentümer 10001:10001 beachten), Container starten.

Sicherheit

  • Rootless: Image und Compose nutzen fest user: "10001:10001"; der Prozess kann keine Root-Rechte erlangen (no-new-privileges)
  • Kein offener App-Port: nur Caddy (80/443) ist von außen erreichbar
  • Security-Header: nosniff, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy (ohne Kamera/Mikrofon/Standort)
  • Sessions sind signiert; Passwortwechsel invalidiert fremde Sitzungen; Admin-Resets werden protokolliert
  • Kein XSS-Risiko: keine dangerouslySetInnerHTML/innerHTML-Sinks; einziger dynamischer Link ist der validierte Shop-Link mit rel="noopener noreferrer"
  • Vollständige TLS-Kette via Caddy (Let's Encrypt, Auto-Renew, HTTP→HTTPS)

Entwicklung

npm ci
npx prisma generate
npm run dev   # http://localhost:3001
npm test
npx tsc --noEmit
npm run build

Tests: Vitest-Suite (tests/), u. a. Migrationstests für Erst-Boot, Idempotenz und Altdaten-Übernahme. CI (.gitea/workflows/docker.yml) läuft bei jedem Push auf main: Tests → Typcheck → Build → Image-Build und Push (:latest + Commit-SHA).

Fehlerbehebung

  • Caddy holt kein Zertifikat: DOMAIN in .env prüfen, DNS A/AAAA und Ports 80/443 prüfen, docker compose logs caddy
  • OIDC-Login scheitert: APP_URL muss exakt der öffentlichen URL entsprechen; Redirect-URI in Authentik prüfen
  • Cron/Erinnerungen laufen nicht: CRON_SECRET gesetzt? Scheduler nutzt APP_INTERNAL_URL (Compose-Standard reicht)
  • DB-Schreibfehler (unable to open database): Volume/Datei muss der UID 10001 gehören – bei manueller Wiederherstellung chown 10001:10001 auf die DB-Datei