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

350 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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. 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:
```html
<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.3``1.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.