From f96cbe965e73bd0e55dd8b83c5f0aeb2f949df30 Mon Sep 17 00:00:00 2001 From: Kyle Dammann Date: Fri, 30 Oct 2026 21:56:01 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 6 +- Dockerfile | 5 +- README.md | 227 +++++++++++++++++++++++++++++++++++----- config/settings_test.py | 17 +++ scripts/backup.sh | 37 +++++++ scripts/restore.sh | 44 ++++++++ 6 files changed, 305 insertions(+), 31 deletions(-) create mode 100755 scripts/backup.sh create mode 100755 scripts/restore.sh diff --git a/CLAUDE.md b/CLAUDE.md index 32d4f9e..4521d0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ port; TLS, domain and proxy are out of scope. - [x] Phase 3: Comments, serving calculator (server-side `?portionen=`), cooking mode (wake lock) - [x] Phase 4: AI (OpenAI): "Mit KI aufbereiten" on the form + photo/screenshot and text import - [x] Phase 5: Link import (schema.org JSON-LD, Instagram best effort + paste/screenshot fallback, PWA share target) -- [ ] Phase 6: Backup script, full README +- [x] Phase 6: Backup/restore scripts, full README ## Stack Django 5.2 LTS (Python 3.12), server-rendered templates, PostgreSQL 17, @@ -45,7 +45,9 @@ no JS build step: plain forms with small progressive enhancements in `static/js/ `scripts/make_icons.py`. - `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`. - `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn. -- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker). +- `scripts/` – `init-env.sh` (creates .env with secrets), `backup.sh` / `restore.sh` (pg_dump + media + .env + in one archive, retention via KEEP), `dev_server.py` (local run without Docker), `make_icons.py`. +- In the container `MEDIA_ROOT=/data/media` (volume `media`); never change without migrating files. ## Commands ```bash diff --git a/Dockerfile b/Dockerfile index 587eecf..964ae88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,8 @@ FROM python:3.12-slim AS base ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + MEDIA_ROOT=/data/media RUN groupadd --system --gid 1000 app \ && useradd --system --uid 1000 --gid app --home /app app @@ -30,6 +31,6 @@ COPY requirements-dev.txt . RUN pip install -r requirements-dev.txt USER app ENTRYPOINT [] -CMD ["pytest"] +CMD ["pytest", "-p", "no:cacheprovider"] FROM base AS production diff --git a/README.md b/README.md index dda9cfe..682a5d3 100644 --- a/README.md +++ b/README.md @@ -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 /kochbuch.git && cd kochbuch -./scripts/init-env.sh deine@mail.de # erzeugt .env mit zufälligen Secrets und Admin-Passwort -nano .env # APP_BASE_URL, TRUSTED_PROXIES, APP_PORT prüfen +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 +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://: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 ; real_ip_header CF-Connecting-IP;`). - Dann reicht `TRUSTED_PROXIES=`. -- 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=` (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: 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`). diff --git a/config/settings_test.py b/config/settings_test.py index f9db873..3f3af35 100644 --- a/config/settings_test.py +++ b/config/settings_test.py @@ -15,3 +15,20 @@ STORAGES = { # Static files are served by the finders in tests; no collectstatic needed. WHITENOISE_USE_FINDERS = True WHITENOISE_AUTOREFRESH = True + +# Deterministic defaults, independent of a real .env (the Docker test service loads it). +import ipaddress # noqa: E402 + +OPENAI_API_KEY = "" +OPENAI_MODEL = "gpt-5-mini" +OPENAI_REASONING_EFFORT = "low" +AI_RATE_LIMIT_PER_USER_PER_DAY = 0 +AI_RATE_LIMIT_GLOBAL_PER_DAY = 0 +COMMENT_RATE_LIMIT_PER_HOUR = 30 +LOGIN_MAX_FAILURES_PER_EMAIL = 5 +LOGIN_MAX_FAILURES_PER_IP = 30 +LOGIN_LOCKOUT_MINUTES = 15 +IMAGE_MAX_UPLOAD_MB = 15 +IMAGE_MAX_PER_RECIPE = 10 +CLIENT_IP_HEADER = "" +TRUSTED_PROXIES = [ipaddress.ip_network(n) for n in ("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")] diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..8886c38 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Backup of database, photos and .env into one archive. +# +# ./scripts/backup.sh [target-dir] default target: ./backups +# KEEP=30 ./scripts/backup.sh keep the newest 30 archives (default 14) +# +# Cron example (daily 03:30): +# 30 3 * * * cd /opt/kochbuch && ./scripts/backup.sh /mnt/nas/kochbuch >> backups/backup.log 2>&1 +set -eu +cd "$(dirname "$0")/.." + +TARGET=${1:-./backups} +KEEP=${KEEP:-14} +STAMP=$(date +%Y%m%d-%H%M%S) +umask 077 +mkdir -p "$TARGET" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +echo "[$(date)] Sichere Datenbank …" +docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom' > "$WORK/db.dump" + +echo "[$(date)] Sichere Fotos …" +docker compose exec -T app tar -C /data -czf - media > "$WORK/media.tar.gz" + +# .env contains the secrets needed to bring the backup up on a new server. +cp .env "$WORK/env.backup" + +ARCHIVE="$TARGET/kochbuch-$STAMP.tar.gz" +tar -C "$WORK" -czf "$ARCHIVE" db.dump media.tar.gz env.backup +echo "[$(date)] Fertig: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))" + +# Retention: delete everything but the newest $KEEP archives. +ls -1t "$TARGET"/kochbuch-*.tar.gz 2>/dev/null | tail -n +"$((KEEP + 1))" | while read -r old; do + rm -f -- "$old" + echo "Altes Backup gelöscht: $old" +done diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..75c831d --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Restores an archive created by backup.sh. ALL current data is replaced. +# +# ./scripts/restore.sh backups/kochbuch-20261030-033000.tar.gz [--yes] +set -eu +cd "$(dirname "$0")/.." + +ARCHIVE=${1:-} +if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then + echo "Aufruf: $0 [--yes]" >&2 + exit 1 +fi +if [ "${2:-}" != "--yes" ]; then + printf "Alle aktuellen Rezepte, Nutzer und Fotos werden durch das Backup ersetzt. Fortfahren? [j/N] " + read -r answer + case "$answer" in j|J|ja|Ja) ;; *) echo "Abgebrochen."; exit 1 ;; esac +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +tar -C "$WORK" -xzf "$ARCHIVE" + +if [ ! -f .env ]; then + cp "$WORK/env.backup" .env + chmod 600 .env + echo ".env aus dem Backup wiederhergestellt." +fi + +echo "Starte Datenbank …" +docker compose up -d db +until docker compose exec -T db sh -c 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; do sleep 2; done + +echo "Stoppe App …" +docker compose stop app || true + +echo "Spiele Datenbank ein …" +docker compose exec -T db sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --single-transaction' < "$WORK/db.dump" + +echo "Spiele Fotos ein …" +docker compose run --rm --no-deps -T --entrypoint sh app -c 'find /data/media -mindepth 1 -delete && tar -C /data -xzf -' < "$WORK/media.tar.gz" + +echo "Starte App …" +docker compose up -d app +echo "Wiederherstellung abgeschlossen."