Das CMD startete uvicorn mit --forwarded-allow-ips "*": jeder, der den Container-Port direkt erreicht, konnte die Absender-IP frei erfinden und bekam pro erfundener IP einen frischen Rate-Limit-Zaehler - und die Limits sind das Einzige vor dem frei zugaenglichen Reservieren-Endpunkt. Zusaetzlich liess sich so die HTTPS-Erkennung fuers Sitzungs-Cookie faelschen. Jetzt liest uvicorn die Grenze aus FORWARDED_ALLOW_IPS (ohne Wert: nur 127.0.0.1, also keinem externen Absender trauen). docker-compose.betrieb.yml reicht die Variable mit Erklaerung durch; das README beschreibt, welche Adresse hineingehoert und dass der Port nur fuer den Proxy erreichbar sein darf. Wird die Variable nicht gesetzt, teilen sich alle Besucher die Zaehler ueber die Proxy-IP - das kleinere Uebel. Plan-Verbesserungen.md: Ergebnis der erneuten Durchsicht vom 14.09.2026 ergaenzt (GZip/Cache/Sicherung vom 13.09. nachgeprueft und fuer gut befunden; drei P3-Funde: oeffentlicher 500er ueber ?limit=abc, nicht atomares Reservieren, 500 statt Fehlermeldung in Betreiber-Formularen). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
190 lines
6.2 KiB
Markdown
190 lines
6.2 KiB
Markdown
# Kinderkleider-Börse
|
||
|
||
Zu klein gewordene Kinderkleidung fotografieren, in einer Galerie zeigen und
|
||
an Familie, Freunde und Nachbarn weitergeben. Wer etwas brauchen kann,
|
||
reserviert es mit einem Klick – ohne Konto, ohne Anmeldung.
|
||
|
||
Fürs Erfassen genügt das Smartphone: alles fotografieren, alle Fotos auf
|
||
einmal hochladen, die Angaben in Ruhe nachtragen.
|
||
|
||
---
|
||
|
||
## In vier Schritten
|
||
|
||
**1.** Einen leeren Ordner anlegen, zum Beispiel `kleiderboerse`.
|
||
|
||
**2.** Darin eine Datei namens `docker-compose.yml` anlegen und den Inhalt
|
||
von [`docker-compose.betrieb.yml`](docker-compose.betrieb.yml) hineinkopieren.
|
||
|
||
**3.** Zwei Werte eintragen:
|
||
|
||
```yaml
|
||
ADMIN_PASSWORD: "dein-passwort"
|
||
SECRET_KEY: "..."
|
||
```
|
||
|
||
Den `SECRET_KEY` erzeugen:
|
||
|
||
```bash
|
||
docker run --rm python:3.11-slim python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||
```
|
||
|
||
Bleibt `ADMIN_PASSWORD` leer, ist der Erfassungsbereich gesperrt – die
|
||
Galerie funktioniert trotzdem. Absichtlich ist kein Passwort vorgegeben:
|
||
ein mitgeliefertes stünde in dieser Anleitung und wäre damit bekannt.
|
||
|
||
**4.** Starten und öffnen:
|
||
|
||
```bash
|
||
docker compose up -d
|
||
```
|
||
|
||
→ <http://localhost:8090>
|
||
|
||
---
|
||
|
||
## Erfassen
|
||
|
||
Nach dem Anmelden gibt es zwei Wege:
|
||
|
||
**Viele auf einmal** – der Weg für den Erstbestand. Zuerst alles mit der
|
||
Kamera-App fotografieren, dann alle Fotos zusammen hochladen. Pro Foto
|
||
entsteht ein Entwurf; unter *Entwürfe* trägst du danach Grösse und Kategorie
|
||
nach. Grösse, Kategorie, Saison und Zustand werden dabei vom zuletzt
|
||
gespeicherten Teil übernommen – beim Abarbeiten einer Kiste sind das vier
|
||
Felder weniger pro Stück.
|
||
|
||
**Einzeln** – Formular mit Foto, für spätere Nachträge.
|
||
|
||
Der Titel ist freiwillig. Bleibt er leer, steht dort automatisch Kategorie
|
||
und Grösse, also zum Beispiel „Jacken 98/104". Wird die Grösse später
|
||
korrigiert, ändert sich der Titel mit.
|
||
|
||
## Reservieren
|
||
|
||
Besucher brauchen kein Konto. Nach dem Reservieren erscheint ein Link, mit
|
||
dem sich die eigene Reservierung wieder aufheben lässt – dieser Link sollte
|
||
aufbewahrt werden. Ohne ihn kann nur der Betreiber die Reservierung lösen.
|
||
|
||
Der Name der reservierenden Person erscheint **nicht** in der Galerie, dort
|
||
steht nur „reserviert". Sichtbar ist er ausschliesslich nach der Anmeldung.
|
||
|
||
---
|
||
|
||
## Betrieb
|
||
|
||
### Aus dem Internet erreichbar machen
|
||
|
||
Die App gehört hinter einen Reverse-Proxy, der HTTPS beendet. Wichtig dabei:
|
||
|
||
- `X-Forwarded-Proto` und `X-Forwarded-For` weiterreichen. Ohne das erkennt
|
||
die App nicht, dass der Browser verschlüsselt verbunden ist, und alle
|
||
Rate-Limits zählen auf die Adresse des Proxys – ein einzelner Besucher
|
||
sperrte damit alle anderen aus.
|
||
- `FORWARDED_ALLOW_IPS` auf die Adresse setzen, mit der der Proxy im
|
||
Container ankommt (siehe Kommentar in `docker-compose.betrieb.yml`).
|
||
Ohne den Wert werden die Kopfzeilen ignoriert; mit `"*"` könnte jeder,
|
||
der den Container-Port direkt erreicht, die Absender-IP erfinden und
|
||
damit die Rate-Limits umgehen.
|
||
- Der veröffentlichte Port (`8090`) gehört hinter die Firewall: erreichbar
|
||
nur für den Proxy, nicht aus dem Internet.
|
||
- `COOKIE_HTTPS_ONLY` auf `"true"` lassen.
|
||
|
||
Die Galerie ist frei zugänglich, wird aber per `noindex` und `robots.txt`
|
||
von Suchmaschinen ferngehalten: sonst wären Fotos und Texte noch auffindbar,
|
||
wenn die Börse längst abgeräumt ist.
|
||
|
||
### Sicherung
|
||
|
||
Zwei Volumes enthalten alles, was nicht im Code steht:
|
||
|
||
| Volume | Inhalt |
|
||
|---|---|
|
||
| `kleiderboerse-db` | Datenbank (`/data`) |
|
||
| `kleiderboerse-bilder` | hochgeladene Fotos (`/uploads`) |
|
||
|
||
```bash
|
||
docker run --rm -v kleiderboerse-db:/d -v "$PWD":/sicherung alpine \
|
||
tar czf /sicherung/db.tar.gz -C /d .
|
||
docker run --rm -v kleiderboerse-bilder:/b -v "$PWD":/sicherung alpine \
|
||
tar czf /sicherung/bilder.tar.gz -C /b .
|
||
```
|
||
|
||
### Einstellungen
|
||
|
||
Alle über Umgebungsvariablen, keine im Image:
|
||
|
||
| Variable | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `ADMIN_PASSWORD` | – | Passwort fürs Erfassen. Leer = gesperrt. |
|
||
| `ADMIN_PASSWORD_HASH` | – | Alternative: fertiger bcrypt-Hash. Hat Vorrang. |
|
||
| `SECRET_KEY` | – | Signiert das Sitzungs-Cookie. Ohne festen Wert nach jedem Neustart abgemeldet. |
|
||
| `COOKIE_HTTPS_ONLY` | `true` | Sitzungs-Cookie nur über HTTPS. |
|
||
| `RATE_LIMIT_RESERVE` | `5/hour` | Reservierungen pro IP. |
|
||
| `RATE_LIMIT_LOGIN` | `10/hour` | Anmeldeversuche pro IP. |
|
||
| `SESSION_IDLE_SECONDS` | `7200` | Abmeldung nach Ruhezeit. |
|
||
| `MAX_UPLOAD_BYTES` | `10485760` | Grösse eines einzelnen Fotos. |
|
||
| `DATABASE_PATH` | `/data/kleiderboerse.sqlite` | Ort der Datenbank. |
|
||
| `UPLOAD_PATH` | `/uploads` | Ort der Fotos. |
|
||
|
||
---
|
||
|
||
## Entwickeln
|
||
|
||
```bash
|
||
cp .env-example .env # dann ADMIN_PASSWORD darin anpassen
|
||
docker compose up -d --build
|
||
```
|
||
|
||
→ <http://localhost:8090>, API-Oberfläche unter `/docs`
|
||
|
||
Tests:
|
||
|
||
```bash
|
||
docker run --rm -v "$PWD":/app -w /app python:3.11-slim bash -c \
|
||
'pip install -q -r requirements-dev.txt && python -m pytest -q'
|
||
```
|
||
|
||
Datenbank-Migration nach einer Modelländerung:
|
||
|
||
```bash
|
||
alembic revision --autogenerate -m "Beschreibung"
|
||
```
|
||
|
||
### Aufbau
|
||
|
||
Ein Dienst, eine SQLite-Datei. FastAPI liefert sowohl die REST-API unter
|
||
`/api/v1/…` als auch die Seiten aus; HTMX tauscht beim Filtern nur die Liste
|
||
aus. Tailwind wird im Docker-Build über die Standalone-Binärdatei übersetzt –
|
||
kein Node.js nötig.
|
||
|
||
```
|
||
app/
|
||
├── main.py Einstiegspunkt, Middleware, Sicherheits-Header
|
||
├── config.py Einstellungen aus Umgebungsvariablen
|
||
├── models.py Datenmodelle
|
||
├── crud.py Datenbankzugriffe
|
||
├── security.py Anmeldung, Sitzung, CSRF, Rate-Limits
|
||
├── images.py Upload prüfen und nach WebP wandeln
|
||
├── routers/ API- und Seiten-Routen
|
||
└── templates/ Jinja2-Vorlagen
|
||
```
|
||
|
||
Fremde Dateien (HTMX, Swagger, Schriften) liegen bewusst lokal im
|
||
Repository statt von einem CDN geladen zu werden: die Content-Security-Policy
|
||
erlaubt nur `script-src 'self'`, und ein CDN-Aufruf gäbe die IP-Adresse jedes
|
||
Besuchers an einen Dritten weiter.
|
||
|
||
## Veröffentlichen
|
||
|
||
Ein Versions-Tag löst den Bau des Images aus:
|
||
|
||
```bash
|
||
git tag -a v1.0.0 -m "Version 1.0.0 – ..."
|
||
git push origin v1.0.0
|
||
```
|
||
|
||
Die CI baut, **testet das fertige Image** und stellt es erst danach in die
|
||
Registry. `latest` wird nur bei echten Versions-Tags gesetzt. Ein Push auf
|
||
`main` löst nichts aus.
|