Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c2d206f8 | ||
|
|
c0a18ac735 | ||
|
|
feceaacabb | ||
|
|
33c728411f | ||
|
|
f3ac3d526e | ||
|
|
c9861613cc | ||
|
|
c748013e4a |
+9
-1
@@ -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"]
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+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)
|
||||
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:
|
||||
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,
|
||||
)
|
||||
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)
|
||||
for name in namen:
|
||||
crud.bild_anhaengen(db, item, name)
|
||||
@@ -405,12 +435,10 @@ def bearbeiten(
|
||||
if item is None:
|
||||
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
|
||||
# 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,
|
||||
@@ -418,6 +446,16 @@ def bearbeiten(
|
||||
# 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")
|
||||
|
||||
try:
|
||||
crud.item_aendern(db, item, daten)
|
||||
except IntegrityError:
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user