Add phase 6: backup/restore scripts and full README

- backup.sh: pg_dump (custom format), media volume and .env in one
  archive with retention (KEEP), cron example
- restore.sh: confirmation, restores .env if missing, pg_restore
  --clean in one transaction, replaces media, restarts the app
- Fix: MEDIA_ROOT=/data/media in the image so photos land on the volume
- README: installation, Nginx/Cloudflare client-IP setup, configuration
  table, AI and Instagram notes, update, backup, troubleshooting, security
- Test settings independent of the local .env

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kyle Dammann
2026-10-30 21:56:01 +01:00
co-authored by Claude Opus 5.5
parent 60785e2437
commit f96cbe965e
6 changed files with 305 additions and 31 deletions
+200 -27
View File
@@ -1,66 +1,239 @@
# Kochbuch
Selbst gehostetes Familien-Kochbuch mit Einladungen, privaten und öffentlichen Rezepten und KI-Import.
Läuft per docker compose (App + PostgreSQL) hinter einem bestehenden Reverse Proxy.
Selbst gehostetes Kochbuch für Familie und Freunde – fürs Handy gemacht.
> Stand: Phase 1 (Konten, Einladungen, Rollen). Die vollständige Anleitung inkl. Update und Backup
> folgt in Phase 6.
- **Rezepte** mit Zutaten (Mengen, Einheiten, Gruppen wie „Für die Soße“), Schritten, Tags, Notizen,
Quelle und mehreren Fotos (direkt aus der Handykamera, auch HEIC).
- **Privat oder öffentlich** pro Rezept. Private Rezepte sieht nur der Autor – auch kein Admin, in keiner
Liste, Suche und unter keiner URL (inkl. Fotos). Das ist mit automatisierten Tests abgesichert.
- **Suche** nach Titel, Zutaten und Tags, **Favoriten**, **Kommentare**, **Portionsrechner** und ein
**Kochmodus** mit großer Schrift, Abhak-Listen und Bildschirm-bleibt-an.
- **Import:** Link zu einer Rezeptseite (schema.org), Instagram-Beitrag, Foto/Screenshot oder eingefügter
Text. Mit OpenAI-Key werden Texte und Bilder per KI strukturiert. Das Ergebnis ist **immer ein Entwurf**,
den du prüfst und erst dann speicherst.
- **Nur auf Einladung:** keine offene Registrierung. Admins verwalten Nutzer, Einladungen und Passwort-Resets.
- **Als App installierbar** (PWA); unter Android erscheint „Kochbuch“ im Teilen-Menü, z. B. direkt aus Instagram.
Technik: Django 5.2 (Python 3.12), PostgreSQL 17, gunicorn. Zwei Container, ca. 150–250 MB RAM.
---
## Inhalt
1. [Installation](#installation)
2. [Reverse Proxy (Nginx + Cloudflare)](#reverse-proxy-nginx--cloudflare)
3. [Konfiguration](#konfiguration)
4. [Erste Schritte](#erste-schritte)
5. [KI-Funktionen](#ki-funktionen)
6. [Instagram-Import](#instagram-import)
7. [Update](#update)
8. [Backup & Wiederherstellung](#backup--wiederherstellung)
9. [Entwicklung & Tests](#entwicklung--tests)
10. [Fehlersuche](#fehlersuche)
11. [Sicherheit](#sicherheit)
---
## Installation
Voraussetzungen: Docker mit dem Compose-Plugin (`docker compose version`), Git.
```bash
git clone <gitea-url>/kochbuch.git && cd kochbuch
./scripts/init-env.sh [email protected] # erzeugt .env mit zufälligen Secrets und Admin-Passwort
nano .env # APP_BASE_URL, TRUSTED_PROXIES, APP_PORT prüfen
git clone <gitea-url>/kochbuch.git /opt/kochbuch
cd /opt/kochbuch
./scripts/init-env.sh [email protected] # erzeugt .env mit zufälligen Secrets + Admin-Passwort
nano .env # APP_BASE_URL, APP_PORT, TRUSTED_PROXIES, OPENAI_API_KEY prüfen
docker compose up -d --build
docker compose ps # app sollte nach ~30 s "healthy" sein
docker compose ps # "app" sollte nach ~30 s "healthy" sein
```
Danach mit der Admin-E-Mail und dem ausgegebenen Passwort anmelden. `ADMIN_PASSWORD` kann nach dem ersten
Start aus der `.env` entfernt werden – ein bestehendes Konto wird nie überschrieben.
`init-env.sh` gibt das Admin-Passwort einmal aus. Damit meldest du dich an. Nach dem ersten Start kannst du
`ADMIN_PASSWORD` aus der `.env` löschen – ein bestehendes Konto wird nie überschrieben.
Weitere Personen lädst du unter **Menü → Einladungen** ein. Der Link wird nur einmal angezeigt.
Passwort vergessen? **Menü → Nutzer verwalten → Aktionen**: Reset-Link oder temporäres Passwort.
Ohne das Skript: `cp .env.example .env` und mindestens `SECRET_KEY`, `POSTGRES_PASSWORD`, `APP_BASE_URL`,
`ADMIN_EMAIL` und `ADMIN_PASSWORD` ausfüllen.
Daten liegen in zwei Docker-Volumes: `kochbuch_pgdata` (Datenbank) und `kochbuch_media` (Fotos).
## Reverse Proxy (Nginx + Cloudflare)
Die App spricht nur HTTP auf `APP_PORT`. Beispiel für Nginx:
Die App spricht nur HTTP auf `APP_PORT` (Standard 8000). TLS macht dein Proxy.
```nginx
server {
server_name kochbuch.dammann.vip;
# listen/ssl wie bei deinen anderen Diensten
# listen 443 ssl; Zertifikate … wie bei deinen anderen Diensten
client_max_body_size 30m; # Foto-Uploads
client_max_body_size 40m; # mehrere Handyfotos pro Upload
location / {
proxy_pass http://<docker-host>:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s; # KI-Aufrufe können dauern
proxy_read_timeout 120s; # KI-Aufrufe können bis zu ~90 s dauern
}
}
```
**Client-IP:** Die App wertet `X-Forwarded-For` nur aus, wenn die Anfrage von einer IP aus
`TRUSTED_PROXIES` kommt. Das ist wichtig für das Login-Rate-Limit. Mit Cloudflare gibt es zwei Wege:
**Wichtig – echte Client-IP:** Login-Sperren arbeiten pro IP. Die App wertet `X-Forwarded-For` nur aus, wenn
die Anfrage von einer Adresse aus `TRUSTED_PROXIES` kommt. Hinter Cloudflare sieht Nginx zunächst
Cloudflare-IPs. Zwei Möglichkeiten:
- Nginx ermittelt die echte IP selbst (`set_real_ip_from <Cloudflare-Netze>; real_ip_header CF-Connecting-IP;`).
Dann reicht `TRUSTED_PROXIES=<IP deines Nginx>`.
- Oder in der `.env` `CLIENT_IP_HEADER=CF-Connecting-IP` setzen. Nur sinnvoll, wenn dein Nginx ausschließlich
über Cloudflare erreichbar ist, sonst kann der Header gefälscht werden.
1. **Empfohlen:** Nginx ermittelt die echte IP selbst
```nginx
# in http { } – aktuelle Liste: https://www.cloudflare.com/ips/
set_real_ip_from 173.245.48.0/20;
# … weitere Cloudflare-Netze …
real_ip_header CF-Connecting-IP;
```
und in der `.env`: `TRUSTED_PROXIES=<IP deines Nginx>` (z. B. `172.18.0.0/16`, wenn Nginx im Docker-Netz läuft).
2. Oder in der `.env` `CLIENT_IP_HEADER=CF-Connecting-IP`. Nur sicher, wenn dein Nginx **ausschließlich**
über Cloudflare erreichbar ist – sonst kann der Header gefälscht werden.
Cloudflare-SSL-Modus „Full (strict)“ verwenden. Falls die App „CSRF-Prüfung fehlgeschlagen“ meldet:
stimmt `APP_BASE_URL` exakt mit der Adresse im Browser überein (inkl. `https://`)?
## Konfiguration
Alle Variablen stehen kommentiert in [`.env.example`](.env.example).
Alle Variablen stehen kommentiert in [`.env.example`](.env.example). Die wichtigsten:
## Entwicklung
| Variable | Bedeutung | Standard |
|---|---|---|
| `SECRET_KEY` | Langer Zufallswert (Sessions, Tokens) | – (Pflicht) |
| `APP_BASE_URL` | Öffentliche Adresse, z. B. `https://kochbuch.dammann.vip` | – (Pflicht) |
| `APP_PORT` / `APP_BIND_ADDRESS` | Port/Interface auf dem Host | `8000` / `0.0.0.0` |
| `TRUSTED_PROXIES` | IPs/Netze, deren Forwarded-Headern vertraut wird | private Netze |
| `CLIENT_IP_HEADER` | z. B. `CF-Connecting-IP` (siehe oben) | leer |
| `COOKIE_SECURE` | Cookies nur über HTTPS | `true` |
| `POSTGRES_PASSWORD` | Datenbank-Passwort | – (Pflicht) |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Erster Admin (nur beim ersten Start) | leer |
| `OPENAI_API_KEY` | Schaltet die KI-Funktionen frei | leer = KI aus |
| `OPENAI_MODEL` | Modell für Text und Bilder (muss Vision + Structured Outputs können) | `gpt-5-mini` |
| `OPENAI_REASONING_EFFORT` | Für Reasoning-Modelle; bei anderen Modellen leer lassen | `low` |
| `AI_RATE_LIMIT_PER_USER_PER_DAY` | KI-Aufrufe pro Nutzer/Tag, `0` = unbegrenzt | `0` |
| `AI_RATE_LIMIT_GLOBAL_PER_DAY` | KI-Aufrufe gesamt/Tag, `0` = unbegrenzt | `0` |
| `IMAGE_MAX_UPLOAD_MB` / `IMAGE_MAX_PER_RECIPE` | Foto-Limits | `15` / `10` |
| `LOGIN_MAX_FAILURES_PER_EMAIL` / `_PER_IP` | Login-Sperre nach Fehlversuchen | `5` / `30` in 15 Min. |
| `GUNICORN_WORKERS` / `GUNICORN_THREADS` | Server-Prozesse/Threads | `2` / `4` |
Nach Änderungen an der `.env`: `docker compose up -d` (startet die App mit den neuen Werten neu).
## Erste Schritte
- **Jemanden einladen:** Menü → Einladungen → Link erzeugen und z. B. per WhatsApp schicken. Der Link ist
einmalig, standardmäßig 7 Tage gültig und wird nur einmal angezeigt.
- **Passwort vergessen:** Menü → Nutzer verwalten → Aktionen → „Reset-Link erzeugen“ (24 h gültig) oder
„Temporäres Passwort“ (muss nach dem Login sofort geändert werden).
- **Nutzer entfernen:** „Deaktivieren“ – das Konto kann sich nicht mehr anmelden, seine Rezepte bleiben. Bei
deaktivierten Nutzern gibt es zusätzlich „Rezepte löschen …“.
- **Als App installieren:** Im Handy-Browser „Zum Startbildschirm hinzufügen“.
**Zutaten eingeben:** eine Zutat pro Zeile, z. B. `200 g Mehl, gesiebt` oder `2-3 Zehen Knoblauch`. Eine Zeile
mit Doppelpunkt am Ende (`Für die Soße:`) startet eine Gruppe. Schritte durch Leerzeilen trennen.
## KI-Funktionen
Mit `OPENAI_API_KEY` stehen zur Verfügung:
- „✨ Mit KI aufbereiten“ im Rezeptformular: strukturiert deinen Text, formuliert Schritte, schlägt Tags vor.
- Import per Foto/Screenshot (Kochbuchseite, Screenshot einer App oder eines Instagram-Beitrags).
- Import per eingefügtem Text und als Fallback für Webseiten ohne strukturierte Rezeptdaten.
Die KI liefert über *Structured Outputs* (striktes JSON-Schema) ein Ergebnis, das zusätzlich serverseitig
validiert wird. Gespeichert wird nichts ohne deine Bestätigung. Fotos für die Erkennung werden verkleinert
an OpenAI geschickt und nicht gespeichert.
**Ohne Key** funktioniert alles andere normal; KI-Buttons sind ausgeblendet bzw. zeigen einen Hinweis.
**Kosten:** Ein Aufruf kostet mit `gpt-5-mini` typischerweise deutlich unter einem Cent (Bilder etwas mehr).
Als Kostenbremse kannst du `AI_RATE_LIMIT_*` setzen und bei OpenAI ein Monatslimit für das Projekt hinterlegen.
Meldet die App „Modell nicht verfügbar“, setze `OPENAI_MODEL` auf ein aktuelles Modell deines Accounts.
## Instagram-Import
Instagram bietet keinen Weg mehr, Beiträge ohne Login auszulesen. Die offizielle oEmbed-Schnittstelle
braucht eine Meta-App und liefert keine Beitragstexte. Die App macht deshalb **einen** ehrlichen Abruf der
öffentlichen Einbettungsseite des Beitrags – ohne Login, Cookies oder Tricks. Instagram blockiert das von
Servern aus häufig. Dann zeigt die App direkt die zuverlässigen Alternativen:
1. **Screenshot** des Beitrags (mit ausgeklappter Beschreibung) über „Foto oder Screenshot“ importieren, oder
2. Beschreibung kopieren und bei „Text einfügen“ einfügen (der Link wird als Quelle übernommen).
Am Android-Handy geht's am schnellsten über **Teilen → Kochbuch** (App vorher installieren).
## Update
```bash
cd /opt/kochbuch
./scripts/backup.sh # vorher sichern
git pull
docker compose up -d --build # Migrationen laufen beim Start automatisch
docker image prune -f # alte Images aufräumen (optional)
```
PostgreSQL bleibt auf Version 17 (`postgres:17-alpine`). Ein Wechsel der Major-Version braucht
Backup → neues Volume → Restore.
## Backup & Wiederherstellung
**Backup** (Datenbank, Fotos und `.env` in einem Archiv, die neuesten 14 bleiben erhalten):
```bash
./scripts/backup.sh # nach ./backups
./scripts/backup.sh /mnt/nas/kochbuch # anderes Ziel
KEEP=30 ./scripts/backup.sh # 30 Archive behalten
```
Täglich per Cron (`crontab -e`):
```cron
30 3 * * * cd /opt/kochbuch && ./scripts/backup.sh /mnt/nas/kochbuch >> /var/log/kochbuch-backup.log 2>&1
```
Das Archiv enthält die `.env` mit Passwörtern – entsprechend geschützt ablegen.
**Wiederherstellen** (ersetzt alle aktuellen Daten):
```bash
./scripts/restore.sh backups/kochbuch-20261030-033000.tar.gz
```
Auf einem neuen Server: Repository klonen, Archiv hineinkopieren, `restore.sh` ausführen – fehlt die `.env`,
wird sie aus dem Backup übernommen. Danach `docker compose up -d --build`.
## Entwicklung & Tests
```bash
python -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
python -m pytest # Tests (SQLite)
python scripts/dev_server.py # http://localhost:8000
docker compose --profile test run --rm test # Tests gegen PostgreSQL
python -m pytest # Tests (SQLite, ohne Netzwerk)
python scripts/dev_server.py # http://localhost:8000, Login: [email protected]
docker compose --profile test run --rm test # dieselben Tests gegen PostgreSQL
```
Die Tests decken u. a. ab: Anmeldung, Einladungen, Resets, Rollen, Sichtbarkeit privater Rezepte über alle
URLs inkl. Bildern, Upload-Validierung, Parser, KI-Antwortvalidierung und Fehlerbehandlung, SSRF-Schutz,
Link-/Instagram-Import. Architektur und Konventionen: [`CLAUDE.md`](CLAUDE.md).
## Fehlersuche
| Problem | Lösung |
|---|---|
| `docker compose ps` zeigt `unhealthy` | `docker compose logs app` – meist fehlt `SECRET_KEY` oder die DB ist nicht erreichbar |
| Login klappt nicht, Seite lädt neu | Läuft der Zugriff über HTTPS? Lokal ohne HTTPS: `COOKIE_SECURE=false` |
| „CSRF-Prüfung fehlgeschlagen“ | `APP_BASE_URL` muss exakt der Browser-Adresse entsprechen; Nginx muss `Host` weiterreichen |
| Alle werden gleichzeitig ausgesperrt | Client-IP falsch erkannt → Abschnitt Reverse Proxy (`TRUSTED_PROXIES`, `CLIENT_IP_HEADER`) |
| Foto-Upload bricht ab (413) | `client_max_body_size` in Nginx erhöhen |
| KI: „Modell nicht verfügbar“ / „API-Key ungültig“ | `OPENAI_MODEL` bzw. `OPENAI_API_KEY` in `.env` prüfen, `docker compose up -d` |
| KI: Zeitüberschreitung | `proxy_read_timeout` ≥ 120 s; ggf. `OPENAI_REASONING_EFFORT=minimal` |
## Sicherheit
- Registrierung nur per Einladung; Einladungs- und Reset-Links werden nur gehasht gespeichert.
- Passwörter mit Argon2; Login-Sperre pro E-Mail und IP; CSRF-Schutz auf allen Formularen.
- Strikte Content-Security-Policy, HSTS, `X-Frame-Options: DENY`, `__Host-`-Cookies (Secure, HttpOnly, SameSite).
- Sichtbarkeit wird bei jedem Zugriff serverseitig geprüft; Fotos werden nur über eine geprüfte URL ausgeliefert.
- Uploads: Typ-Prüfung durch Dekodieren, Größenlimit, Neu-Kodierung (entfernt EXIF/GPS).
- Abruf fremder URLs nur zu öffentlichen IP-Adressen (Schutz vor Zugriffen ins Heimnetz).
- Keine Secrets im Repository – alles über `.env` (in `.gitignore`).