Files
Kochbuch/README.md
T
Kyle DammannandClaude Opus 5.5 f96cbe965e 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]>
2026-10-30 21:56:01 +01:00

240 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kochbuch
Selbst gehostetes Kochbuch für Familie und Freunde – fürs Handy gemacht.
- **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 /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
```
`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.
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` (Standard 8000). TLS macht dein Proxy.
```nginx
server {
server_name kochbuch.dammann.vip;
# listen 443 ssl; Zertifikate … wie bei deinen anderen Diensten
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 bis zu ~90 s dauern
}
}
```
**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:
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). Die wichtigsten:
| 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, 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`).