Files
kleiderboerse/Plan.md
T
StefanandClaude Opus 5 d5bb83a94f Projektbeschrieb und Umsetzungsplan
projekt.md beschreibt die Kinderkleider-Börse (FastAPI, SQLite, Bild-Upload
mit WebP-Konvertierung). Plan.md ergänzt die dort offenen Entscheidungen:
Frontend-Variante A (Jinja2 + HTMX, kein Node.js), Erfassen hinter Passwort,
Reservieren ohne Anmeldung, erreichbar aus dem Internet.

Aus dieser Kombination folgt der Schwerpunkt des Plans - ein offener,
zustandsändernder Endpunkt im Internet und ein Bild-Upload sind die beiden
Angriffsflächen, die den Ausschlag geben.

Noch kein Code.

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

10 KiB
Raw 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
Erreichbarkeit Aus dem Internet, per Reverse-Proxy mit HTTPS

Die letzten beiden zusammen sind der eigentliche Knackpunkt dieses Projekts: ein Endpunkt, den jeder im Internet ohne Anmeldung auslösen kann, der den Zustand ändert. Alles unter „Absicherung" ergibt sich daraus.


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. Vier 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. Begründung unten unter „Offene Punkte".

  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.

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.


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. 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 (Admin-Ansicht), damit eine Missbrauchswelle in einem Rutsch aufgeräumt werden kann.

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
  • 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

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, Erfassungsformular mit Kamera-Aufnahme (<input type="file" accept="image/*" capture="environment">)
  • Reservierungs-Ablauf inklusive Selbst-Freigabe
  • Prüfpunkt: der ganze Weg auf einem echten Smartphone

Phase 5 Betrieb

  • Dockerfile (Tailwind-Build inbegriffen), docker-compose.yml, Volumes
  • Healthcheck, Backup-Hinweise für Datenbank und Bilder
  • README für Einrichtung und Reverse-Proxy

Offene Punkte

  1. Wer darf eine Reservierung wieder aufheben? projekt.md sieht POST /items/{id}/release vor, sagt aber nicht, wer ihn aufrufen darf. Ist er offen, kann jeder die Reservierung eines anderen löschen. Vorschlag: beim Reservieren wird ein Token vergeben und als Link angezeigt („Reservierung aufheben") wer ihn hat, kann die eigene zurücknehmen; der Betreiber kann es ohnehin. Bitte bestätigen, dann kommt reservation_token ins Modell.

  2. Soll die Galerie überhaupt öffentlich sein? Auch ohne Schreibzugriff zeigt sie Kinderkleidung, Namen und indirekt den Wohnort. Ein einfacher Zugangscode für die ganze Seite (einmal eingeben, bleibt im Cookie) würde Suchmaschinen und Zufallsbesucher aussperren, ohne dass jemand ein Konto braucht. Empfehlung: ja, zusätzlich zu noindex. Deine Entscheidung.

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

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