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
|
# --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"]
|
||||||
|
|||||||
@@ -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
|
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:
|
||||||
|
|||||||
+26
-1
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
@@ -75,6 +76,11 @@ app.add_middleware(
|
|||||||
max_age=_cfg.sitzung_max_ruhe_sekunden,
|
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
|
app.state.limiter = limiter
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +129,26 @@ app.include_router(images.router)
|
|||||||
app.include_router(pages.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)
|
@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."
|
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.
|
||||||
|
|||||||
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"},
|
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