Beim Erstbestand sind viele Teile auf einmal zu erfassen. Der Aufwand
steckt nicht im Fotografieren, sondern im Rundlauf pro Stück. Darum:
- Stapel-Import: erst alles mit der Kamera-App fotografieren, dann alle
Fotos auf einmal hochladen. Pro Foto entsteht ein Entwurf, die Details
kommen später. Bewusst ohne capture="environment" - das erzwingt ein
Foto pro Vorgang und schliesst multiple aus.
- Titel wird optional und sonst aus Kategorie und Grösse gebildet
("Hose 98/104"). Erzeugt beim Anzeigen, nicht beim Speichern, damit er
einer späteren Korrektur der Grösse folgt.
Daraus folgt ein neuer Status "draft" im Datenmodell; title und size
müssen NULL erlauben. Entwürfe erscheinen nicht in der Galerie.
Ausserdem HEIC in Phase 2 aufgenommen: iPhones nehmen so auf, und beim
Stapel-Import fiele ein nicht lesbares Bild sonst still hinten runter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
326 lines
15 KiB
Markdown
326 lines
15 KiB
Markdown
# 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. 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. 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.
|
||
|
||
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. 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
|
||
- **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. **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.
|