diff --git a/.env.example b/.env.example index c20d010..f9bf3e4 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,7 @@ APP_SHORT_NAME=Einkauf # Einziger nach aussen veroeffentlichter Port. Dahinter liegt nginx, # das sowohl die Oberflaeche als auch /api/ ausliefert. Der api-Container # hat bewusst KEINEN eigenen Port nach aussen. -HTTP_PORT=46600 +HTTP_PORT=8080 # Mailpit-Weboberflaeche, nur mit "--profile dev". An 127.0.0.1 gebunden. MAILPIT_PORT=8025 @@ -29,14 +29,14 @@ MAILPIT_PORT=8025 # Basis-URL, wie Nutzer die App im Browser erreichen - inklusive Port, # falls kein vorgelagerter Reverse Proxy auf 80/443 steht. # Landet in Verifikations- und Einladungsmails, muss also stimmen. -PUBLIC_BASE_URL=http://einkauf.example.de:46600 +PUBLIC_BASE_URL=http://einkauf.example.de:8080 # Welchen Absendern von X-Forwarded-For die API glauben darf. # "*" ist in Ordnung, solange api keinen oeffentlichen Port hat. FORWARDED_ALLOW_IPS=* # Cookie nur über HTTPS ausliefern. -# false bei direktem HTTP-Zugriff auf Port 46600 +# false bei direktem HTTP-Zugriff auf Port 8080 # true sobald ein Reverse Proxy mit TLS davorsteht (dann muss # PUBLIC_BASE_URL ebenfalls auf https:// zeigen) # Ein Secure-Cookie über reines HTTP wird vom Browser verworfen - die diff --git a/.gitignore b/.gitignore index 3f2c607..d4c2f0e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ dist/ .vscode/ *.swp .DS_Store -*.zip \ No newline at end of file +*.zip +doku/ diff --git a/README.md b/README.md index de1a43b..8dc005c 100644 --- a/README.md +++ b/README.md @@ -1,1460 +1,287 @@ -# Einkaufsapp – Phase 0 & 1 +# Einkaufsliste -Backend-Grundgerüst mit Registrierung, Mailverifikation, Login und -Administrationsschalter, dazu nginx als einziger Eintrittspunkt. +Selbst gehostete Einkaufslisten-App für Haushalt, WG oder Verein. Läuft als +Progressive Web App auf dem Telefon, funktioniert ohne Verbindung weiter und +gleicht ab, sobald wieder Netz da ist. -## Ports +Keine Werbung, kein Tracking, keine externen Schriften oder Skripte. Alle Daten +bleiben auf dem eigenen Server. -Nach außen ist genau **ein** Port offen: `HTTP_PORT` aus der `.env`, -voreingestellt `46600`. Dahinter liegt nginx und verteilt: +## Funktionen -| Pfad | Ziel | +- **Listen teilen** – per Einladung mit Rollen (lesen, bearbeiten, verwalten) + oder über einen zeitlich begrenzten öffentlichen Link +- **Offline nutzbar** – Änderungen werden lokal festgehalten und später + nachgereicht, ohne dass etwas verlorengeht +- **Nach Märkten und Warengruppen sortiert** – die Reihenfolge lässt sich an + den Weg durch den Laden anpassen +- **Strichcodes scannen** – eigener EAN-Decoder, Produktdaten von Open Food + Facts (Abfrage über den Server, nicht vom Gerät) +- **Preise erfassen und vergleichen** – je Markt, ohne Personenbezug +- **Push-Benachrichtigungen** bei Änderungen, gedrosselt auf höchstens eine + Meldung je Liste alle zwei Stunden +- **Druckansicht** auf A4, nach Märkten gegliedert +- **Benutzerverwaltung** mit einstellbaren Fristen für Bereinigung und Archiv + +## Technik + +| Schicht | Verwendet | |---|---| -| `/` | statische Dateien (ab Phase 3 die gebaute PWA) | -| `/api/…` | FastAPI | -| `/docs`, `/openapi.json` | FastAPI-Dokumentation | -| `/healthz`, `/readyz` | Statusprüfungen | +| Backend | Python 3.12, FastAPI, SQLAlchemy 2, Alembic | +| Datenbank | MariaDB 11.4 | +| Frontend | ES-Module, kein Framework, kein Bauschritt | +| Auslieferung | nginx | +| Betrieb | Docker Compose, drei Container | -Der `api`-Container hat keinen veröffentlichten Port. Wenn du ihn zum -Debuggen doch direkt erreichen willst, leg eine -`docker-compose.override.yml` an: +## Voraussetzungen + +- Docker mit Compose-Plugin +- Ein SMTP-Zugang für Bestätigungs- und Einladungsmails +- Für den öffentlichen Betrieb: ein Reverse Proxy mit TLS (siehe unten) + +## Installation + +```bash +git clone einkaufsapp +cd einkaufsapp +cp .env.example .env +``` + +In der `.env` mindestens setzen: + +```ini +DB_PASSWORD=… # eigenes Passwort +DB_ROOT_PASSWORD=… # anderes Passwort +ADMIN_EMAIL=du@example.de # das erste Konto +ADMIN_INITIAL_PASSWORD=… # mind. 12 Zeichen, danach ändern +PUBLIC_BASE_URL=https://einkauf.example.de +COOKIE_SECURE=true # false nur beim Testen ohne TLS + +SMTP_HOST=mail.example.de +SMTP_PORT=587 +SMTP_SECURITY=starttls +SMTP_USER=… +SMTP_PASSWORD=… +SMTP_FROM=einkauf@example.de +``` + +Prüfen und starten: + +```bash +bash tools/check-env.sh .env +docker compose up -d +docker compose logs -f api +``` + +Beim ersten Start legt die Anwendung das Administratorkonto an und führt die +Datenbankmigrationen aus. Danach ist sie unter `http://:8080/` erreichbar. + +**Nach der ersten Anmeldung:** Passwort ändern und `ADMIN_INITIAL_PASSWORD` in +der `.env` leeren – der Wert wird danach ignoriert, stünde aber weiter im +Klartext in der Umgebung. + +### Push-Benachrichtigungen (freiwillig) + +```bash +python3 tools/vapid-keys.py +``` + +Die drei ausgegebenen Zeilen in die `.env` übernehmen – **vorhandene Zeilen +ersetzen, nicht ergänzen.** Bei doppelten Einträgen nimmt Docker Compose den +letzten, und das ist oft die leere Vorlagenzeile. + +```bash +docker compose up -d +``` + +## TLS und Reverse Proxy + +> **Die Anwendung fordert keine Zertifikate an und verwaltet sie nicht.** +> Sie liefert ausschließlich HTTP auf dem konfigurierten Port aus. Für TLS ist +> ein vorgeschalteter Reverse Proxy zuständig. + +Ohne HTTPS fehlen wesentliche Funktionen: Service Worker, Kamerazugriff zum +Scannen und Push-Benachrichtigungen sind an einen sicheren Kontext gebunden. +Für lokale Tests gilt `localhost` als sicher, jede andere Adresse nicht. + +### Variante A: nginx davor + +```nginx +server { + listen 443 ssl http2; + server_name einkauf.example.de; + + ssl_certificate /etc/letsencrypt/live/einkauf.example.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/einkauf.example.de/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Server-Sent Events: ohne diese beiden Zeilen bekommt die App + # keine Änderungen mit, die andere gemacht haben. + proxy_buffering off; + proxy_read_timeout 3600s; + } +} +``` + +**Wichtig:** Setze am vorgelagerten Proxy **keine eigene +Content-Security-Policy.** Der mitgelieferte nginx bringt bereits eine mit. +Zwei Richtlinien werden vom Browser als Schnittmenge angewandt – die Anwendung +hört dann ohne erkennbaren Fehler auf zu funktionieren. + +Die übrigen Sicherheitskopfzeilen (HSTS, `X-Frame-Options`, +`Referrer-Policy`) doppelt zu setzen ist unschädlich. + +### Variante B: Traefik + +`docker-compose.override.yml` im Projektverzeichnis: ```yaml services: - api: - ports: - - "127.0.0.1:8000:8000" + web: + ports: [] # kein direkter Port mehr nötig + networks: [einkaufsapp, traefik] + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik" + - "traefik.http.routers.einkauf.rule=Host(`einkauf.example.de`)" + - "traefik.http.routers.einkauf.entrypoints=websecure" + - "traefik.http.routers.einkauf.tls.certresolver=le" + - "traefik.http.services.einkauf.loadbalancer.server.port=80" + # SSE: Antworten nicht puffern + - "traefik.http.services.einkauf.loadbalancer.responseforwarding.flushinterval=100ms" + +networks: + traefik: + external: true ``` -Die Bindung an `127.0.0.1` ist wichtig – sonst hängt eine ungeschützte API -am öffentlichen Interface. +Traefik setzt `X-Forwarded-*` von sich aus. Auch hier gilt: keine zusätzliche +CSP über eine Middleware. -## Start +### Nach dem Einrichten prüfen ```bash -cp .env.example .env -# DB_PASSWORD, DB_ROOT_PASSWORD und ADMIN_EMAIL setzen - -docker compose --profile dev up -d --build -docker compose ps -docker compose logs -f api web +curl -sI https://einkauf.example.de | grep -i content-security-policy ``` -Erwartete Log-Ausgabe: +Es darf **genau eine** Zeile erscheinen. -``` -[entrypoint] Warte auf Datenbank db:3306 ... -[wait_for_db] Datenbank erreichbar. -[entrypoint] Migrationen einspielen ... -INFO [alembic.runtime.migration] Running upgrade -> 0001 -[entrypoint] Starte API. -INFO: Application startup complete. -``` +## Betrieb -### Ersteinrichtung im Browser - -1. `ADMIN_INITIAL_PASSWORD` in der `.env` setzen -2. `docker compose up -d` – **nicht** `restart`: ein Neustart übernimmt keine - geänderten Werte aus der `.env`, der Container muss neu angelegt werden -3. `http://:46600/` aufrufen, mit `ADMIN_EMAIL` und dem Startpasswort - anmelden -4. Die Oberfläche verlangt sofort ein neues Passwort – erst danach ist alles - andere erreichbar -5. `ADMIN_INITIAL_PASSWORD` wieder aus der `.env` entfernen - -Ohne Startpasswort führt derselbe Weg über „Konto anlegen"; dann brauchst du -für die Bestätigungsmail allerdings einen funktionierenden SMTP-Relay oder -Mailpit. - -Oberfläche: `http://:46600/` -Interaktive API-Dokumentation: `http://:46600/docs` -Abgefangene Mails (nur mit `--profile dev`): `http://localhost:8025` - -## Durchklicken +### Aktualisieren ```bash -curl -s localhost:46600/readyz - -# 1. Registrieren -curl -s -X POST localhost:46600/api/auth/register \ - -H 'Content-Type: application/json' \ - -d '{"email":"admin@example.com","password":"ein-langes-testpasswort"}' - -# 2. Link aus Mailpit (http://localhost:8025) im Browser öffnen -# oder: curl -s "localhost:46600/api/auth/verify?token=" - -# 3. Anmelden – Cookies in eine Datei schreiben -curl -s -c cookies.txt -X POST localhost:46600/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"email":"admin@example.com","password":"ein-langes-testpasswort"}' - -# 4. Eigenes Profil lesen (nur Session-Cookie nötig) -curl -s -b cookies.txt localhost:46600/api/auth/me - -# 5. Schreibender Request braucht zusätzlich den CSRF-Header -CSRF=$(grep ea_csrf cookies.txt | awk '{print $7}') -curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \ - -X PUT localhost:46600/api/admin/settings \ - -H 'Content-Type: application/json' \ - -d '{"allow_self_registration": false}' +cd einkaufsapp +git pull +bash tools/check-all.sh # alle Prüfungen +docker compose up -d --build +docker compose logs --tail=30 api ``` -Schritt 5 ohne den Header muss mit `403 CSRF-Token fehlt oder ungültig` -abgewiesen werden – das ist der Test, ob der Schutz greift. +Migrationen laufen beim Start automatisch. **Vorher sichern** – Migrationen +lassen sich nicht immer zurücknehmen. -### Mailversand prüfen +### Sichern ```bash -# Verbindung zum Relay, ohne etwas zu senden -curl -s -b cookies.txt localhost:46600/api/admin/mail/check - -# Testnachricht -curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \ - -X POST localhost:46600/api/admin/mail/test \ - -H 'Content-Type: application/json' \ - -d '{"to":"dein-postfach@example.de"}' - -docker compose logs --tail=20 api +docker compose exec -T db mariadb-dump -u root -p"$DB_ROOT_PASSWORD" \ + --single-transaction --quick --routines --events \ + --default-character-set=utf8mb4 einkaufsapp | gzip > einkaufsapp-$(date +%F).sql.gz ``` -## Was hier absichtlich so ist +Sichere den **Datenbankauszug**, nicht das Volume: Ein Dateisystemabbild einer +laufenden Datenbank ist nicht zuverlässig wiederherstellbar. `--single-transaction` +liefert einen konsistenten Stand, ohne die Anwendung anzuhalten. -**Erstes Administratorkonto – zwei Wege.** +Die `.env` gehört ebenfalls gesichert – aber getrennt und verschlüsselt. Sie +enthält Datenbankpasswort, SMTP-Zugangsdaten und den privaten VAPID-Schlüssel. -*Ohne Startpasswort* (`ADMIN_INITIAL_PASSWORD` leer): Du registrierst dich -regulär über `POST /api/auth/register`. Stimmt die Adresse mit `ADMIN_EMAIL` -überein, wird das Konto zum Administrator. Kein Passwort liegt jemals in der -Umgebung – dafür brauchst du für die Bestätigung entweder Mailpit oder ein -`UPDATE` in der Datenbank. +Ausführlich: [`docs/betrieb.md`](docs/betrieb.md) -*Mit Startpasswort*: Setz `ADMIN_INITIAL_PASSWORD` in der `.env`. Beim ersten -Start legt die Anwendung das Konto an, bestätigt es sofort und markiert es mit -`must_change_password`. Bis das Passwort geändert ist, antworten alle Routen -außer `GET /api/auth/me` und `POST /api/auth/password/change` mit `403`. +### Prüfskripte -Die Absicherungen sind bewusst eng: Das Konto entsteht nur, wenn die Datenbank -noch **gar keinen** Nutzer enthält. Ein vergessener Eintrag in der `.env` kann -damit weder ein bestehendes Konto überschreiben noch ein zweites Admin-Konto -nachschieben – die Anwendung protokolliert stattdessen eine Warnung. +```bash +bash tools/check-all.sh +``` -Trotzdem: Nach der ersten Anmeldung gehört der Wert aus der `.env` entfernt. -Er steht dort im Klartext, liegt in jedem Backup und ist über -`docker inspect einkaufsapp_api` für jeden lesbar, der Zugriff auf den Host -hat. Für Docker Secrets gibt es `ADMIN_INITIAL_PASSWORD_FILE`. +Prüft Konfiguration, Berechtigungen aller Endpunkte, Datenbankschema gegen die +Modelle, nginx-Kopfzeilen, JavaScript-Module, Fristen und den Strichcode-Decoder. +Rückgabewert 1 bei Problemen – als Git-Hook oder in einer CI verwendbar. -**Registrierung antwortet immer gleich.** Ob eine Adresse schon existiert, -verrät die API nicht – weder über den Statuscode noch über die Laufzeit. Beim -Login läuft die Passwortprüfung auch bei unbekanntem Konto gegen einen -Dummy-Hash, damit die Antwortzeit konstant bleibt. +### Funktionstest gegen eine laufende Instanz -**Rate Limiting sowohl pro IP als auch pro Konto.** Nur pro IP zu begrenzen -hilft nicht gegen verteilte Angriffe auf ein einzelnes Konto; nur pro Konto zu -begrenzen macht das Aussperren fremder Nutzer trivial. +```bash +bash tools/smoke-test.sh http://localhost:8080 admin@example.de deinpasswort +``` -**Produktivbetrieb ist die Voreinstellung.** Der `api`-Container läuft mit -`restart: unless-stopped`, ohne Bind-Mount und ohne `--reload` – es läuft der -Code aus dem gebauten Abbild. +Legt eine Liste an, fügt Einträge hinzu, hakt ab, räumt auf und löscht sich +selbst wieder. Nützlich nach einem Update. -Zum Entwickeln: +### Entwicklungsmodus ```bash cp docker-compose.override.yml.example docker-compose.override.yml docker compose up -d ``` -Compose zieht eine vorhandene `docker-compose.override.yml` automatisch mit -heran. Sie bindet `./backend` ein, schaltet `--reload` an und setzt -`restart: "no"`, damit ein Startfehler zu einem stehenden Container führt statt -zu einer Endlosschleife im Log. Die Datei steht in `.gitignore` und gehört -nicht auf den Produktivserver. +Bindet den Quelltext ein und startet uvicorn bei Änderungen neu. **Die Datei +gehört nicht auf den Produktivserver** – dort liefe sonst Code vom Host statt +aus dem gebauten Abbild. -**Ohne Override wirken Änderungen am Quelltext erst nach einem Neubau:** +## Rechtliches -```bash -docker compose up -d --build -``` +`web/html/impressum.html` und `web/html/datenschutz.html` sind **Entwürfe** mit +Platzhaltern. Vor der Veröffentlichung ausfüllen und prüfen lassen – die +Hinweiskästen im Dokument nennen, worauf zu achten ist. -**Selbstregistrierung: drei Zustände statt zwei.** `ALLOW_SELF_REGISTRATION` -kennt `true`, `false` und `admin`. Bei `true`/`false` ist der Wert fest -verdrahtet, die Admin-API antwortet auf Änderungsversuche mit `409` und nennt -den Grund. Bei `admin` entscheidet die Datenbankeinstellung und der -Administrator darf zur Laufzeit umschalten. `SELF_REGISTRATION_DEFAULT` ist -dann der Startwert beim allerersten Start. +Beide sind aus jeder Ansicht über die Fußzeile erreichbar. -Die Alternative wäre gewesen, die Umgebungsvariable bei jedem Neustart über die -Admin-Einstellung schreiben zu lassen – dann wäre eine Abschaltung nach dem -nächsten `docker compose up` still wieder verschwunden. +## Dokumentation -**SMTP-Relay.** `SMTP_SECURITY` ersetzt die vorherigen Flags `SMTP_STARTTLS` -und `SMTP_SSL`; erlaubt sind `none`, `starttls` und `ssl`. Beim Start prüft die -Anwendung, dass keine Zugangsdaten über eine unverschlüsselte Verbindung gehen, -und bricht sonst mit einer klaren Meldung ab. Details zu SPF, DKIM, DMARC und -PTR stehen in `docs/mail-zustellbarkeit.md`. - -**Ein Port statt zwei.** Oberfläche und API teilen sich dieselbe Origin. -Damit entfällt CORS vollständig, Cookies verhalten sich vorhersagbar, und der -Service Worker in Phase 4 kann API-Antworten ohne Sonderregeln cachen. Zwei -Ports hätten drei zusätzliche Fehlerquellen eingeführt, ohne etwas zu gewinnen. - -**`COOKIE_SECURE` muss zum Zugriffsweg passen.** Bei direktem HTTP-Zugriff auf -Port 46600 gehört `false` in die `.env`. Steht ein Reverse Proxy mit TLS davor -– bei dir vermutlich OPNsense –, dann `true` und `PUBLIC_BASE_URL` auf -`https://…`. Ein `Secure`-Cookie über reines HTTP wird vom Browser verworfen, -und die Anmeldung scheitert dann ohne verständliche Fehlermeldung. - -**CSP ohne `unsafe-inline`.** Die Platzhalterseite lagert deshalb CSS und -JavaScript in eigene Dateien aus. Wenn du dort etwas ergänzt: Inline-`