Files
StefanandClaude Opus 5 3cf6903df5 Reservierung aufheben und offene Galerie entschieden
- Aufheben darf der Reservierende selbst (per Token-Link) und der
  Betreiber. Ein offener release-Endpunkt wäre die naheliegende, aber
  falsche Variante: dann löscht jeder Besucher fremde Reservierungen, und
  weil reserved_by mitgeht, bleibt nicht mal nachvollziehbar, dass jemand
  reserviert hatte. Token wird beim Aufheben und beim Erledigen gelöscht,
  Vergleich mit compare_digest.
- Die Galerie bleibt frei zugänglich, ohne Zugangscode. Damit wird das
  Rate-Limit zur einzigen Bremse vor dem Reservieren-Endpunkt, und zwei
  Dinge gehören zwingend dazu: reserved_by erscheint öffentlich nur als
  "reserviert", und noindex/robots.txt verhindern, dass Fotos und Texte
  dauerhaft im Suchindex landen.

projekt.md bei /release entsprechend präzisiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-08-29 21:50:34 +02:00

16 KiB
Raw Permalink Blame History

Umsetzungsplan Kinderkleider-Börse

Grundlage: projekt.md. Dieser Plan ergänzt sie um die Entscheidungen, die dort offen waren, und um die Punkte, die durch den Internet-Zugang dazukommen.

Getroffene Entscheidungen

Frage Entscheidung
Frontend Variante A Jinja2 + HTMX + Tailwind, ein einziger Dienst, kein Node.js
Erfassen/Bearbeiten/Löschen Hinter einem Passwort
Reservieren Ohne Anmeldung
Reservierung aufheben Der Reservierende selbst (per Token-Link) und der Betreiber
Galerie Frei zugänglich, ohne Zugangscode
Erreichbarkeit Aus dem Internet, per Reverse-Proxy mit HTTPS

Die Kombination aus „frei zugänglich" und „Reservieren ohne Anmeldung" ist der eigentliche Knackpunkt: ein Endpunkt, den jeder im Internet ohne jede Hürde auslösen kann und der den Zustand ändert. Alles unter „Absicherung" ergibt sich daraus bei einer offenen Galerie ist das Rate-Limit keine Kür, sondern das Einzige, was zwischen einem Skript und dem kompletten Bestand steht.


Architektur

Ein Container, ein Prozess (uvicorn), eine SQLite-Datei. FastAPI liefert sowohl die REST-API (/api/v1/...) als auch die gerenderten Seiten aus dieselbe Anwendung, dieselben CRUD-Funktionen. HTMX ersetzt beim Filtern und Reservieren nur Teilbereiche der Seite, statt eine SPA zu bauen.

Tailwind ohne Node.js: nicht per CDN (im Betrieb nicht vorgesehen und wäre eine Fremdverbindung), sondern über die Standalone-CLI eine einzelne Binärdatei, die im Docker-Build einmalig ein fertiges style.css erzeugt. Im Image landet nur das fertige CSS.

Persistenz: ein Volume für die Datenbank, ein zweites für die Bilder. Beide ausserhalb des ausgelieferten Codes.

Projektstruktur

Die Struktur in projekt.md ist an einer Stelle verrutscht (die Module stehen auf derselben Ebene wie app/, static/ und templates/ hängen an einer Ebene, die es nicht gibt). Vorschlag:

kleiderboerse/
├── app/
│   ├── __init__.py
│   ├── main.py            # FastAPI-App, Startup, Router einhängen
│   ├── config.py          # Einstellungen aus Umgebungsvariablen
│   ├── database.py        # Engine & Session
│   ├── models.py          # SQLAlchemy-Modelle
│   ├── schemas.py         # Pydantic-Schemas
│   ├── crud.py            # Datenbankzugriffe
│   ├── security.py        # Passwort, Sitzung, CSRF, Rate-Limit
│   ├── images.py          # Upload-Prüfung, WebP-Konvertierung
│   ├── routers/
│   │   ├── items.py       # /api/v1/items
│   │   ├── categories.py  # /api/v1/categories, /sizes
│   │   └── pages.py       # HTML-Seiten (Galerie, Detail, Erfassen)
│   ├── static/            # erzeugtes CSS, Icons  (Bilder NICHT hier)
│   └── templates/
├── alembic/               # Migrationen
├── data/                  # Volume: kleiderboerse.sqlite
├── uploads/               # Volume: hochgeladene Bilder
├── tests/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── README.md

Hochgeladene Bilder liegen nicht unter static/, sondern in einem eigenen Verzeichnis, das über eine eigene Route ausgeliefert wird. Sonst wäre jede hochgeladene Datei unter ihrem Namen direkt abrufbar und ein als Bild getarntes Skript ebenfalls.


Datenmodell Abweichungen von projekt.md

Das Schema wird grundsätzlich übernommen. Fünf Ergänzungen:

  1. Feste Wertelisten statt freier VARCHAR. gender, season, condition und status bekommen ein Python-Enum plus CHECK-Bedingung in der Datenbank. Ohne das schleicht sich über die API früher oder später "Gril" oder "reserviert" ein, und die Filter greifen dann still nicht mehr.

  2. reservation_token (neu) in items. Zufälliger Wert, der beim Reservieren erzeugt wird. Nur wer ihn hat, kann die eigene Reservierung wieder aufheben; der Betreiber kann es ohnehin. Wird beim Aufheben und beim Erledigen wieder gelöscht. Einzelheiten unter „Absicherung", Punkt 2.

  3. sizes als eigene Tabelle. projekt.md sieht GET /api/v1/sizes vor, aber keine Tabelle dazu. Grössen aus den vorhandenen Einträgen zu destillieren (SELECT DISTINCT size) klingt bequem, sortiert aber falsch (98/104 vor 24) und liefert Tippfehler gleich mit als Filteroption. Darum eine gepflegte Liste mit sort_order.

  4. updated_at wird in der Anwendung gesetzt, nicht per DB-Trigger bleibt so unabhängig von SQLite-Eigenheiten.

  5. Status draft (neu). projekt.md kennt nur available, reserved und given_away. Der Stapel-Import (siehe unten) legt Einträge an, die noch keinen Titel und keine Grösse haben die dürfen nicht in der öffentlichen Galerie erscheinen. title und size müssen dafür NULL erlauben; gefüllt sein müssen sie erst beim Wechsel auf available.

Ausserdem: beim Löschen eines Eintrags müssen die Bilddateien mitgelöscht werden, sonst wächst das Upload-Verzeichnis unbegrenzt mit verwaisten Dateien. ON DELETE CASCADE räumt nur die Datenbankzeilen weg, nicht die Dateien.


Schnelles Erfassen

Beim Erstbestand sind auf einen Schlag viele Teile zu erfassen. Der Aufwand steckt dabei nicht im Fotografieren, sondern im Rundlauf pro Stück: Formular öffnen, fotografieren, Felder ausfüllen, speichern, von vorn. Zwei Massnahmen dagegen.

Stapel-Import

Zuerst wird alles mit der gewohnten Kamera-App fotografiert die ist fürs schnelle Knipsen gebaut, ohne Umweg über den Browser. Danach einmal ins Formular, alle Fotos auf einmal auswählen:

<input type="file" name="fotos" accept="image/*" multiple>

Pro Foto entsteht ein Eintrag mit Status draft. Die Details werden später nachgetragen in Ruhe, auch am Rechner mit richtiger Tastatur. Damit ist Fotografieren vom Erfassen entkoppelt.

Bewusst ohne capture="environment": das springt zwar direkt in die Rückkamera, erzwingt aber ein Foto pro Vorgang und schliesst multiple aus. Für einzelne Nachträge im Alltag ist es passend dann als zweiter, kleiner Knopf „Direkt fotografieren" neben dem regulären Feld.

Nötig dafür:

  • Ansicht „Unfertige Einträge (12)", nur für angemeldete Benutzer
  • Entwürfe erscheinen nicht in der Galerie und nicht in GET /api/v1/items, solange dort nicht ausdrücklich status=draft angefragt wird
  • Ein Entwurf wird erst zu available, wenn Grösse und Kategorie gesetzt sind

Titel ist freiwillig

title wird optional. Bleibt das Feld leer, setzt die Anwendung ihn aus Kategorie und Grösse zusammen „Hose 98/104", „Winterjacke 116". Das ist genau die Angabe, nach der man in einer Galerie ohnehin sucht, und spart beim Erfassen das mit Abstand lästigste Feld, weil es als einziges freien Text verlangt.

Erzeugt wird der Titel beim Anzeigen, nicht beim Speichern: sonst bliebe ein automatisch gesetzter Titel stehen, wenn später die Grösse korrigiert wird. In der Datenbank bleibt title dann schlicht NULL.


Absicherung

Die App ist aus dem Internet erreichbar, also gelten hier andere Massstäbe als bei einem Heimnetz-Dienst. Punkte 1 und 2 sind die wichtigsten.

1. Bild-Upload die grösste Angriffsfläche

  • Dateiname nie aus dem Upload übernehmen, sondern selbst erzeugen (UUID + .webp). Damit ist Pfad-Manipulation ausgeschlossen.
  • Vor dem Öffnen: Grösse begrenzen (z.B. 10 MB) und Inhaltstyp prüfen.
  • Image.MAX_IMAGE_PIXELS setzen sonst kann ein wenige Kilobyte grosses PNG beim Entpacken den Arbeitsspeicher füllen („Dekompressionsbombe").
  • Nach dem Konvertieren nach WebP wird nur das Ergebnis gespeichert, nie das Original. Das entfernt eingebettete Fremdinhalte und die EXIF-Daten letztere enthalten bei Handyfotos oft die GPS-Koordinaten der Wohnung.
  • Ausliefern mit festem Content-Type: image/webp und X-Content-Type-Options: nosniff.

2. Reservieren ohne Anmeldung

Ein offener, zustandsändernder Endpunkt im Internet bei frei zugänglicher Galerie ohne jede vorgelagerte Hürde. Ohne Bremse kann ein Skript in Sekunden alles reservieren oder reserved_by als Werbefläche missbrauchen.

  • Rate-Limit pro IP (slowapi), z.B. 5 Reservierungen pro Stunde.
  • reserved_by begrenzen (Länge, keine URLs) und beim Anzeigen maskieren. Jinja2 maskiert von sich aus entscheidend ist, |safe dort nirgends zu verwenden.
  • Reservierungen sind jederzeit vom Betreiber aufhebbar, auch mehrere auf einmal, damit eine Missbrauchswelle in einem Rutsch aufgeräumt werden kann.

Wer darf aufheben und wie das ohne Konten funktioniert

Zwei Wege, entsprechend der Entscheidung oben:

  1. Der Reservierende selbst. Beim Reservieren erzeugt die Anwendung ein reservation_token (Zufallswert) und zeigt danach einen Link /items/{id}/release?token=… zum Merken oder Weiterschicken. Nur wer ihn hat, kann diese Reservierung zurücknehmen.
  2. Der Betreiber, aus der Admin-Ansicht heraus, ohne Token.

Ein offener release-Endpunkt ohne Token wäre die naheliegende, aber falsche Variante: dann könnte jeder Besucher die Reservierung eines anderen löschen und weil reserved_by mitgelöscht wird, bliebe nicht einmal nachvollziehbar, dass überhaupt jemand reserviert hatte.

Das Token wird beim Aufheben und beim Erledigen (mark-given) gelöscht, damit ein alter Link nicht später eine neue Reservierung eines anderen aufhebt. Verglichen wird es mit secrets.compare_digest.

2b. Folgen der offenen Galerie

Die Galerie ist ohne Zugangscode erreichbar. Das ist eine bewusste Entscheidung; zwei Dinge gehören dann aber dazu:

  • reserved_by nicht öffentlich anzeigen (siehe Abschnitt 5). In der Galerie steht nur „reserviert", der Name erscheint ausschliesslich in der Admin-Ansicht. Ohne Zugangscode wäre der Name sonst für jeden lesbar.
  • noindex und eine robots.txt. Frei erreichbar heisst nicht, dass die Seite in Suchergebnissen auftauchen muss. Ohne das landen Fotos und Texte im Index von Google und sind auch dann noch auffindbar, wenn die Börse längst abgeräumt ist.

3. Anmeldung fürs Erfassen

  • Passwort als bcrypt-Hash aus einer Umgebungsvariablen. Kein Standardpasswort mitliefern: ohne gesetzten Wert ist der Bereich gesperrt, nicht offen.
  • Hash einmal beim Start berechnen, nicht pro Anfrage.
  • Sitzungs-Cookie HttpOnly, SameSite=Lax, Secure.
  • Secure muss X-Forwarded-Proto auswerten, nicht nur die direkte Verbindung hinter einem TLS-Reverse-Proxy sieht die App sonst „HTTP" und lässt das Cookie unverschlüsselt mitgehen.
  • Rate-Limit auch auf die Anmeldung, plus Sitzungs-Timeout.
  • CSRF-Token für alle ändernden Formulare.

4. Allgemein

  • Content-Security-Policy mit script-src 'self' (HTMX wird lokal ausgeliefert, nicht per CDN), dazu nosniff und frame-ancestors 'none'.
  • HTTPS erzwingen; uvicorn mit --proxy-headers und gesetzten forwarded-allow-ips.
  • Fehlermeldungen ohne interne Details; Stacktraces nur ins Log.
  • Pagination auf GET /api/v1/items ohne Begrenzung liefert der Endpunkt irgendwann alle Einträge samt Bildpfaden in einer Antwort.

5. Personendaten

reserved_by ist ein Personenname auf einer öffentlich erreichbaren Seite. Vorschlag: auf der Galerie nur „reserviert" anzeigen, den Namen ausschliesslich in der Admin-Ansicht. Zusätzlich beim Erledigen (mark-given) den Namen löschen. Das ist datensparsam und macht die Seite gleichzeitig unattraktiver für Spam.


Meilensteine

Die Phasen aus projekt.md, ergänzt um Tests und die Absicherung.

Phase 1 Fundament

  • Projektgerüst, requirements.txt, Einstellungen aus Umgebungsvariablen
  • SQLAlchemy-Modelle, Alembic-Erstmigration, Kategorien und Grössen vorbefüllen
  • CRUD-Schicht mit Tests
  • GET/POST/PATCH/DELETE /api/v1/items, GET /api/v1/categories, /sizes
  • Prüfpunkt: über Swagger UI ein Kleidungsstück anlegen, filtern, ändern, löschen

Phase 2 Bilder

  • Upload per multipart/form-data, mehrere Bilder pro Eintrag
  • Prüfung und WebP-Konvertierung (max. 1200 px Breite) nach Abschnitt 1 oben
  • HEIC unterstützen (pillow-heif): iPhones nehmen in diesem Format auf. Safari wandelt beim Hochladen über ein Datei-Feld meist selbst nach JPEG um aber nicht zuverlässig, und beim Stapel-Import fällt ein einzelnes nicht lesbares Bild sonst still hinten runter. Pillow allein kann HEIC nicht öffnen.
  • Ausliefer-Route, is_primary-Logik, Aufräumen beim Löschen
  • Prüfpunkt: bewusst fehlerhafte Uploads (zu gross, falscher Typ, riesige Pixelmasse, .php als .jpg getarnt) werden alle abgewiesen; ein HEIC-Bild wird angenommen

Phase 3 Absicherung

  • Anmeldung, Sitzung, CSRF, Rate-Limits, Sicherheits-Header
  • Prüfpunkt: ohne Anmeldung ist kein Schreibzugriff möglich; ohne gesetztes Passwort ist der Erfassungsbereich gesperrt

Phase 4 Oberfläche

  • Mobile-First-Layout, Galerie mit Filterleiste (HTMX)
  • Detailseite und Erfassungsformular
  • Stapel-Import mit Entwurfsliste zum Nachtragen (siehe „Schnelles Erfassen"), dazu der automatische Titel aus Kategorie und Grösse
  • Reservierungs-Ablauf inklusive Selbst-Freigabe
  • Prüfpunkt: der ganze Weg auf einem echten Smartphone 20 Fotos in einem Rutsch hochladen, danach die Entwürfe abarbeiten

Phase 5 Betrieb

  • Dockerfile (Tailwind-Build inbegriffen), docker-compose.yml, Volumes
  • Healthcheck, Backup-Hinweise für Datenbank und Bilder
  • Gitea Actions: Image bauen und in die Registry stellen (siehe unten)
  • Zwei Compose-Dateien wie bei der Kantone-App: eine zum Selberbauen aus dem Quellcode, eine zum blossen Starten des fertigen Images
  • README für Einrichtung und Reverse-Proxy

Bauen über Gitea Actions

Gleiches Vorgehen wie bei der Kantone-App (.gitea/workflows/docker-image.yml dort als Vorlage):

  • Auslöser: nur ein Versions-Tag (v*) oder ein manueller Start (workflow_dispatch) nicht jeder Push auf main. Dadurch bleibt das Zusammenführen von Branches folgenlos, und ein Release ist ein bewusster Schritt.
  • Registry: gitea.boing86.myds.me, Image docker/kleiderboerse.
  • Version: aus dem Tag (v1.2.31.2.3), bei manuellen Läufen ein Zeitstempel. Tag und Commit werden als Build-Argumente durchgereicht und in der Fusszeile angezeigt so ist sofort erkennbar, welcher Stand läuft.
  • Test vor der Veröffentlichung: Container starten und prüfen, bevor irgendetwas in die Registry geht. Bei der Kantone-App wird die API auf 26 Kantone geprüft; hier wären die Entsprechungen: Startseite antwortet mit 200, /api/v1/categories liefert die vorbefüllten Kategorien, und ein Testupload wird tatsächlich als WebP abgelegt. Schlägt das fehl, wird nichts veröffentlicht.
  • latest nur für echte Versions-Tags, nicht für manuelle Läufe.
  • Anmeldung über --password-stdin (das Token taucht so weder in der Prozessliste noch im Protokoll auf), docker logout mit if: always(). Zugangsdaten als Repo-Secrets (REGISTRY_TOKEN, REGISTRY_USER), nicht im Workflow.
  • Rechte im Job auf contents: read und packages: write begrenzen.
  • Werte aus ${{ ... }} nie direkt in die Shell schreiben, sondern über env: durchreichen. Ausdrücke werden vor der Shell ersetzt, und Tag-Namen dürfen Zeichen wie " oder $ enthalten sonst lässt sich über einen präparierten Tag Code auf dem Runner ausführen, in einem Job, der ein Registry-Token hält. (Genau das wurde in der Kantone-App nachträglich korrigiert.)

Offene Punkte

  1. Benachrichtigung bei Reservierung? Nicht in projekt.md. Ohne sie musst du selbst nachschauen. Ein einfacher Weg wäre eine Nachricht per E-Mail oder Telegram. Kann auch später kommen dann aber besser gleich als eigener Meilenstein statt nachträglich eingeschoben.

  2. Mehrere Kinder / Grössenverläufe? Aktuell ist alles ein flacher Bestand. Falls du später nach „von wem" oder „Jahrgang" filtern willst, wäre jetzt der günstige Zeitpunkt für ein zusätzliches Feld.