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:
@@ -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.
|
||||
Reference in New Issue
Block a user