Einkaufsapp Phase 0 & 1

Backend-Grundgerüst mit Registrierung, Mailverifikation, Login und Administrationsschalter, dazu nginx als einziger Eintrittspunkt.

Ports

Nach außen ist genau ein Port offen: HTTP_PORT aus der .env, voreingestellt 46600. Dahinter liegt nginx und verteilt:

Pfad Ziel
/ statische Dateien (ab Phase 3 die gebaute PWA)
/api/… FastAPI
/docs, /openapi.json FastAPI-Dokumentation
/healthz, /readyz Statusprüfungen

Der api-Container hat keinen veröffentlichten Port. Wenn du ihn zum Debuggen doch direkt erreichen willst, leg eine docker-compose.override.yml an:

services:
  api:
    ports:
      - "127.0.0.1:8000:8000"

Die Bindung an 127.0.0.1 ist wichtig sonst hängt eine ungeschützte API am öffentlichen Interface.

Start

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

Erwartete Log-Ausgabe:

[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.

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://<host>: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://<host>:46600/ Interaktive API-Dokumentation: http://<host>:46600/docs Abgefangene Mails (nur mit --profile dev): http://localhost:8025

Durchklicken

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=<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}'

Schritt 5 ohne den Header muss mit 403 CSRF-Token fehlt oder ungültig abgewiesen werden das ist der Test, ob der Schutz greift.

Mailversand prüfen

# 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

Was hier absichtlich so ist

Erstes Administratorkonto zwei Wege.

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.

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.

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.

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.

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.

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.

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.

Zum Entwickeln:

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.

Ohne Override wirken Änderungen am Quelltext erst nach einem Neubau:

docker compose up -d --build

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.

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.

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-<script> und <style> werden vom Browser blockiert.

Migrationen statt create_all(). Ab jetzt ist jede Schemaänderung eine Alembic-Revision. Neue Revision erzeugen:

docker compose exec api alembic revision --autogenerate -m "beschreibung"
docker compose exec api alembic upgrade head

Phase 2 Listen und Katalog

Neue Endpunkte, alle unterhalb von /api/lists:

Methode Pfad Rolle
GET / POST /api/lists
GET / PATCH / DELETE /api/lists/{id} viewer / owner / owner
GET /api/lists/{id}/members viewer
PUT / DELETE /api/lists/{id}/members/{user} owner
GET / POST /api/lists/{id}/markets viewer / editor
PUT / DELETE /api/lists/{id}/markets/{mid} editor
GET / POST /api/lists/{id}/categories viewer / editor
PUT / DELETE /api/lists/{id}/categories/{cid} editor
GET / POST /api/lists/{id}/articles viewer / editor
GET /api/lists/{id}/articles/by-barcode/{code} viewer
PATCH / DELETE /api/lists/{id}/articles/{aid} editor
GET / POST /api/lists/{id}/items viewer / editor
PATCH / DELETE /api/lists/{id}/items/{iid} editor
POST /api/lists/{id}/items/clear-bought editor
GET /api/lists/{id}/view viewer

Migration einspielen und durchtesten:

docker compose restart api        # entrypoint.sh führt alembic upgrade head aus
docker compose logs --tail=20 api

./smoke-phase2.sh http://localhost:46600 admin@example.de deinpasswort

Rollen

viewer liest, editor ändert Einträge und Katalog, owner verwaltet zusätzlich Mitgliedschaften und kann die Liste umbenennen oder löschen. Wer kein Mitglied ist, bekommt 404 statt 403 sonst ließe sich über die Statuscodes herausfinden, welche Listen-IDs existieren.

Revisionszähler

Jede Liste hat einen Zähler rev. Jede Änderung erhöht ihn und schreibt den neuen Wert nach row_rev der geänderten Zeile. Ein Client kann damit später fragen: „gib mir alles mit row_rev > N". Der Zähler wird unter SELECT … FOR UPDATE fortgeschrieben, damit zwei gleichzeitige Änderungen nicht dieselbe Nummer bekommen sonst verpasst ein synchronisierender Client eine davon.

clear-bought verbraucht bewusst nur eine Revision für alle entfernten Einträge. So sieht ein Client eine geschlossene Änderung statt dutzender einzelner.

Löschen

Überall Soft Delete über deleted_at. Ein Gerät, das eine Woche offline war, muss noch erfahren, dass etwas verschwunden ist bei einem harten DELETE gäbe es dafür keine Spur. Aufräumen nach 30 Tagen kommt in Phase 9.

Ein gelöschter Markt reißt keine Einträge mit: sie verlieren nur den Marktbezug und landen in der Gruppe „Ohne Markt". Ein gelöschter Artikel nimmt seine Listeneinträge dagegen mit, sonst zeigten sie ins Leere.

Phase 3 PWA ohne Build-Schritt

Die Oberfläche besteht aus nativen ES-Modulen unter web/html/js/. Kein npm, kein Bundler, kein Node im Container das web-Image bleibt das schlanke nginx:alpine.

web/html/
├── index.html              Hülle, lädt js/app.js als Modul
├── app.css                 inklusive @media print für A4
├── manifest.webmanifest
├── sw.js                   Service Worker (nur die Programmdateien)
├── icons/
└── js/
    ├── app.js              Routing über location.hash
    ├── api.js              einziger Ort mit fetch(), CSRF, Fehlerübersetzung
    ├── dom.js              el(), Formatierung für Preise und Mengen
    ├── store.js            ein Zustandsspeicher, Ansichten zeichnen daraus
    └── views/
        ├── auth.js         Anmelden, Registrieren, Passwort
        ├── lists.js        Übersicht
        ├── list-detail.js  gruppierte Liste, Abhaken, Preise
        └── manage.js       Märkte und Warengruppen

Übernehmen und neu bauen:

docker compose up -d --build web

Warum kein Framework

Statt React ein einzelner Zustandsspeicher (store.js), aus dem heraus die betroffene Ansicht neu gezeichnet wird. Niemand fasst fremde DOM-Knoten an. Das ist im Kern dasselbe Prinzip, nur ohne die rund 300 Pakete, die ein Vite-React-Projekt mitbringt und ohne postinstall-Skripte, über die die Angriffe der letzten Zeit gelaufen sind.

Was fehlt: TypeScript, Hot Reload, Typprüfung. Bei dieser Größenordnung verschmerzbar.

Optimistische Änderungen

Abhaken, Preis eintragen, Markt wechseln: Die Anzeige ändert sich sofort, der Serveraufruf läuft danach. Schlägt er fehl, wird der Serverstand neu geholt und ein Hinweis eingeblendet die Anzeige darf nicht auf einer Änderung stehenbleiben, die nie angekommen ist. Diese Stelle (mutate() in list-detail.js) wird in Phase 4 zur Outbox-Warteschlange ausgebaut.

Service Worker

Speichert nur die Programmdateien, nicht die API-Antworten. Eine veraltete Liste aus dem Cache wäre schlimmer als gar keine. Das kontrollierte Vorhalten der Daten übernimmt in Phase 4 IndexedDB.

Bei Änderungen an den Dateien in SHELL muss VERSION in sw.js hochgezählt werden, sonst behalten installierte Clients die alte Fassung.

Auf dem Telefon installieren

Android/Chrome bietet „Zur Startseite hinzufügen" von selbst an. Bei iOS geht es nur über Teilen → „Zum Home-Bildschirm". Das ist auch die Voraussetzung dafür, dass Web Push in Phase 8 überhaupt funktioniert im normalen Safari-Tab gibt es keine Benachrichtigungen.

Beides braucht HTTPS. Über reines HTTP auf Port 46600 lässt sich die PWA nicht installieren und der Service Worker nicht registrieren (Ausnahme: localhost). Spätestens jetzt lohnt sich der Reverse Proxy mit TLS dann COOKIE_SECURE=true und PUBLIC_BASE_URL auf https://… setzen.

Drucken

app.css enthält bereits die @media print-Regeln: A4, Bedienelemente ausgeblendet, leere Kästchen zum Abhaken, gepunktete Linie für den Preis. Im Browser über Strg+P. Der serverseitig gerenderte Ausdruck aus Phase 9 kommt zusätzlich für den Fall, dass jemand ohne geöffnete App drucken will.

Phase 5 Teilen

Jede Liste wird einzeln geteilt. Wer zehn Listen hat, kann jede davon mit einem anderen Personenkreis teilen; es gibt keine übergreifenden Gruppen.

Methode Pfad Wer
GET / POST /api/lists/{id}/invites Eigentümer
POST /api/lists/{id}/invites/{iid}/resend Eigentümer
DELETE /api/lists/{id}/invites/{iid} Eigentümer
POST /api/lists/{id}/unshare Eigentümer
POST /api/lists/{id}/transfer Eigentümer
PUT / DELETE /api/lists/{id}/members/{uid} Eigentümer
POST /api/lists/{id}/leave jedes Mitglied
GET /api/invites/{token} Eingeladener
POST /api/invites/{token}/accept Eingeladener

In der Oberfläche: Liste öffnen → „Teilen“ (nur beim Eigentümer sichtbar).

Ablauf

  1. Eigentümer gibt eine E-Mail-Adresse und die Berechtigung an
  2. Es entsteht ein Datensatz mit Zufallstoken, versendet wird ein Link auf /invite?token=…
  3. Der Empfänger meldet sich an oder registriert sich; der Pfad bleibt dabei erhalten, danach geht es direkt weiter
  4. Beim Annehmen entsteht die Mitgliedschaft

Das läuft bewusst unabhängig davon, ob unter der Adresse schon ein Konto besteht. Damit verrät das Einladen nicht, wer registriert ist, und neue wie bestehende Nutzer gehen denselben Weg.

Entscheidungen, die Begründung verdienen

Die Adresse muss zum annehmenden Konto passen. Ein weitergeleiteter Link öffnet niemandem sonst den Zugang. Der Preis: Wer die Einladung an seine Arbeitsadresse bekommt, aber ein Konto mit privater Adresse hat, braucht eine neue Einladung.

Erneutes Senden erzeugt ein neues Token. In der Datenbank steht nur der Hash, der Klartext des alten Links ist nicht rekonstruierbar. Nebeneffekt: Ein versehentlich weitergeleiteter Link verliert dabei seine Gültigkeit was praktisch ist, wenn genau das der Grund fürs erneute Senden war.

Rate Limiting pro Liste und pro IP. Jede Einladung erzeugt eine Mail an eine frei wählbare Adresse. Ohne Bremse wäre die Anwendung eine Versandhilfe für unerwünschte Nachrichten und deine Relay-Reputation wäre das Erste, was darunter leidet.

Eigentumsübertragung macht den bisherigen Eigentümer zum Bearbeiter. Er verliert die Verwaltung, aber nicht den Zugang zu einer Liste, an der er mitarbeitet.

Wer einen Eintrag angelegt hat, steht im Untertitel aber nur bei geteilten Listen. Bei einer Liste für sich allein wäre „von mir“ nur Rauschen. Ausgeliefert wird der Anzeigename beziehungsweise der lokale Teil der Adresse, nicht die vollständige E-Mail-Adresse.

Zusätzlich zum Einladen einzelner Personen lässt sich ein Link erzeugen, über den auch Empfänger ohne Konto die Liste ansehen können.

Methode Pfad Wer
GET / POST /api/lists/{id}/public-links Eigentümer oder Berechtigte
DELETE /api/lists/{id}/public-links/{sid} Eigentümer oder Berechtigte
POST /api/lists/{id}/public-links/revoke-all Eigentümer oder Berechtigte
GET /api/public/{token} jeder mit Link
POST /api/public/{token}/items/{iid} jeder mit Link, wenn erlaubt

Der Link hat die Form https://…/s/<token> und öffnet eine eigenständige Ansicht ohne Anmeldung gegliedert nach Markt und Warengruppe, auf A4 druckbar.

Was über den Link geht: ansehen, drucken, und sofern beim Erzeugen erlaubt Artikel abhaken. Sonst nichts. Kein Anlegen, kein Löschen, keine Preisänderung, keine Märkte, keine Mitgliederliste.

Was bewusst nicht sichtbar ist: wer welchen Artikel eingetragen hat. Bei einem Link ist der Empfängerkreis unbestimmt; Namen sind personenbezogene Daten und haben dort nichts verloren. Die Ansicht wird serverseitig anonymisiert, nicht erst im Browser ausgeblendet.

Das Zusatzrecht. Öffentliche Links darf zunächst nur der Eigentümer erzeugen. Er kann das Recht einzelnen Mitgliedern erteilen (Häkchen in der Teilen-Ansicht) und jederzeit wieder entziehen. Es hängt nicht an der Rolle auch ein Mitglied mit „Nur lesen“ kann es bekommen, muss es aber nicht.

Ablaufdatum ist Pflicht, höchstens 365 Tage. Ein unbefristeter Link wäre ein dauerhaft offenes Fenster in fremde Daten. Höchstens 10 Links gleichzeitig aktiv je Liste.

Der Link wird nur einmal angezeigt. In der Datenbank steht nur ein Prüfwert, nicht die Adresse selbst ein Datenbankleck gibt also keinen Zugriff auf die Listen. Der Preis: Wer den Link später noch einmal braucht, muss einen neuen erzeugen. Das ist eine bewusste Abwägung zugunsten der Sicherheit; sag Bescheid, wenn du es umgekehrt haben willst.

Nutzungsanzeige ohne Personenbezug. Der Eigentümer sieht, wie oft und wann zuletzt ein Link geöffnet wurde keine IP-Adressen, keine Zeitreihe. Erkennbar soll sein, dass ein Link benutzt wird, nicht von wem.

robots.txt und X-Robots-Tag schließen /s/, /invite und /reset von der Indexierung aus.

Phase 4 Offline und Synchronisation

Die Listenansicht arbeitet ab jetzt gegen einen lokalen Speicher, nicht mehr direkt gegen den Server.

js/db.js     IndexedDB: Zwischenspeicher und Outbox
js/sync.js   Senden, Holen, Ereigniskanal, lokales Anwenden offener Operationen

Ablauf einer Änderung

  1. Die Operation wandert in die Outbox (IndexedDB, überlebt einen Absturz)
  2. Sie wird sofort auf den zwischengespeicherten Stand angewandt und angezeigt
  3. Der Versand läuft danach; scheitert er, bleibt sie liegen
  4. Erneut versucht wird bei Verbindungsrückkehr, beim Zurückholen der App in den Vordergrund und ersatzweise alle 30 Sekunden

Jede Operation trägt eine vom Client vergebene op_id. Kommt sie zweimal an weil die Antwort unterwegs verlorenging oder das Telefon neu gestartet wurde , erkennt der Server das über die Tabelle applied_op und führt sie nicht noch einmal aus. Ohne diesen Schritt würde aus einem verlorenen „Butter hinzufügen" beim nächsten Versuch zweimal Butter.

Was ich bewusst nicht gebaut habe

Im Architekturpapier stand ein Delta-Endpunkt changes?since=N. Der ist hier nicht entstanden. Eine Einkaufsliste hat Dutzende Einträge, keine Zehntausende die vollständige Ansicht neu zu holen kostet ein paar Kilobyte und spart eine ganze Fehlerklasse, die beim Zusammensetzen von Teilständen entsteht. Der Revisionszähler bleibt und sorgt dafür, dass das nur passiert, wenn sich wirklich etwas geändert hat.

Sollte sich das als zu grob erweisen, ist der Umbau später klein: row_rev liegt auf jeder Zeile bereit.

Ereigniskanal

GET /api/lists/{id}/events liefert Server-Sent Events. Übertragen wird nur der neue Revisionsstand, keine Nutzdaten der Client entscheidet dann selbst, ob er nachladen muss. Das hält den Kanal schlank und vermeidet Reihenfolgeprobleme.

Ohne Redis wird der Stand alle zwei Sekunden abgefragt statt verteilt. Bei dieser Größenordnung völlig ausreichend; die Abfrage läuft im Threadpool, damit sie die Ereignisschleife nicht blockiert.

Wichtig für den Reverse Proxy: proxy_buffering off ist Pflicht. Ohne das sammelt nginx die Ereignisse und liefert sie gebündelt aus die Liste aktualisiert sich dann verzögert oder gar nicht. Der web-Container hat dafür einen eigenen location-Block; auf der OPNsense muss dasselbe eingestellt werden (siehe docs/reverse-proxy.md).

Konfliktauflösung

Letzter Schreibvorgang gewinnt, mit dem Zeitstempel des Servers. Für eine Einkaufsliste reicht das: Der kritische Fall ist der Statuswechsel („hab ich schon eingepackt"), und der ist idempotent. Der Aufwand für CRDTs stünde in keinem Verhältnis.

Ein Sonderfall ist abgefangen: Fachlich fehlgeschlagene Operationen etwa eine Änderung an einem Eintrag, den jemand anders inzwischen gelöscht hat werden einzeln abgelehnt und gemeldet, statt den ganzen Stapel scheitern zu lassen. Sonst würde ein einziger toter Eintrag alle übrigen Änderungen eines Geräts dauerhaft blockieren.

Was der Nutzer sieht

  • Offline: Hinweisbalken, Änderungen werden gesammelt
  • Ausstehende Übertragung: Zähler im Hinweisbalken, betroffene Einträge grün hinterlegt mit einem Punkt hinter dem Namen
  • Neu angelegte, noch nicht übertragene Einträge lassen sich nicht weiter ändern sie haben serverseitig noch keine ID. Sobald die Übertragung durch ist, sind sie normal bedienbar.

Prüfskripte

Vor jedem Neubau sinnvoll alle geben bei Problemen Rückgabewert 1 zurück:

bash tools/check-all.sh

Führt alle Einzelprüfungen aus und gibt bei Problemen Rückgabewert 1 zurück damit lässt es sich als Git-Hook oder in einer CI verwenden.

Einzeln:

bash tools/check-env.sh .env      # doppelte oder fehlerhafte .env-Einträge
python3 tools/check-routes.py     # Endpunkte ohne Berechtigungsprüfung
python3 tools/check-schema.py     # db/schema.sql gegen die Modelle
python3 tools/check-editable.py   # Felder eines Eintrags überall verdrahtet?
python3 tools/check-nginx.py web  # Kopfzeilen, doppelte Direktiven
node tools/check-js.mjs           # undefinierte Bezeichner in den JS-Modulen
node tools/test-barcode.mjs       # Strichcode-Decoder
cd backend && python3 -m compileall -q app alembic

Nach dem Entpacken eines Archivs fehlt den Skripten das Ausführungsbit (ZIP speichert es nicht zuverlässig). Einmalig:

chmod +x tools/*.sh smoke-phase2.sh
git update-index --chmod=+x tools/check-all.sh tools/check-env.sh smoke-phase2.sh

Der zweite Befehl sorgt dafür, dass das Recht im Repository erhalten bleibt Git speichert es, ZIP nicht.

check-routes.py

Listet alle Endpunkte mit ihren Berechtigungsabhängigkeiten und meldet jeden, der weder eine Prüfung noch einen Eintrag in der Ausnahmeliste hat. FastAPI mahnt fehlende Absicherung nicht an beim Hinzufügen einer Route ist sie schnell vergessen.

check-editable.py

Prüft, dass jedes änderbare Feld eines Listeneintrags auch tatsächlich überall eingetragen ist: Modell, ItemUpdateIn, ItemOut, items.py, sync.py und die Oberfläche.

Ein Feld hinzuzufügen bedeutet sechs Stellen. Fehlt eine, merkt es niemand: Die Anwendung startet, die Oberfläche zeigt ein Eingabefeld, der Server verwirft den Wert stillschweigend. Genau so ist der Artikelname zunächst untergegangen.

check-schema.py

Vergleicht db/schema.sql mit den SQLAlchemy-Modellen: fehlende oder überzählige Tabellen und Spalten, und ob der eingetragene Migrationsstand zur höchsten Revision passt. Die SQL-Datei ist von Hand geschrieben und wird beim nächsten Modellumbau sonst leicht vergessen.

check-env.sh

./tools/check-env.sh .env

Findet mehrfach definierte Variablen, Leerzeichen vor dem = und Anführungszeichen im Wert.

Der erste Fall ist der tückische: Docker Compose nimmt bei doppelten Schlüsseln die letzte Zeile. Wer einen Wert einfügt und die leere Vorlagenzeile darunter stehen lässt, schaltet die Funktion still wieder ab ohne Fehlermeldung, nur mit einem Verhalten, das niemand erklären kann.

Beim Start meldet die Anwendung inzwischen auch selbst, wenn nur einer der beiden VAPID-Schlüssel gesetzt ist.

check-js.mjs

Sucht Bezeichner, die verwendet, aber nirgends deklariert oder importiert werden.

Ohne Build-Schritt gibt es keinen Linter, und der Browser meldet x is not defined erst, wenn die betroffene Zeile tatsächlich ausgeführt wird. Eine Funktion, die nur im Menü eines Eintrags gebraucht wird, kann so lange fehlen, ohne dass es auffällt genau das ist zwischen Phase 7 und dem ersten Öffnen einer Liste passiert.

Bewusst eine grobe Prüfung ohne echten Parser: Sie kennt keine Blockgeltungsbereiche und meldet nichts, was irgendwo in der Datei deklariert ist. Für den Zweck „ganz vergessen“ statt „am falschen Ort“ reicht das.

Prüfskript für die nginx-Konfiguration

python3 tools/check-nginx.py web

Findet drei Fehlerklassen, die uns bereits begegnet sind:

Doppelte Direktiven. nginx bricht mit directive is duplicate ab. Passiert leicht, wenn eine location proxy_common.conf einbindet und etwas wiederholt, das dort schon steht.

Fehlende Sicherheitskopfzeilen. nginx vererbt add_header nur, wenn die untergeordnete Ebene gar kein add_header setzt. Ein einziges Cache-Control in einer location lässt alle Kopfzeilen des server-Blocks verschwinden und zwar ohne Fehlermeldung. Genau das war zwischen Phase 3 und Phase 4 unbemerkt der Fall: Die CSP, auf die wir uns bei der Entscheidung gegen npm gestützt hatten, war auf der gesamten Anwendung nicht aktiv.

Ein types-Block im server-Kontext. Der ersetzt die geerbte MIME-Tabelle vollständig, statt sie zu ergänzen CSS und JavaScript kämen als application/octet-stream an, und der Browser lehnt ES-Module ab.

Sinnvoll als Schritt vor jedem docker compose build web. Rückgabewert 1 bei Problemen, damit es sich in ein Makefile oder einen Git-Hook einbauen lässt.

Reihenfolge per Anfasser

Märkte und Warengruppen werden gezogen, nicht durchnummeriert. sort_order verwaltet die App selbst und schreibt nach jedem Verschieben 10, 20, 30 … zurück die Abstände lassen Platz, falls später einzeln eingefügt werden soll. Gesendet werden nur die Einträge, deren Rang sich tatsächlich geändert hat.

js/sortable.js nutzt Pointer Events statt der HTML5-Drag-and-Drop-API: Letztere funktioniert auf Touchgeräten praktisch nicht, und die App wird überwiegend am Telefon bedient. Der Anfasser hat touch-action: none, sonst scrollt die Seite, statt die Zeile zu ziehen.

Bedienbar auch ohne Zeigegerät: Anfasser mit Tab fokussieren, dann Pfeiltasten.

Fallstrick: null im DOM

Node.append(null) macht daraus die Zeichenkette "null" und zeigt sie an. Die Funktion el() filtert solche Werte heraus, aber ein direktes clear(root).append(…) umging diesen Schutz sichtbar als ein einsames „null" über der Verwaltungsansicht.

Dafür gibt es jetzt mount(node, …children) in dom.js. Alle Ansichten sind umgestellt; clear(root).append( kommt im Quelltext nicht mehr vor.

Sammelendpunkt für den Gesamtstand

GET /api/lists/{id}/snapshot liefert Liste, gruppierte Ansicht, Märkte und Warengruppen in einer Antwort. Vorher waren das vier Aufrufe bei einer Synchronisation nach jedem Abhaken summieren sich vier Rundreisen zu einer spürbaren Verzögerung, besonders im Mobilfunknetz.

Dazu behoben: Nach erfolgreichem Versand lief die Synchronisation doppelt. installTriggers rief selbst pull() auf und löste über onChange die Ansicht aus, die ihrerseits syncNow() startete. Jetzt sendet der Auslöser nur noch; das Holen übernimmt die Ansicht.

Aus acht Anfragen je Änderung sind damit zwei geworden: ein POST /ops und ein GET /snapshot.

Anwendungsname

APP_NAME in der .env erscheint in der Oberfläche, im Browsertitel, auf dem Startbildschirm und in allen Mails. APP_SHORT_NAME ist die kürzere Fassung fürs Symbol auf dem Startbildschirm.

$EDITOR .env          # APP_NAME=Einkaufsliste
docker compose up -d api

Der web-Container muss nicht neu gebaut werden. Der Name kommt über GET /api/config zur Laufzeit, und das Manifest erzeugt der api-Container (GET /manifest.webmanifest, von nginx durchgereicht). Beim Bauen einzusetzen hätte bedeutet, dass jede Umbenennung einen Neubau erfordert.

Der zuletzt bekannte Name liegt lokal in IndexedDB, damit die App auch ohne Verbindung nicht namenlos startet.

SMTP_FROM_NAME leer lassen, um APP_NAME als Absendernamen zu verwenden.

Listen umbenennen, löschen, verlassen

In der Übersicht bei jeder Zeile über „⋯“:

  • Eigentümer: Umbenennen, Löschen
  • Andere Mitglieder: Liste verlassen

Beim Löschen und Verlassen wird auch der lokale Zwischenspeicher der Liste aufgeräumt sonst bliebe ein verwaister Stand samt eventuell wartender Operationen in IndexedDB liegen.

Behobener Fehler: Eine neu angelegte Liste erschien erst nach dem Neuladen der Seite. create() schrieb den neuen Stand in den Zustandsspeicher, rief aber render() nicht auf und die Ansicht hatte sich beim Speicher nie angemeldet.

Phase 6 Strichcode und Artikelattribute

Der Decoder ist selbst geschrieben

web/html/js/barcode.js liest EAN-13, EAN-8 und UPC-A. Rund 200 Zeilen, keine Abhängigkeit.

Die übliche Lösung wäre ZXing eine 250-KB-Datei aus dem npm-Ökosystem. Für drei Formate, die alle nach demselben Schema arbeiten, steht das in keinem Verhältnis, und es widerspräche der Entscheidung, die Lieferkette klein zu halten.

Wo die native BarcodeDetector-API vorhanden ist (Chrome, Android), wird sie bevorzugt: schneller, und sie erkennt zusätzlich QR- und Datamatrix-Codes. Der eigene Decoder greift überall sonst, insbesondere in Safari auf iPhone und iPad.

node tools/test-barcode.mjs

Der Test erzeugt synthetische Signale, verzerrt sie (Unschärfe, Rauschen, verschiedene Maßstäbe, umgedreht gehalten) und prüft, ob der Decoder sie zurückliest und ob er auf leeren Flächen und reinem Rauschen schweigt. 17 Fälle.

Wie er arbeitet: Aus dem Kamerabild werden 21 waagerechte Linien abgetastet, jede in eine Hell-Dunkel-Folge zerlegt und gegen die Zifferntabellen gehalten. Verglichen werden Verhältnisse, keine absoluten Breiten der Abstand zur Kamera spielt dadurch keine Rolle. Ein Ergebnis gilt erst als sicher, wenn zwei Linien dasselbe ergeben und die Prüfziffer stimmt.

Dazu die Ruhezonenprüfung: Vor und hinter dem Code verlangt die Norm einen hellen Rand von mindestens sieben Modulen. Diese Prüfung ist der wirksamste Schutz gegen Fehltreffer in zufälligem Rauschen gibt es keine so breiten hellen Flächen, in einem echten Kamerabild dagegen immer. Gemessen: 0 von 500 Fehltreffern auf reinem Rauschen, vorher gelegentlich einer.

Der Preis: Ein Code, der bis an den Bildrand angeschnitten ist (unter etwa 3 Module Rand), wird nicht gelesen. Der Sucherrahmen führt den Nutzer so, dass das selten vorkommt.

Scannen in der Liste

Beim Hinzufügen gibt es einen Knopf „Scannen“:

  • Code bekannt → der Artikel landet direkt auf der Liste
  • Code unbekannt → Name erfragen; der Code wird beim Artikel hinterlegt und beim nächsten Mal sofort gefunden
  • Ohne Kamera oder über reines HTTP → Eingabe der Ziffern von Hand; der Knopf erscheint dann gar nicht erst

Scannen braucht eine Verbindung: Die Zuordnung Code → Artikel steht auf dem Server. Offline bliebe eine Ziffernfolge ohne Bedeutung die App bricht dort ehrlich ab, statt etwas Unbrauchbares in die Warteschlange zu legen.

Kamerazugriff verlangt HTTPS. Über http://…:46600 verweigert der Browser ihn; hinter dem Reverse Proxy funktioniert es.

Artikelstamm

Neuer Punkt „Artikel“ in der Liste. Je Artikel:

  • Name, Strichcode (auch scanbar), Notiz
  • Vorgabe für Markt und Warengruppe gilt für neue Einträge
  • „Erhältlich bei“: mehrere Märkte ankreuzbar
  • Frei definierbare Eigenschaften: Verpackungseinheit, Farbe, Größe, was immer gebraucht wird

Artikel entstehen weiterhin automatisch beim Eintragen; hier werden sie nachträglich ausgestaltet.

Eigenschaft je Eintrag

Neben Menge und Einheit gibt es ein Feld „Eigenschaft“: die nähere Bestimmung des gewünschten Artikels bei Pfeffer etwa „bunt, ganz“ oder „schwarz, gemahlen“, bei Milch „laktosefrei“.

Bewusst getrennt von der Notiz: Die Eigenschaft gehört zum Produkt und steht deshalb neben dem Namen, die Notiz ist eine Bemerkung für den Einkaufenden („beim Metzger fragen“). Beide lassen sich im „⋯“-Menü nachträglich ändern.

Das Feld schlägt vor, was auf dieser Liste schon vorkommt „bunt, ganz“ muss also nur einmal getippt werden.

Das ist etwas anderes als die Eigenschaften im Artikelstamm: Dort steht, was den Artikel dauerhaft beschreibt (Verpackungseinheit, Farbe). Hier steht, was diesmal gebraucht wird.

Migration 0007 fügt die Spalte hinzu.

Layout der Eingabezeile

Name über die volle Breite, darunter Menge/Einheit/Eigenschaft, darunter die Schaltflächen. Vorher teilten sich Felder und Schaltflächen eine Zeile auf schmalen Geräten lief „Hinzufügen“ aus der Box.

Produktdatenbank

Gescannte Codes werden bei Open Food Facts nachgeschlagen rund drei Millionen überwiegend Lebensmittel, frei zugänglich.

PRODUCT_LOOKUP=openfoodfacts     # oder "off"

Über den Server, nicht aus dem Browser. Bei einer Abfrage direkt aus der App wüsste Open Food Facts, wer wann welches Produkt scannt genau die Datenspur, die vermieden werden soll. Außerdem bliebe die CSP nicht bei connect-src 'self': Eine Ausnahme für eine fremde Domain wäre eine dauerhafte Schwächung für eine gelegentliche Abfrage.

Du hattest ursprünglich „direkt vom Client“ gewählt. Ich habe es trotzdem als Proxy gebaut, weil der Aufwand mit dem Zwischenspeicher ohnehin anfiel und die Nachteile der direkten Variante bleiben. Umstellen wäre eine Änderung in app/product_lookup.py und eine Zeile in der CSP sag Bescheid, wenn du es so willst.

Zwischenspeicher. Jeder Code wird höchstens einmal je Gültigkeitszeitraum abgefragt (Treffer 180 Tage, Fehlschläge 14). Das schont auch den fremden Dienst.

Reihenfolge beim Auflösen: erst der eigene Artikelstamm, dann Open Food Facts. Was hier gepflegt wurde, ist verlässlicher als eine Fremdquelle, und ein eigener Name soll nicht überschrieben werden.

Bestätigung. Ein Treffer in der Fremdquelle landet nicht ungeprüft im eigenen Bestand: Der Dialog zeigt Herkunft und Vorschlag, übernommen wird, was der Nutzer dort stehen lässt. Ein Artikel aus dem eigenen Stamm geht dagegen ohne Rückfrage auf die Liste dort ist nichts zu prüfen.

Voraussetzung: Der api-Container muss world.openfoodfacts.org erreichen. Die Daten stehen unter der Open Database License; für den Eigengebrauch unproblematisch, bei Weiterverbreitung ist die Lizenz zu beachten.

Runde Abhak-Kästchen

Die Kästchen wirkten oval. Ursache: Die allgemeine button-Regel setzt padding: .7rem 1.1rem; die 2,2 rem waagerechte Innenabstand waren mehr als die vorgesehenen 2 rem Breite und haben den Knopf auseinandergezogen. Jetzt padding: 0 plus aspect-ratio: 1.

Phase 7 Preisdatenbank und Vergleich

Methode Pfad Zweck
GET /api/lists/{id}/prices Vergleich über alle Artikel
GET /api/lists/{id}/price-hints kompakt für die Listenansicht
GET /api/lists/{id}/articles/{aid}/prices Verlauf eines Artikels

In der Oberfläche: Liste öffnen → „Preise“.

Erfassung läuft nebenbei

Sobald an einem Eintrag ein Preis steht und er einem Markt zugeordnet ist, entsteht ein Datenpunkt. Ohne Markt ergibt ein Preis keinen Vergleichswert und wird nicht aufgenommen.

Gegen Wildwuchs: Derselbe Preis am selben Markt für dieselbe Packungsgröße gilt innerhalb von zwölf Stunden als ein Wert. Ohne das entstünde bei jedem Tippen im Preisfeld ein neuer Eintrag.

Die Datenpunkte überleben das Aufräumen der Liste gerade darum geht es: Was einmal beobachtet wurde, bleibt für den Vergleich erhalten.

Ohne Personenbezug

price_point führt Liste, Artikel, Markt, Betrag, Menge und Zeitpunkt kein user_id. Aus „wer hat wann wo was zu welchem Preis gekauft“ ließe sich ein Bewegungs- und Konsumprofil bilden; für den Zweck ist das nicht nötig.

Das ist ein bewusster Verzicht: Man kann damit nicht nachvollziehen, wer einen falschen Preis eingetragen hat.

Was die Ansicht zeigt

Je Artikel eine Karte mit den Märkten nach Preis sortiert, der günstigste markiert, die anderen mit dem Aufschlag daneben. Aufgeklappt: Zahl der Beobachtungen, Spanne, Preis je Mengeneinheit und der Verlauf.

Keine Matrix Artikel × Markt bei vier Märkten und dreißig Artikeln wird die auf einem Telefon unlesbar. Gesucht wird ohnehin „wo ist X am günstigsten“.

Preis je Einheit wird nur berechnet, wenn eine Menge erfasst ist. 1,29 € für einen Liter ist etwas anderes als 1,29 € für 200 Milliliter ohne Bezugsgröße wäre der Vergleich irreführend, deshalb bleibt das Feld dann leer.

Hinweis beim Einkaufen

Im „⋯“-Menü eines Eintrags steht, wo der Artikel zuletzt am günstigsten war und was er im gerade zugeordneten Markt gekostet hat. Nur bei Artikeln, deren Märkte sich tatsächlich unterscheiden.

Die Hinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet die Liste ohne sie weiter.

Stückzahl und Gebinde getrennt (Migration 0010)

Bis hierher steckten beide in quantity/unit. Bei einer Flasche zu 500 ml und 2,99 € rechnete die Summenbildung 500 × 2,99 = 1495 €.

Jetzt drei Felder:

Feld Bedeutung Beispiel
count Stückzahl wie viele Packungen 6
pack_size / pack_unit Gebinde wie groß eine Packung ist 500 ml
price_cents Preis für ein Gebinde 2,99 €

Die Summe ist price_cents × count. In der Zeile steht „6 × 500 ml“, und bei mehr als einem Stück erscheint die Zeilensumme unter dem Preisfeld damit sichtbar ist, worauf sich der eingetragene Betrag bezieht.

Was die Migration mit vorhandenen Daten macht: Die alten Werte werden als Gebinde übernommen (aus „500 ml“ wird pack_size=500, pack_unit=ml), die Stückzahl auf 1 gesetzt. Das ist die sichere Richtung: Wo bisher tatsächlich eine Stückzahl gemeint war, stimmt die Summe danach sie wird nur nicht mehr vervielfacht. Wer sechs Flaschen will, trägt die 6 einmal nach.

Die Stückzahl steht als eigenes Feld links neben dem Namen, nicht im Untertitel. Beim Einkaufen ist „wie viele“ die Angabe, die man am leichtesten übersieht und dann packt man zu wenig ein. Mehr als ein Stück wird zusätzlich fett hervorgehoben. Der Untertitel führt nur noch Gebinde, Eigenschaft und Notiz.

Preis je Einheit bezieht sich jetzt eindeutig auf das Gebinde: 2,99 € für 500 ml sind 5,98 € je Liter, unabhängig davon, wie viele Flaschen auf der Liste stehen.

Open Food Facts liefert die Trennung gleich mit: „6 x 33 cl“ wird zu Stückzahl 6 und Gebinde 33 cl, „500 g“ zu Gebinde 500 g ohne Stückzahl.

Outbox von vor der Umstellung: Der Server nimmt die alten Feldnamen weiterhin an und legt sie als Gebinde ab das entspricht ihrer bisherigen Bedeutung. Wartende Änderungen gehen also nicht verloren.

Phase 8 Push-Benachrichtigungen

python3 tools/vapid-keys.py     # Ausgabe in die .env übernehmen
docker compose up -d --build

VAPID ist die Art, wie sich dieser Server gegenüber den Push-Diensten der Browserhersteller ausweist. Das Schlüsselpaar gehört dir kein Konto bei Google oder Apple nötig, und außer der verschlüsselten Nachricht fließt nichts an Dritte. Auch der Push-Dienst kann den Inhalt nicht lesen.

Einschalten: Listenübersicht → Einstellungen → „Auf diesem Gerät einschalten“. Dort gibt es auch eine Testnachricht und die Liste der angemeldeten Geräte.

Die Zwei-Stunden-Regel

Wer eine Liste ändert, löst bei allen anderen Mitgliedern eine Meldung aus aber höchstens eine je Liste und Person alle zwei Stunden (PUSH_THROTTLE_HOURS).

Ohne das wäre die Funktion unbrauchbar: Wer im Laden steht und abhakt, erzeugt in wenigen Minuten Dutzende Änderungen. Die anderen bekämen ebenso viele Meldungen und würden Benachrichtigungen nach dem ersten Einkauf abschalten.

Zwei Feinheiten:

  • Konnte nichts zugestellt werden (kein Gerät angemeldet, Endpunkt tot), startet die Sperrfrist nicht. Sonst verpasst man die nächste Änderung, ohne die erste bekommen zu haben.
  • Ein Stapel aus der Outbox, der nur aus Wiederholungen bestand, löst nichts aus dort hat sich nichts geändert.

Was in der Meldung steht

Listenname und wer sie geändert hat. Keine Artikelnamen.

Eine Benachrichtigung erscheint auf dem gesperrten Bildschirm, und was dort steht, sieht jeder, der das Gerät gerade in der Hand hält. „Marco hat die Liste geändert“ ist harmlos, eine Aufzählung von Medikamenten wäre es nicht.

Gleiches tag je Liste bedeutet: Eine neue Meldung ersetzt die alte, statt sich zu stapeln.

Aufräumen

Meldet ein Push-Dienst einen Endpunkt als ungültig (404 oder 410 typisch nach Deinstallation oder Löschen der Websitedaten), wird die Anmeldung sofort entfernt. Nach zehn aufeinanderfolgenden Fehlschlägen ebenfalls.

iOS

Auf iPhone und iPad funktioniert Web Push nur, wenn die App über „Teilen → Zum Home-Bildschirm“ installiert wurde. Im normalen Safari-Tab gibt es keine Benachrichtigungen eine Einschränkung von Apple, kein Fehler der Anwendung. Die Einstellungsseite erkennt das und erklärt es an Ort und Stelle, statt einen Knopf anzubieten, der nichts tut.

Phase 9 Ausdruck, Aufräumen, Betrieb

Druckansicht vom Server

Pfad Wer
/api/lists/{id}/print Mitglieder
/api/public/{token}/print jeder mit öffentlichem Link

Erreichbar über „Drucken“ in der Listenansicht und in der öffentlichen Ansicht. Öffnet eine eigenständige A4-Seite: Markt → Warengruppe → Artikel alphabetisch, Kästchen zum Abhaken, Stückzahl, Gebinde, gepunktete Linie für den Preis, Summe je Markt und Gesamtsumme.

Die App bringt zwar schon @media print mit. Die Serverseite kommt hinzu, weil der Ausdruck auch ohne geöffnete App möglich sein soll aus einem Lesezeichen, über einen geteilten Link, oder von einem Rechner, auf dem niemand die PWA installiert hat.

Die Gliederung stammt aus app/list_view.py, derselben Quelle wie die Bildschirmansicht. Ein zweiter Sortieralgorithmus würde mit der Zeit abweichen, und dann zeigt der Ausdruck eine andere Reihenfolge als das Telefon in der Hand.

Zurückgestellte Artikel sind standardmäßig nicht dabei: Wer ausdruckt, will die Einkaufsliste, nicht die Merkliste. Über ?include_deferred=true umschaltbar.

Jinja2 mit autoescape Artikelnamen und Notizen sind freie Nutzereingaben und landen direkt im HTML.

Aufräumen

Läuft von selbst im api-Container: 30 Sekunden nach dem Start, danach alle 24 Stunden. Kein Cron-Container, kein zusätzlicher Dienst das wäre eine weitere Stelle, an der etwas kaputtgehen kann, für eine Aufgabe, die einmal am Tag ein paar Zeilen löscht.

Weich gelöschte Daten verschwinden nach 30 Tagen endgültig (CLEANUP_DELETED_DAYS). Kürzer sollte die Frist nicht sein: So lange kann ein Gerät offline bleiben und beim nächsten Abgleich noch erfahren, dass eine Liste gelöscht wurde. Danach bliebe sie dort stehen, bis jemand sie von Hand entfernt.

Sofort auslösen: POST /api/admin/cleanup (Administrator).

Benutzerverwaltung

Einstellungen → Benutzerverwaltung (nur für Administratoren sichtbar; der Endpunkt prüft zusätzlich eine ausgeblendete Schaltfläche ist kein Schutz).

Methode Pfad
GET /api/admin/users, /api/admin/stats
POST /api/admin/users anlegen und einladen
POST /api/admin/users/{id}/welcome Willkommensnachricht erneut
POST / DELETE /api/admin/users/{id}/email Adresswechsel starten / zurückziehen
POST /api/admin/users/{id}/deactivate, /activate
POST /api/admin/users/{id}/delete
GET / PUT /api/admin/settings

Konto anlegen

Die Person bekommt eine Willkommensnachricht mit einem Link, über den sie ihr Passwort selbst festlegt. Ein vom Administrator vergebenes Passwort wäre ihm bekannt und ginge zudem im Klartext per Mail.

Der Klick auf den Link bestätigt zugleich die Adresse eine zusätzliche Verifikationsmail wäre ein weiterer Schritt ohne Erkenntnisgewinn.

Erneut senden geht nur, solange das Konto nicht eingerichtet ist. Danach wäre es kein Willkommensgruß mehr, sondern ein vom Administrator ausgelöstes Zurücksetzen des Passworts das soll vom Kontoinhaber ausgehen und läuft über „Passwort vergessen".

Adressänderung

Gewöhnliche Konten: Die neue Adresse muss bestätigen. Die bisherige bekommt einen Hinweis, damit eine untergeschobene Änderung auffällt.

Administratorkonten: beide Adressen müssen bestätigen. Der Grund ist kein formaler: Wer Zugriff auf ein Administratorkonto erlangt, könnte sonst die Adresse auf eine eigene umstellen und sich dauerhaft einnisten der rechtmäßige Inhaber verlöre den Weg zurück über „Passwort vergessen". Die Zustimmung von der alten Adresse macht das unmöglich, solange der Angreifer nicht auch das Postfach hat.

Wird die Änderung wirksam, werden alle Sitzungen des Kontos beendet.

Deaktivieren und Löschen

Beim Anmeldeversuch an einem deaktivierten Konto erscheint eine eigene Meldung aber erst nach erfolgreicher Passwortprüfung. Vorher wäre sie ein Hinweis darauf, dass es das Konto gibt; danach weiß die Person das ohnehin. Wer nur „falsches Passwort" liest, obwohl es stimmt, sucht den Fehler an der falschen Stelle.

Beim Löschen werden die Listen des Kontos geregelt: Geteilte Listen gehen an das dienstälteste andere Mitglied über, Listen ohne weitere Mitglieder werden gelöscht. Eine geteilte Liste mitzulöschen würde anderen Leuten Daten wegnehmen, mit denen das ausscheidende Konto nichts mehr zu tun hat. Zur Bestätigung muss die Adresse eingetippt werden.

Administratorkonten sind vor Deaktivierung und Löschung geschützt auch vor anderen Administratoren und vor sich selbst. Sonst könnte sich die Verwaltung aussperren. Wer einen Administrator entfernen will, nimmt ihm zuerst die Rechte; das geht bewusst nur direkt in der Datenbank.

Automatische Bereinigung

Vier Fristen, in der Verwaltung einstellbar:

Einstellung Voreinstellung Wirkung
Konto deaktivieren nach … Monaten ohne Anmeldung 12 Konto wird gesperrt, Person bekommt eine Nachricht
Konto löschen nach … Monaten Deaktivierung 12 Konto wird endgültig entfernt
Liste archivieren nach … Monaten ohne Öffnen 12 Je Person die Liste verschwindet nur aus deren Übersicht
Liste löschen nach … Monaten im Archiv 12 Nur nach dem Archivdatum des Eigentümers

0 bedeutet abgeschaltet, nicht „sofort". Ein Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle Konten verschwinden.

Administratorkonten sind ausgenommen. Die Übersicht zeigt vorab, wie viele Konten beim nächsten nächtlichen Durchlauf betroffen wären die Automatik soll nicht überraschen.

Grundlage ist user.last_seen_at. Der Wert wird bei der Anmeldung gesetzt und danach höchstens stündlich nachgeführt, damit auch jemand mitzählt, der sich monatelang nicht neu anmeldet, weil die Sitzung hält.

Archiv

Jede Liste kann archiviert werden je Person getrennt. Wer eine gemeinsame Liste nicht mehr braucht, nimmt sie aus seiner Übersicht, ohne sie den anderen wegzunehmen.

In der Listenübersicht erscheint unterhalb der aktiven Listen ein aufklappbarer Abschnitt „Archiv". Archivieren und Zurückholen stehen im „⋯"-Menü jeder Zeile und stehen allen Mitgliedern offen, nicht nur dem Eigentümer.

Methode Pfad
GET /api/lists?archived=false|true ohne Parameter alle
POST /api/lists/{id}/archive, /unarchive

Automatisch archivieren

Wer eine Liste zwölf Monate nicht geöffnet hat, findet sie danach im Archiv. Grundlage ist list_member.last_opened_at, gesetzt bei jedem Zugriff und höchstens stündlich nachgeführt.

Das wirkt sich individuell aus: Öffnet ein Mitglied die Liste regelmäßig und ein anderes nie, bleibt sie beim einen aktiv und wandert beim anderen ins Archiv.

Automatisch löschen

Maßgeblich ist allein das Archivdatum des Eigentümers. Dass ein eingeladenes Mitglied die Liste weggeräumt hat, sagt nichts darüber, ob sie noch gebraucht wird sonst könnte jemand, der einmal eingeladen wurde und nie wieder hineinschaut, eine Löschung auslösen, die den Eigentümer trifft.

Zwei Sicherungen gegen Datenverlust:

Vorwarnungen per E-Mail an den Eigentümer, 7 Tage und 1 Tag vor der Löschung. Jede Stufe genau einmal eine tägliche Erinnerung wäre Belästigung statt Warnung. Ein Klick auf „Aus dem Archiv holen" setzt die Frist zurück und löscht auch die Warnvermerke, damit beim nächsten Mal wieder gewarnt wird.

Wer die Liste zwischenzeitlich geöffnet hat, verliert sie nicht auch wenn das Archivdatum alt ist. Ohne diese Prüfung könnte eine manuell archivierte, aber weiterhin benutzte Liste verschwinden.

Gelöscht wird per Soft Delete: Offline gebliebene Geräte erfahren noch davon, endgültig entfernt der Aufräumlauf nach CLEANUP_DELETED_DAYS.

Kopfleiste

Alle Ansichten teilen sich eine Leiste (web/html/js/appbar.js): links der Weg zurück, in der Mitte der Kontext (Listenname), rechts ein Menü mit den Einträgen untereinander.

Vorher hatte jede Ansicht ihre eigene Zeile aus Textlinks. Bei sechs Einträgen unterschiedlicher Länge „Drucken", „Märkte & Gruppen", „Teilen (2)" brach das auf schmalen Geräten unregelmäßig um, und die Grundlinien lagen versetzt.

Zwei Feinheiten:

  • Eine einzelne Aktion erscheint direkt, ohne Menü. Ein Menü mit einem Eintrag wäre ein Klick zu viel.
  • Verweise bleiben Verweise. „Drucken" ist ein <a>, damit „In neuem Tab öffnen" und das Kopieren der Adresse funktionieren.

Das Menü schließt bei Klick daneben, mit Escape und beim Ansichtswechsel. Im Ausdruck ist die gesamte Leiste ausgeblendet.

Einträge bearbeiten

Im „⋯"-Menü eines Eintrags lässt sich alles ändern: Artikel, Anzahl, Gebinde, Einheit, Eigenschaft, Notiz, Markt, Warengruppe, Preis und Status.

Artikel wechseln oder umbenennen

Der Name gehört nicht dem Eintrag, sondern dem Artikel dahinter und der kann von mehreren Einträgen benutzt werden, trägt einen Strichcode, Vorgaben für Markt und Warengruppe und hängt an der Preishistorie. Ein Namenswechsel am Eintrag ist deshalb mehrdeutig:

"Mlich" → "Milch"     ein Tippfehler soll verschwinden
"Milch" → "Butter"    dieser Eintrag soll etwas anderes sein

Die Regel: Benutzt außer diesem Eintrag niemand den Artikel, wird er umbenannt der Tippfehler verschwindet dann restlos. Sonst hängt der Eintrag auf einen anderen Artikel um, und die übrigen Einträge bleiben unberührt.

Bleibt der bisherige Artikel dabei ohne Einträge zurück und hängt nichts Gepflegtes daran (kein Strichcode, keine Eigenschaften, keine Vorgaben, keine beobachteten Preise), verschwindet er mit. Wo etwas dranhängt, bleibt er stehen dort stört er nicht und lässt sich von Hand löschen.

Wer einen Artikel ausdrücklich überall umbenennen will, tut das unter „Artikel"; dort ist der Name ein Feld wie jedes andere.

Vorschläge beim Eintippen

Ab drei Zeichen im Feld „Artikel hinzufügen" erscheint eine Liste dessen, was auf dieser Liste schon eingetragen wurde von allen Mitgliedern, aus den letzten 24 Monaten.

Quelle sind die Listeneinträge, auch die bereits gelöschten: Gerade die abgehakten und aufgeräumten sind die interessanten. Was jemand vor drei Jahren einmal gekauft hat, hilft dagegen nicht mehr und würde die Liste nur verlängern.

Ein Antippen füllt Name, Anzahl, Gebinde, Eigenschaft sowie Markt und Warengruppe mit den zuletzt verwendeten Werten trägt den Artikel aber noch nicht ein. Der Fokus springt ins Gebinde-Feld, damit noch Änderungen möglich sind. Erst „Hinzufügen" oder Enter übernimmt.

Sortiert nach Häufigkeit, dann nach Aktualität; höchstens acht Vorschläge.

GET /api/lists/{id}/articles/suggest?q=…

Datenbankschema

db/schema.sql enthält das vollständige Schema (Revision 0011).

Für den Normalbetrieb wird die Datei nicht gebraucht der api-Container führt beim Start alembic upgrade head aus und legt alles selbst an. Sie ist da für die Nachvollziehbarkeit im Repository, für das Aufsetzen ohne Alembic und zum Vergleich, wenn eine Migration irgendwo halb durchgelaufen ist.

docker compose up -d db
docker compose exec -T db mariadb -u root -p einkaufsapp < db/schema.sql

Der Eintrag in alembic_version am Ende ist wichtig: Ohne ihn würde Alembic beim nächsten Start alle Migrationen erneut anwenden und an den vorhandenen Tabellen scheitern.

Sicherheit

docs/sicherheit.md Ergebnisse der Prüfung auf SQL-Injection, XSS, Berechtigungen, Sitzungsbehandlung und Datensparsamkeit, dazu die bekannten Grenzen.

Betriebsdokumentation

docs/betrieb.md Sicherung, Wiederherstellung, Aktualisierung, Speicherplatzkontrolle.

Kurzfassung: Gesichert gehört der Datenbankauszug (nicht das Volume ein Dateisystemabbild einer laufenden Datenbank ist nicht zuverlässig wiederherstellbar) und die .env. Der Quelltext gehört ins Versionsverwaltungssystem, die .env ausdrücklich nicht.

Das dortige Cron-Skript legt den Auszug unter /var/backups/einkaufsapp ab von dort nimmt ihn deine vorhandene Borgmatic-Sicherung mit.

Description
Plattformunabhängige selbst gehostete App für kollaborative Einkaufslisten
Readme 943 KiB
Languages
Python 53.5%
JavaScript 33.6%
CSS 6.7%
HTML 3.2%
Shell 2.8%
Other 0.1%