Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c2d206f8 | ||
|
|
c0a18ac735 | ||
|
|
feceaacabb | ||
|
|
33c728411f |
+9
-1
@@ -67,5 +67,13 @@ ENTRYPOINT ["kleiderboerse-entrypoint.sh"]
|
|||||||
# --proxy-headers: hinter einem Reverse-Proxy stünde sonst dessen IP in
|
# --proxy-headers: hinter einem Reverse-Proxy stünde sonst dessen IP in
|
||||||
# jedem Rate-Limit, und ein einzelner Besucher sperrte alle anderen aus.
|
# jedem Rate-Limit, und ein einzelner Besucher sperrte alle anderen aus.
|
||||||
# Ausserdem erkennt die App darüber, ob der Browser per HTTPS verbunden ist.
|
# 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", \
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
|
||||||
"--proxy-headers", "--forwarded-allow-ips", "*"]
|
"--proxy-headers"]
|
||||||
|
|||||||
@@ -112,3 +112,109 @@ Die Sicherung wurde gegen den laufenden Container erprobt: 4 Einträge,
|
|||||||
Sicherungsordner. Gegenprobe mit entferntem Bild wird erkannt.
|
Sicherungsordner. Gegenprobe mit entferntem Bild wird erkannt.
|
||||||
|
|
||||||
Offen bleibt P4 (Indizes auf `gender`/`season`) — ohne praktische Bedeutung.
|
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).
|
||||||
|
|||||||
@@ -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
|
die App nicht, dass der Browser verschlüsselt verbunden ist, und alle
|
||||||
Rate-Limits zählen auf die Adresse des Proxys – ein einzelner Besucher
|
Rate-Limits zählen auf die Adresse des Proxys – ein einzelner Besucher
|
||||||
sperrte damit alle anderen aus.
|
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.
|
- `COOKIE_HTTPS_ONLY` auf `"true"` lassen.
|
||||||
|
|
||||||
Die Galerie ist frei zugänglich, wird aber per `noindex` und `robots.txt`
|
Die Galerie ist frei zugänglich, wird aber per `noindex` und `robots.txt`
|
||||||
|
|||||||
+20
-8
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.models import Groesse, Item, ItemBild, Kategorie, Status, jetzt
|
from app.models import Groesse, Item, ItemBild, Kategorie, Status, jetzt
|
||||||
@@ -135,17 +135,29 @@ def item_loeschen(db: Session, item: Item) -> list[str]:
|
|||||||
|
|
||||||
# --------------------------------------------------------- Reservierung ---
|
# --------------------------------------------------------- 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
|
"""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)
|
token = secrets.token_urlsafe(32)
|
||||||
item.status = Status.reserved.value
|
betroffen = db.execute(
|
||||||
item.reserved_by = name
|
update(Item)
|
||||||
item.reservation_token = token
|
.where(Item.id == item.id, Item.status == Status.available.value)
|
||||||
item.updated_at = jetzt()
|
.values(
|
||||||
|
status=Status.reserved.value,
|
||||||
|
reserved_by=name,
|
||||||
|
reservation_token=token,
|
||||||
|
updated_at=jetzt(),
|
||||||
|
)
|
||||||
|
).rowcount
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(item)
|
db.refresh(item)
|
||||||
return token
|
return token if betroffen else None
|
||||||
|
|
||||||
|
|
||||||
def freigeben(db: Session, item: Item) -> Item:
|
def freigeben(db: Session, item: Item) -> Item:
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ def item_reservieren(
|
|||||||
status_code=409, detail="Dieses Kleidungsstück ist nicht mehr verfügbar."
|
status_code=409, detail="Dieses Kleidungsstück ist nicht mehr verfügbar."
|
||||||
)
|
)
|
||||||
token = crud.reservieren(db, item, daten.reserved_by)
|
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)
|
return ReservierenAus(item=_als_antwort(item), reservation_token=token)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+49
-11
@@ -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)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
def galerie(request: Request, db: Session = Depends(get_db)):
|
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(
|
return vorlagen.TemplateResponse(
|
||||||
request, "galerie.html", _umgebung(request, db, kategorien=crud.kategorien(db),
|
request, "galerie.html", _umgebung(request, db, kategorien=crud.kategorien(db),
|
||||||
groessen=crud.groessen(db), **_liste_daten(db, request, limit)),
|
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)
|
@router.get("/teil/liste", response_class=HTMLResponse)
|
||||||
def liste_ausschnitt(request: Request, db: Session = Depends(get_db)):
|
def liste_ausschnitt(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Nur die Liste - von HTMX beim Filtern und Nachladen geholt."""
|
"""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(
|
return vorlagen.TemplateResponse(
|
||||||
request, "_liste.html", _umgebung(request, db, **_liste_daten(db, request, limit))
|
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")
|
return _weiter(f"/kleid/{item_id}", "Bitte nur einen Namen angeben.", "fehler")
|
||||||
|
|
||||||
token = crud.reservieren(db, item, geprueft.reserved_by)
|
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)
|
freigabe = request.url_for("freigeben_per_link", item_id=item.id)
|
||||||
return vorlagen.TemplateResponse(
|
return vorlagen.TemplateResponse(
|
||||||
request, "reserviert.html", _umgebung(request, db, item=item, freigabe_url=f"{freigabe}?token={token}"),
|
request, "reserviert.html", _umgebung(request, db, item=item, freigabe_url=f"{freigabe}?token={token}"),
|
||||||
@@ -319,16 +337,28 @@ def erfassen(
|
|||||||
security.betreiber_noetig(request)
|
security.betreiber_noetig(request)
|
||||||
_csrf_oder_fehler(request, csrf)
|
_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:
|
try:
|
||||||
namen = _bilder_speichern(dateien)
|
|
||||||
except images.BildFehler as e:
|
|
||||||
return _weiter("/erfassen", str(e), "fehler")
|
|
||||||
|
|
||||||
daten = ItemAnlegen(
|
daten = ItemAnlegen(
|
||||||
title=title, description=description, size=size,
|
title=title, description=description, size=size,
|
||||||
category_id=int(category_id), gender=gender, season=season,
|
category_id=int(category_id), gender=gender, season=season,
|
||||||
condition=condition,
|
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")
|
||||||
|
|
||||||
item = crud.item_anlegen(db, daten)
|
item = crud.item_anlegen(db, daten)
|
||||||
for name in namen:
|
for name in namen:
|
||||||
crud.bild_anhaengen(db, item, name)
|
crud.bild_anhaengen(db, item, name)
|
||||||
@@ -405,12 +435,10 @@ def bearbeiten(
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
||||||
|
|
||||||
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
|
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(
|
daten = ItemAendern(
|
||||||
title=title, description=description, size=size,
|
title=title, description=description, size=size,
|
||||||
category_id=int(category_id), gender=gender, season=season,
|
category_id=int(category_id), gender=gender, season=season,
|
||||||
@@ -418,6 +446,16 @@ def bearbeiten(
|
|||||||
# Ein Entwurf wird durchs Nachtragen sichtbar.
|
# Ein Entwurf wird durchs Nachtragen sichtbar.
|
||||||
status=Status.available if war_entwurf else None,
|
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")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
crud.item_aendern(db, item, daten)
|
crud.item_aendern(db, item, daten)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ services:
|
|||||||
RATE_LIMIT_RESERVE: "5/hour"
|
RATE_LIMIT_RESERVE: "5/hour"
|
||||||
RATE_LIMIT_LOGIN: "10/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:
|
volumes:
|
||||||
# Hier liegen Datenbank und Bilder. Ohne diese beiden Zeilen wäre nach
|
# Hier liegen Datenbank und Bilder. Ohne diese beiden Zeilen wäre nach
|
||||||
# einem Neustart alles weg.
|
# einem Neustart alles weg.
|
||||||
|
|||||||
@@ -89,3 +89,19 @@ def test_name_ohne_werbung(client, item_id):
|
|||||||
json={"reserved_by": "Jetzt billig kaufen http://spam.example"},
|
json={"reserved_by": "Jetzt billig kaufen http://spam.example"},
|
||||||
)
|
)
|
||||||
assert antwort.status_code == 422
|
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
|
||||||
|
|||||||
@@ -263,3 +263,26 @@ def test_farbschema_skript_laeuft_vor_dem_zeichnen(client):
|
|||||||
|
|
||||||
def test_umschalter_ist_vorhanden(client):
|
def test_umschalter_ist_vorhanden(client):
|
||||||
assert 'id="schema-knopf"' in client.get("/").text
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user