7 Commits
Author SHA1 Message Date
Stefan 20c2d206f8 Merge branch 'robustheit'
Docker-Image bauen / build (push) Successful in 1m53s
2026-09-14 17:22:54 +02:00
StefanandClaude Fable 5 c0a18ac735 Wettlauf beim Reservieren, 500er bei unsinnigen Eingaben
Drei P3-Funde aus der Durchsicht vom 14.09.:

Reservieren: Statusprüfung und Schreiben waren getrennt - zwei
gleichzeitige Anfragen konnten beide passieren, die zweite überschrieb
Name und Token der ersten, ohne dass die es erfuhr. Jetzt entscheidet
ein UPDATE mit Status-Bedingung; der Verlierer bekommt None und die
Route meldet "schon weg" (Seite) bzw. 409 (API).

/?limit=abc lieferte jedem anonymen Besucher einen internen
Serverfehler, limit=-1 hiess in SQLite "alles". _limit_lesen() fällt
bei Unsinn auf die Seitengrösse zurück und deckelt bei 500.

Erfassen/Bearbeiten: int(category_id) und die Pydantic-Prüfung warfen
im Handler - 500 statt Fehlermeldung. Jetzt Meldung; ausserdem werden
die Angaben VOR den Bildern geprüft, damit bei abgelehnten Angaben
keine verwaisten Bilddateien liegen bleiben.

Vier neue Tests, jeder einmal gegen den alten Code gelaufen und dabei
rot geworden. 84 lokal grün (HEIC-Test braucht pillow-heif, Docker).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 17:22:54 +02:00
Stefan feceaacabb Merge branch 'proxy-vertrauen' 2026-09-14 17:19:12 +02:00
StefanandClaude Fable 5 33c728411f X-Forwarded-For nur noch dem Proxy glauben, Durchsicht vom 14.09. notiert
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>
2026-09-14 17:19:12 +02:00
StefanandClaude Opus 5 f3ac3d526e Merge branch 'verbesserungen'
Docker-Image bauen / build (push) Successful in 1m44s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-09-14 04:35:34 +02:00
StefanandClaude Opus 5 c9861613cc GZip, Cache-Dauer fuer statische Dateien, Sicherungsskript
GZip: Die Startseite ging unkomprimiert raus - 9725 Byte, jetzt 1900.
Zuletzt angemeldet heisst zuerst ausgefuehrt, die Komprimierung liegt damit
aussen.

Statische Dateien: StaticFiles kennt keine Cache-Dauer, per Unterklasse
nachgeruestet. Bewusst nur eine Stunde - die Dateien tragen keinen
Versionsanhaenger, eine lange Dauer wuerde nach einem Update die alte
Fassung festhalten. (Die hochgeladenen Bilder hatten ihre Header laengst;
der Plan behauptete faelschlich das Gegenteil, korrigiert.)

Sicherung: sicherung.sh sichert Datenbank UND Bilder - eine Datenbank ohne
die Fotos ist wertlos. Pfade werden aus der Anwendung gelesen statt geraten.
Die Pruefung oeffnet die Kopie, nicht das Original. Erprobt: 4 Eintraege,
4 Bilder, vollstaendig; ein entferntes Bild wird erkannt.

81 Tests weiterhin gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-09-13 21:08:44 +02:00
StefanandClaude Opus 5 c748013e4a Plan fuer Verbesserungen aus der Durchsicht vom 13.09.2026
Vorabpruefung der Branches und Tests ist enthalten. Nichts davon ist
umgesetzt - der Plan haelt nur fest, was gefunden wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-09-13 20:54:09 +02:00
11 changed files with 475 additions and 25 deletions
+9 -1
View File
@@ -67,5 +67,13 @@ ENTRYPOINT ["kleiderboerse-entrypoint.sh"]
# --proxy-headers: hinter einem Reverse-Proxy stünde sonst dessen IP in
# jedem Rate-Limit, und ein einzelner Besucher sperrte alle anderen aus.
# Ausserdem erkennt die App darüber, ob der Browser per HTTPS verbunden ist.
#
# WEM die X-Forwarded-*-Kopfzeilen geglaubt werden, steuert
# FORWARDED_ALLOW_IPS - uvicorn liest die Variable selbst aus der Umgebung
# (ohne Wert: nur 127.0.0.1). Früher stand hier "--forwarded-allow-ips *":
# damit durfte jeder, der den Port direkt erreicht, die Absender-IP frei
# erfinden - pro erfundener IP ein frischer Rate-Limit-Zähler, und die
# Limits sind das Einzige vor dem Reservieren-Endpunkt. Im Betrieb die
# Adresse des Proxys eintragen (siehe docker-compose.betrieb.yml).
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--proxy-headers", "--forwarded-allow-ips", "*"]
"--proxy-headers"]
+220
View File
@@ -0,0 +1,220 @@
# Plan: Verbesserungen
Erstellt am 13.09.2026 nach einer Durchsicht des gesamten Repos, parallel zur
Kantone-App. Ergänzt `Plan.md`, der den ursprünglichen Aufbau beschreibt.
**Noch nichts davon ist umgesetzt.**
## Vorabprüfung (13.09.2026, erledigt)
| | |
|---|---|
| Arbeitsverzeichnis | sauber |
| Offene Branches | **keine** |
| Tests | **81 grün in 12 Sekunden** |
| Letzter Tag | v1.0.0 |
**Der CI-Lauf zu v1.0.0 wurde nie bestätigt.** Der Tag wurde einmal auf dem
Server gelöscht und danach auf `e0212cf` neu gesetzt. Vor dem nächsten
Release prüfen, ob in der Registry tatsächlich ein Image liegt.
## Zum Zustand des Codes
Die Durchsicht hat wenig ergeben, und das ist das Ergebnis: Escaping, CSRF,
Token-Vergleich in konstanter Zeit, Bild-Upload mit Inhaltsprüfung statt
Endung, bewusst vermiedenes N+1, CHECK-Constraints in der Datenbank statt
nur in Python — das ist alles vorhanden und richtig gemacht.
Die Punkte unten sind Ergänzungen, keine Korrekturen.
---
## P1 — Keine Sicherung der Datenbank ✅ ERLEDIGT 13.09.2026
Der wichtigste offene Punkt, und hier wiegt er schwerer als bei der
Kantone-App: In `kleiderboerse.sqlite` stecken Einträge und in `uploads/`
die dazugehörigen Fotos. **Beides ist nicht wiederherstellbar** — anders als
die Kantonsdaten, die sich neu importieren liessen.
`docker-compose.betrieb.yml` sieht keinen Sicherungsschritt vor.
Ein `cp` der laufenden Datei genügt nicht; sie kann mitten in einer
Schreiboperation erwischt werden. Richtig ist `sqlite3 ... ".backup ..."`
oder `VACUUM INTO`, beide konsistent auch bei laufendem Zugriff.
**Vorgehen**
1. Täglicher Lauf, sieben Stände, danach rollierend überschreiben.
2. **Die Bilder gehören dazu** — eine Datenbank ohne `uploads/` ist wertlos.
3. Einmal einen Stand tatsächlich zurückspielen und prüfen, dass die
Galerie danach vollständig ist. Eine ungetestete Sicherung ist keine.
Punkt 3 ist kein Formalismus: Am 13.09.2026 ist Jellyfin auf demselben
Netzwerk an einer Datenbank-Migration hängengeblieben. Dass es dort
Sicherungen gab, war der einzige Grund, warum die Lage beherrschbar blieb.
## P2 — Antworten werden nicht komprimiert ✅ ERLEDIGT 13.09.2026
`app/main.py` bindet keine `GZipMiddleware` ein. Jede HTML- und
HTMX-Antwort geht unkomprimiert über die Leitung — bei einer Galerie mit
vielen Kacheln merkbar, besonders über Mobilfunk.
```python
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)
```
## P2 — Statische Dateien ohne Cache-Dauer ✅ ERLEDIGT 13.09.2026
```python
app.mount("/static", StaticFiles(directory=...), name="static")
```
Ohne `max_age` werden `htmx.min.js`, `theme.js` und die beiden
Swagger-Dateien bei jedem Aufruf neu verhandelt. Eine lange Cache-Dauer
setzen und bei Änderungen einen Versionsanhänger an die URL hängen.
**Korrektur:** Für die hochgeladenen Bilder galt das *nicht* — die haben
ihre Cache-Header längst (`app/routers/images.py` setzt
`max-age=31536000, immutable`). Das war eine Fehlannahme beim Erstellen
dieses Plans.
## P4 — Zwei Filterspalten ohne Index
`crud.items_suchen()` filtert unter anderem nach `gender` und `season`.
Beide Spalten haben keinen Index; `status`, `size` und `category_id` haben
einen.
Bei den zu erwartenden Stückzahlen ist das ohne praktische Bedeutung — der
Vollständigkeit halber notiert, nicht als Handlungsbedarf.
---
## Reihenfolge
1. Sicherungen einrichten, inklusive Bilder, mit einer echten Rückspielprobe
2. GZip und Cache-Dauer — zusammen wenige Zeilen
3. Rest nach Bedarf
---
## Umsetzung am 13.09.2026 (Branch `verbesserungen`)
| Punkt | Ergebnis |
|---|---|
| Sicherung | `sicherung.sh`, Datenbank **und** Bilder, geprüft |
| GZip | Startseite 9725 → 1900 Byte (80 %) |
| Statische Dateien | `Cache-Control: public, max-age=3600` |
| Tests | 81 grün, unverändert |
Die Sicherung wurde gegen den laufenden Container erprobt: 4 Einträge,
4 Bilder, alle in der Datenbank verzeichneten Bilder auch wirklich im
Sicherungsordner. Gegenprobe mit entferntem Bild wird erkannt.
Offen bleibt P4 (Indizes auf `gender`/`season`) — ohne praktische Bedeutung.
---
## Durchsicht 14.09.2026
Erneute Durchsicht des gesamten Repos, mit Schwerpunkt auf den seit dem
13.09. gemergten Änderungen (`c986161`, Branch `verbesserungen`) — die
waren bisher ungeprüft.
**Ergebnis der Nachprüfung:** GZip, Cache-Header und Sicherungsskript sind
korrekt umgesetzt. Tests lokal ausgeführt: **80 von 81 grün** — der eine
rote (`test_heic_vom_iphone_wird_angenommen`) liegt allein daran, dass
`pillow-heif` in der improvisierten Umgebung fehlte (Python 3.14, kein
Wheel); im Docker-Testimage ist das Paket enthalten. Kein Code-Fehler.
### P2 — `--forwarded-allow-ips "*"` macht die Rate-Limits umgehbar ✅ ERLEDIGT 14.09.2026
Das `CMD` im Dockerfile vertraut `X-Forwarded-For` von **jedem** Absender.
Wer den Container-Port direkt erreicht (das Compose veröffentlicht
`8090:8000` auf dem Host), setzt den Header selbst und bekommt pro
erfundener IP einen frischen Zähler — die Rate-Limits sind laut eigenem
Kommentar „das Einzige, was zwischen einem Skript und dem Bestand steht",
und genau die fallen damit. Zusätzlich lässt sich so die HTTPS-Erkennung
fürs Cookie fälschen.
Hinter einem sauber konfigurierten Proxy, der den Header **ersetzt**, und
mit einem von aussen unerreichbaren Port 8000 ist alles gut — aber darauf
verlässt sich das Image derzeit stillschweigend.
**Vorgehen:** `FORWARDED_ALLOW_IPS` als Umgebungsvariable durchreichen
(Standard: leer = keinem trauen) und in `docker-compose.betrieb.yml` die
Proxy-IP eintragen; dort ausserdem den Hinweis ergänzen, dass der
veröffentlichte Port nur für den Proxy erreichbar sein darf.
### P3 — Öffentlicher 500er über `?limit=abc` ✅ ERLEDIGT 14.09.2026
`pages.py` (`galerie`, `liste_ausschnitt`) rechnet
`int(request.query_params.get("limit") or SEITE)` ohne Prüfung — `/?limit=x`
liefert jedem anonymen Besucher einen internen Serverfehler, `?limit=-1`
bedeutet in SQLite „alles". Die API-Seite macht es mit
`Query(ge=1, le=200)` längst richtig; dieselbe Grenze hier nachziehen
(ungültig → Standardwert).
### P3 — Betreiber-Formulare: 500 statt Fehlermeldung ✅ ERLEDIGT 14.09.2026
In `erfassen` und `bearbeiten` (`pages.py`) wird `int(category_id)` ohne
Prüfung gerechnet, und `ItemAnlegen(...)` wirft bei ungültigen Werten eine
ValidationError **im** Handler — beides ergibt einen 500er statt der sonst
üblichen freundlichen Meldung. Erreichbar nur angemeldet, und die
HTML-Formulare (Dropdown, `maxlength`) verhindern es auf dem normalen Weg —
es bricht aber das Muster, das `reservieren` mit try/except vormacht.
### P3 — Reservieren: Prüfen und Setzen sind nicht atomar ✅ ERLEDIGT 14.09.2026
`reservieren` prüft erst `status == available` und schreibt dann. Zwei
gleichzeitige Anfragen können beide die Prüfung passieren; der zweite
Commit überschreibt Name und Token der ersten Person, ohne dass die es
erfährt — und genau dieser Fall (zwei wollen dasselbe Stück) ist der
wahrscheinlichste Konflikt der ganzen App. Robust wäre ein
`UPDATE … SET status='reserved' … WHERE id=? AND status='available'` mit
Prüfung der betroffenen Zeilen.
### P4 — Kleinigkeiten (neu)
* **HEIC-Test** könnte mit `pytest.importorskip("pillow_heif")` sauber
übersprungen werden, wenn das Paket fehlt — dann bleibt die Suite auch
ausserhalb des Docker-Images aussagekräftig.
* **`description` ohne Längenbegrenzung** (`schemas.py`) — Titel und
Grösse haben eine, das Beschreibungsfeld nicht. Nur vom Betreiber
befüllbar.
* **Freigabe-Link trägt das Token in der URL** (GET `/kleid/…/freigeben`)
— landet damit in Proxy-Logs und Browserverlauf. Für den Zweck
(einmalige Selbst-Freigabe) hingenommen, der Vollständigkeit halber
notiert.
### Stand der alten Punkte
P4 (Indizes auf `gender`/`season`) bleibt offen und bleibt bedeutungslos.
### Umsetzung des P2 am 14.09.2026
`--forwarded-allow-ips "*"` ist aus dem `CMD` entfernt; uvicorn liest die
Grenze jetzt aus der Umgebungsvariable `FORWARDED_ALLOW_IPS` (ohne Wert:
nur 127.0.0.1, also keinem externen Absender trauen — fail closed).
`docker-compose.betrieb.yml` reicht die Variable mit Erklärung durch, das
README beschreibt, welche Adresse hineingehört und dass der Port nur für
den Proxy erreichbar sein darf.
Bewusste Folge der sicheren Vorgabe: Wer die Variable nicht setzt, hat
funktionierende Rate-Limits, aber alle Besucher teilen sich die Zähler
über die Proxy-IP. Das ist das kleinere Übel gegenüber frei erfindbaren
Absenderadressen. **Beim nächsten Deployment die Proxy-IP eintragen und
einmal prüfen, dass `request.client.host` die echte Besucher-IP zeigt.**
### Umsetzung der drei P3 am 14.09.2026
| Punkt | Ergebnis |
|---|---|
| `?limit=abc` | `_limit_lesen()` in `pages.py`: ungültig → Seitengrösse, Deckel 500 |
| Betreiber-Formulare | Angaben werden **vor** dem Bildspeichern geprüft; ValueError → Meldung statt 500 (und keine verwaisten Bilddateien mehr) |
| Reservieren | `crud.reservieren()` schreibt per `UPDATE … WHERE status='available'`; der Verlierer des Wettlaufs bekommt None → „schon weg" |
Vier neue Tests decken genau diese Fälle ab; alle vier wurden nach der
Hausregel einmal gegen den alten Code laufen gelassen und sind dabei
nachweislich rot geworden. Suite: **85 Tests** (84 lokal grün, der
HEIC-Test braucht `pillow-heif` und läuft im Docker-Testimage).
+7
View File
@@ -81,6 +81,13 @@ Die App gehört hinter einen Reverse-Proxy, der HTTPS beendet. Wichtig dabei:
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`
+20 -8
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import secrets
from sqlalchemy import func, select
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session, selectinload
from app.models import Groesse, Item, ItemBild, Kategorie, Status, jetzt
@@ -135,17 +135,29 @@ def item_loeschen(db: Session, item: Item) -> list[str]:
# --------------------------------------------------------- Reservierung ---
def reservieren(db: Session, item: Item, name: str) -> str:
def reservieren(db: Session, item: Item, name: str) -> str | None:
"""Reserviert und gibt das Token zurück, mit dem sich das rückgängig
machen lässt. Das Token verlässt die Anwendung nur dieses eine Mal."""
machen lässt. Das Token verlässt die Anwendung nur dieses eine Mal.
Prüfen und Setzen stehen in EINEM UPDATE mit Status-Bedingung: zwei
gleichzeitige Anfragen könnten sonst beide die Prüfung in der Route
passieren, und die zweite überschriebe Name und Token der ersten -
ohne dass die es je erfährt. So gewinnt genau eine; die andere
bekommt None und der Aufrufer meldet "schon weg"."""
token = secrets.token_urlsafe(32)
item.status = Status.reserved.value
item.reserved_by = name
item.reservation_token = token
item.updated_at = jetzt()
betroffen = db.execute(
update(Item)
.where(Item.id == item.id, Item.status == Status.available.value)
.values(
status=Status.reserved.value,
reserved_by=name,
reservation_token=token,
updated_at=jetzt(),
)
).rowcount
db.commit()
db.refresh(item)
return token
return token if betroffen else None
def freigeben(db: Session, item: Item) -> Item:
+26 -1
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from slowapi.errors import RateLimitExceeded
@@ -75,6 +76,11 @@ app.add_middleware(
max_age=_cfg.sitzung_max_ruhe_sekunden,
)
# Zuletzt angemeldet heisst zuerst ausgefuehrt: die Komprimierung liegt damit
# aussen und erfasst auch die Antworten der uebrigen Middleware. Unter 1000
# Byte lohnt sie nicht - der Kopf der gzip-Huelle frisst den Gewinn auf.
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.state.limiter = limiter
@@ -123,7 +129,26 @@ app.include_router(images.router)
app.include_router(pages.router)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
class StatischMitCache(StaticFiles):
"""StaticFiles kennt keine Cache-Dauer - hier nachgeruestet.
Ohne das verhandelt der Browser htmx.min.js, theme.js und die beiden
Swagger-Dateien bei jedem Seitenaufruf neu. Eine Stunde, nicht ein Jahr:
die Dateien tragen keinen Versionsanhaenger im Namen, eine lange Dauer
wuerde nach einem Update die alte Fassung festhalten.
"""
def file_response(self, *args, **kwargs):
antwort = super().file_response(*args, **kwargs)
antwort.headers.setdefault("Cache-Control", "public, max-age=3600")
return antwort
app.mount(
"/static",
StatischMitCache(directory=Path(__file__).parent / "static"),
name="static",
)
@app.get("/docs", include_in_schema=False)
+5
View File
@@ -154,6 +154,11 @@ def item_reservieren(
status_code=409, detail="Dieses Kleidungsstück ist nicht mehr verfügbar."
)
token = crud.reservieren(db, item, daten.reserved_by)
if token is None:
# Zwischen Prüfung und Schreiben war jemand schneller.
raise HTTPException(
status_code=409, detail="Dieses Kleidungsstück ist nicht mehr verfügbar."
)
return ReservierenAus(item=_als_antwort(item), reservation_token=token)
+53 -15
View File
@@ -127,9 +127,24 @@ def _liste_daten(db: Session, request: Request, limit: int) -> dict:
}
def _limit_lesen(request: Request) -> int:
"""?limit= kommt aus der URL und ist damit Nutzereingabe.
Ohne die Prüfung liefert /?limit=abc jedem anonymen Besucher einen
internen Serverfehler, und limit=-1 heisst in SQLite "alles". Der
Deckel ist bewusst grosszügiger als die 200 der API: "Mehr anzeigen"
wächst in 24er-Schritten und soll den ganzen Bestand erreichen können.
"""
try:
wert = int(request.query_params.get("limit", ""))
except ValueError:
return SEITE
return max(1, min(wert, 500))
@router.get("/", response_class=HTMLResponse)
def galerie(request: Request, db: Session = Depends(get_db)):
limit = int(request.query_params.get("limit") or SEITE)
limit = _limit_lesen(request)
return vorlagen.TemplateResponse(
request, "galerie.html", _umgebung(request, db, kategorien=crud.kategorien(db),
groessen=crud.groessen(db), **_liste_daten(db, request, limit)),
@@ -139,7 +154,7 @@ def galerie(request: Request, db: Session = Depends(get_db)):
@router.get("/teil/liste", response_class=HTMLResponse)
def liste_ausschnitt(request: Request, db: Session = Depends(get_db)):
"""Nur die Liste - von HTMX beim Filtern und Nachladen geholt."""
limit = int(request.query_params.get("limit") or SEITE)
limit = _limit_lesen(request)
return vorlagen.TemplateResponse(
request, "_liste.html", _umgebung(request, db, **_liste_daten(db, request, limit))
)
@@ -181,6 +196,9 @@ def reservieren(
return _weiter(f"/kleid/{item_id}", "Bitte nur einen Namen angeben.", "fehler")
token = crud.reservieren(db, item, geprueft.reserved_by)
if token is None:
# Zwischen Prüfung und Schreiben war jemand schneller.
return _weiter(f"/kleid/{item_id}", "Das ist leider schon weg.", "fehler")
freigabe = request.url_for("freigeben_per_link", item_id=item.id)
return vorlagen.TemplateResponse(
request, "reserviert.html", _umgebung(request, db, item=item, freigabe_url=f"{freigabe}?token={token}"),
@@ -319,16 +337,28 @@ def erfassen(
security.betreiber_noetig(request)
_csrf_oder_fehler(request, csrf)
# Erst die Angaben prüfen, dann die Bilder speichern: scheitert die
# Prüfung, lägen sonst schon Dateien ohne Eintrag auf der Platte.
# int() und die Pydantic-Prüfung werfen beide ValueError - ohne das
# Abfangen würde daraus ein 500er statt einer Fehlermeldung. Die
# HTML-Formulare verhindern das zwar auf dem normalen Weg, aber ein
# Handgriff an der Anfrage darf keinen Serverfehler auslösen.
try:
daten = ItemAnlegen(
title=title, description=description, size=size,
category_id=int(category_id), gender=gender, season=season,
condition=condition,
)
except ValueError:
return _weiter("/erfassen",
"Bitte die Angaben prüfen - Kategorie wählen, "
"Titel höchstens 100 Zeichen.", "fehler")
try:
namen = _bilder_speichern(dateien)
except images.BildFehler as e:
return _weiter("/erfassen", str(e), "fehler")
daten = ItemAnlegen(
title=title, description=description, size=size,
category_id=int(category_id), gender=gender, season=season,
condition=condition,
)
item = crud.item_anlegen(db, daten)
for name in namen:
crud.bild_anhaengen(db, item, name)
@@ -405,19 +435,27 @@ def bearbeiten(
if item is None:
raise HTTPException(status_code=404, detail="Nicht gefunden.")
war_entwurf = item.status == Status.draft.value
# Wie beim Erfassen: erst prüfen, dann Bilder - und ValueError (int,
# Pydantic) als Meldung statt als 500er.
try:
daten = ItemAendern(
title=title, description=description, size=size,
category_id=int(category_id), gender=gender, season=season,
condition=condition,
# Ein Entwurf wird durchs Nachtragen sichtbar.
status=Status.available if war_entwurf else None,
)
except ValueError:
return _weiter(f"/kleid/{item_id}/bearbeiten",
"Bitte die Angaben prüfen - Kategorie wählen, "
"Titel höchstens 100 Zeichen.", "fehler")
try:
namen = _bilder_speichern(dateien)
except images.BildFehler as e:
return _weiter(f"/kleid/{item_id}/bearbeiten", str(e), "fehler")
war_entwurf = item.status == Status.draft.value
daten = ItemAendern(
title=title, description=description, size=size,
category_id=int(category_id), gender=gender, season=season,
condition=condition,
# Ein Entwurf wird durchs Nachtragen sichtbar.
status=Status.available if war_entwurf else None,
)
try:
crud.item_aendern(db, item, daten)
except IntegrityError:
+10
View File
@@ -42,6 +42,16 @@ services:
RATE_LIMIT_RESERVE: "5/hour"
RATE_LIMIT_LOGIN: "10/hour"
# Nur diesem Absender werden die X-Forwarded-*-Kopfzeilen geglaubt.
# Hier gehört die Adresse hin, mit der der Reverse-Proxy im Container
# ankommt - bei Docker meist das Bridge-Gateway (nachsehen mit:
# docker compose logs, oder "172.17.0.1" bzw. "172.18.0.1" probieren).
# Leer = keinem trauen: die App läuft trotzdem, aber alle Besucher
# teilen sich dann die Rate-Limits über die Adresse des Proxys.
# Niemals "*" eintragen: dann kann jeder, der den Port direkt
# erreicht, mit erfundenen Adressen die Rate-Limits umgehen.
FORWARDED_ALLOW_IPS: ""
volumes:
# Hier liegen Datenbank und Bilder. Ohne diese beiden Zeilen wäre nach
# einem Neustart alles weg.
Executable
+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh
# Sichert Datenbank UND Bilder der Kleiderboerse.
#
# Beides gehoert zusammen: eine Datenbank ohne die zugehoerigen Fotos ist
# wertlos, und Fotos ohne Datenbank sind ein Haufen Dateien mit zufaelligen
# Namen. Anders als bei der Kantone-App laesst sich hier nichts neu
# importieren - was weg ist, ist weg.
#
# Warum nicht einfach cp: SQLite schreibt nicht atomar. Eine Kopie mitten im
# Schreibvorgang kann unbrauchbar sein, und das faellt erst auf, wenn man sie
# braucht. "VACUUM INTO" liefert auch bei laufendem Betrieb einen
# konsistenten Stand.
#
# Aufruf (Container muss laufen):
# ./sicherung.sh [zielordner]
#
# Taeglich um 03:00 per cron:
# 0 3 * * * /pfad/zu/sicherung.sh /pfad/zu/sicherungen >> /var/log/kleiderboerse-sicherung.log 2>&1
set -eu
CONTAINER="${KLEIDER_CONTAINER:-kleiderboerse-app-1}"
ZIEL="${1:-./sicherungen}"
BEHALTEN="${BEHALTEN:-7}"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "FEHLER: Container '$CONTAINER' laeuft nicht." >&2
echo "Anderer Name? Ueber KLEIDER_CONTAINER setzen." >&2
exit 1
fi
mkdir -p "$ZIEL"
STAND="$(date +%Y%m%d-%H%M%S)"
ORDNER="$ZIEL/kleiderboerse-$STAND"
mkdir -p "$ORDNER"
# Erzeugen und pruefen laufen im Container: der Host braucht nur Docker.
# Die Pruefung oeffnet bewusst die KOPIE, nicht das Original - nur dann sagt
# die Zahl etwas ueber die Sicherung aus.
ANZAHL="$(docker exec "$CONTAINER" python -c '
import os, sqlite3
from app.config import einstellungen
# Nicht raten: die Anwendung kennt ihre Pfade, und sie koennen ueber
# DATABASE_PATH / UPLOAD_PATH abweichen.
quelle = str(einstellungen().datenbank_pfad)
ziel = "/tmp/sicherung.sqlite"
if os.path.exists(ziel):
os.unlink(ziel)
con = sqlite3.connect(quelle)
con.execute("VACUUM INTO ?", (ziel,))
con.close()
kopie = sqlite3.connect(ziel)
print(kopie.execute("SELECT COUNT(*) FROM items").fetchone()[0])
kopie.close()
' 2>/dev/null)"
if ! printf '%s' "${ANZAHL:-}" | grep -qE '^[0-9]+$'; then
echo "FEHLER: Sicherung der Datenbank fehlgeschlagen (keine Zahl zurueck)." >&2
rmdir "$ORDNER" 2>/dev/null || true
exit 1
fi
docker cp "$CONTAINER:/tmp/sicherung.sqlite" "$ORDNER/kleiderboerse.sqlite"
docker exec "$CONTAINER" rm -f /tmp/sicherung.sqlite
# Die Bilder dazu. Ohne sie zeigt die wiederhergestellte Galerie nur Luecken.
UPLOADS="$(docker exec "$CONTAINER" python -c '
from app.config import einstellungen
print(einstellungen().upload_pfad)
')"
docker cp "$CONTAINER:$UPLOADS" "$ORDNER/uploads" 2>/dev/null || mkdir -p "$ORDNER/uploads"
BILDER="$(find "$ORDNER/uploads" -name '*.webp' | wc -l)"
echo "$(date '+%d.%m.%Y %H:%M') $ORDNER ($ANZAHL Eintraege, $BILDER Bilder, $(du -sh "$ORDNER" | cut -f1))"
# Warnen, wenn Eintraege da sind, aber keine Bilder - dann stimmt am Pfad
# etwas nicht und die Sicherung waere halb.
if [ "$ANZAHL" -gt 0 ] && [ "$BILDER" -eq 0 ]; then
echo " WARNUNG: $ANZAHL Eintraege, aber kein einziges Bild gesichert. Upload-Pfad pruefen." >&2
fi
# Alte Staende entfernen, neueste zuerst behalten.
ls -1dt "$ZIEL"/kleiderboerse-*/ 2>/dev/null | tail -n "+$((BEHALTEN + 1))" | while read -r alt; do
echo " entferne alten Stand: $(basename "$alt")"
rm -rf "$alt"
done
+16
View File
@@ -89,3 +89,19 @@ def test_name_ohne_werbung(client, item_id):
json={"reserved_by": "Jetzt billig kaufen http://spam.example"},
)
assert antwort.status_code == 422
def test_wettlauf_beim_reservieren_gewinnt_nur_einer(client, item_id, db_sitzung):
"""Zwei Anfragen können beide die Statusprüfung in der Route passieren,
bevor eine schreibt. Das UPDATE mit Status-Bedingung lässt trotzdem nur
eine gewinnen - die zweite bekommt None und überschreibt nichts."""
from app import crud
item = crud.item_holen(db_sitzung, item_id)
erster = crud.reservieren(db_sitzung, item, "A")
zweiter = crud.reservieren(db_sitzung, item, "B")
assert erster is not None
assert zweiter is None
assert item.reserved_by == "A"
assert item.reservation_token is not None
+23
View File
@@ -263,3 +263,26 @@ def test_farbschema_skript_laeuft_vor_dem_zeichnen(client):
def test_umschalter_ist_vorhanden(client):
assert 'id="schema-knopf"' in client.get("/").text
# ------------------------------------------------- Unsinnige Eingaben ---
def test_unsinniges_limit_ist_kein_serverfehler(gast):
"""?limit= kommt aus der URL - Buchstaben oder negative Werte dürfen
keinen 500er auslösen, sondern fallen auf die Seitengrösse zurück."""
assert gast.get("/?limit=abc").status_code == 200
assert gast.get("/teil/liste?limit=-5").status_code == 200
def test_unsinnige_kategorie_gibt_meldung_statt_500(client, kategorie_id):
antwort = erfasse(client, kategorie_id, category_id="abc")
assert antwort.status_code == 200
assert "Bitte die Angaben prüfen" in antwort.text
def test_zu_langer_titel_gibt_meldung_statt_500(client, kategorie_id):
"""maxlength im HTML schützt nur den Browserweg - die Anwendung muss
es selbst abfangen."""
antwort = erfasse(client, kategorie_id, title="x" * 101)
assert antwort.status_code == 200
assert "Bitte die Angaben prüfen" in antwort.text