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
This commit is contained in:
2026-08-29 21:03:10 +02:00
co-authored by Claude Opus 5
commit d5bb83a94f
3 changed files with 388 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
# 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:
```text
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.