# 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 /kochbuch.git /opt/kochbuch cd /opt/kochbuch ./scripts/init-env.sh deine@mail.de # 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://: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=` (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: admin@kochbuch.localhost 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`).