Lesen darf jeder, schreiben nur der Betreiber - mit der bewussten Ausnahme des Reservierens. Dazu Dockerfile und Compose-Datei, damit sich das lokal ausprobieren lässt. Beim Bauen sind zwei Fehler aufgefallen, die ohne Test nicht aufgefallen wären: 1. slowapi zählt pro URL-Pfad. Weil jedes Kleidungsstück eine eigene URL hat, bekam jedes seinen eigenen Zähler - ein Skript hätte also den gesamten Bestand reservieren können, ohne je an ein Limit zu stossen. Genau der Missbrauch, gegen den das Limit gedacht ist. Behoben mit shared_limit und festem scope. 2. Der erste Anlauf des Tests machte fünf Anfragen gegen ein Limit von fünf und konnte damit gar nichts zeigen. Geprüft wird jetzt der tatsächlich ausgelieferte Standardwert, mit mehr Anfragen als erlaubt. Weiter umgesetzt: - Passwort als bcrypt-Hash aus der Umgebung, einmal beim Start gebildet und gemerkt. Bei jeder Anfrage neu gehasht liesse sich die Anwendung sonst ohne Anmeldung lahmlegen - bcrypt ist absichtlich langsam. - Ohne hinterlegtes Passwort bleibt der Erfassungsbereich gesperrt (503) statt offen zu stehen. Kein mitgeliefertes Standardpasswort. - Sitzung als signiertes Cookie, HttpOnly, SameSite=Lax (blockt seitenfremde POSTs), Secure abschaltbar nur fürs lokale Testen, Abmeldung nach zwei Stunden Ruhe. - CSP mit script-src 'self', nosniff, frame-ancestors none, dazu noindex und robots.txt: die Galerie ist frei zugänglich, soll aber nicht dauerhaft im Suchindex stehen. - Der Betreiber darf Reservierungen ohne Token aufheben, damit sich eine Missbrauchswelle aufräumen lässt. - uvicorn mit --proxy-headers: hinter einem Reverse-Proxy zählte sonst alles auf dessen IP, und ein einzelner Besucher sperrte alle aus. - Container läuft nicht als root; Code gehört root, nur Daten und Bilder dem Dienstbenutzer. Umgebungsvariablen heissen jetzt ausdrücklich englisch (ADMIN_PASSWORD, SECRET_KEY, ...), passend zur Anleitung und zur Kantone-App. 59 Tests, alle grün. Zusätzlich gegen den laufenden Container geprüft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
190 lines
7.1 KiB
Python
190 lines
7.1 KiB
Python
"""Kleidungsstücke: durchsuchen, verwalten, reservieren.
|
|
|
|
Der Schreibschutz (Anmeldung, Rate-Limit) kommt in Phase 3 dazu; die
|
|
Berechtigungslogik der Reservierung steckt aber schon hier, weil sie den
|
|
Datenfluss bestimmt.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import crud, images, security
|
|
from app.config import einstellungen
|
|
from app.database import get_db
|
|
from app.models import Geschlecht, Item, Saison, Status
|
|
from app.schemas import (
|
|
ItemAendern,
|
|
ItemAnlegen,
|
|
ItemAus,
|
|
ItemListeAus,
|
|
ReservierenAus,
|
|
ReservierenRein,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/v1/items", tags=["Kleidungsstücke"])
|
|
|
|
|
|
def _item_oder_404(db: Session, item_id: int) -> Item:
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Kleidungsstück nicht gefunden.")
|
|
return item
|
|
|
|
|
|
def _als_antwort(item: Item) -> ItemAus:
|
|
"""Baut die Antwort und ergänzt den abgeleiteten Titel.
|
|
|
|
display_title entsteht hier und nicht beim Speichern: sonst bliebe ein
|
|
automatisch gesetzter Titel stehen, wenn später die Grösse korrigiert
|
|
wird.
|
|
"""
|
|
return ItemAus.model_validate({**item.__dict__, "display_title": item.anzeige_titel,
|
|
"kategorie": item.kategorie, "bilder": item.bilder})
|
|
|
|
|
|
@router.get("", response_model=ItemListeAus)
|
|
def items_auflisten(
|
|
db: Session = Depends(get_db),
|
|
size: str | None = None,
|
|
category_id: int | None = None,
|
|
item_status: Status | None = Query(default=None, alias="status"),
|
|
gender: Geschlecht | None = None,
|
|
season: Saison | None = None,
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
):
|
|
treffer, gesamt = crud.items_suchen(
|
|
db,
|
|
size=size,
|
|
category_id=category_id,
|
|
status=item_status.value if item_status else None,
|
|
gender=gender.value if gender else None,
|
|
season=season.value if season else None,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
return ItemListeAus(
|
|
items=[_als_antwort(i) for i in treffer],
|
|
total=gesamt,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
@router.get("/{item_id}", response_model=ItemAus)
|
|
def item_anzeigen(item_id: int, db: Session = Depends(get_db)):
|
|
return _als_antwort(_item_oder_404(db, item_id))
|
|
|
|
|
|
@router.post("", response_model=ItemAus, status_code=status.HTTP_201_CREATED,
|
|
dependencies=[security.BetreiberNoetig])
|
|
def item_anlegen(daten: ItemAnlegen, db: Session = Depends(get_db)):
|
|
if daten.category_id is not None and crud.kategorie_holen(db, daten.category_id) is None:
|
|
raise HTTPException(status_code=422, detail="Kategorie gibt es nicht.")
|
|
try:
|
|
item = crud.item_anlegen(db, daten)
|
|
except IntegrityError as e:
|
|
db.rollback()
|
|
# Greift z.B., wenn ein Eintrag ohne Grösse oder Kategorie
|
|
# veröffentlicht werden soll (CHECK in models.py).
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Ein sichtbarer Eintrag braucht Grösse und Kategorie.",
|
|
) from e
|
|
return _als_antwort(item)
|
|
|
|
|
|
@router.patch("/{item_id}", response_model=ItemAus,
|
|
dependencies=[security.BetreiberNoetig])
|
|
def item_aendern(item_id: int, daten: ItemAendern, db: Session = Depends(get_db)):
|
|
item = _item_oder_404(db, item_id)
|
|
if daten.category_id is not None and crud.kategorie_holen(db, daten.category_id) is None:
|
|
raise HTTPException(status_code=422, detail="Kategorie gibt es nicht.")
|
|
try:
|
|
geaendert = crud.item_aendern(db, item, daten)
|
|
except IntegrityError as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Ein sichtbarer Eintrag braucht Grösse und Kategorie.",
|
|
) from e
|
|
return _als_antwort(geaendert)
|
|
|
|
|
|
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT,
|
|
dependencies=[security.BetreiberNoetig])
|
|
def item_loeschen(item_id: int, db: Session = Depends(get_db)):
|
|
item = _item_oder_404(db, item_id)
|
|
# ON DELETE CASCADE räumt nur die Zeilen in item_images weg. Die Dateien
|
|
# müssen hier weg, sonst läuft das Upload-Verzeichnis mit Waisen voll.
|
|
dateien = crud.item_loeschen(db, item)
|
|
for name in dateien:
|
|
images.loeschen(einstellungen().upload_pfad, name)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
# --------------------------------------------------------- Reservierung ---
|
|
|
|
@router.post("/{item_id}/reserve", response_model=ReservierenAus)
|
|
# shared_limit statt limit: slowapi zählt sonst pro URL-Pfad, und weil jedes
|
|
# Kleidungsstück eine eigene URL hat, bekäme jedes seinen eigenen Zähler -
|
|
# ein Skript könnte also den gesamten Bestand reservieren, ohne je an ein
|
|
# Limit zu stossen. Der feste scope fasst alle Reservierungen zusammen.
|
|
@security.limiter.shared_limit(
|
|
lambda: einstellungen().limit_reservieren, scope="reservieren"
|
|
)
|
|
def item_reservieren(
|
|
request: Request,
|
|
item_id: int,
|
|
daten: ReservierenRein,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Reserviert ein Kleidungsstück - bewusst ohne Anmeldung.
|
|
|
|
Die Galerie ist frei zugänglich, es gibt also keine vorgelagerte Hürde.
|
|
Das Rate-Limit ist damit das Einzige, was zwischen einem Skript und dem
|
|
gesamten Bestand steht.
|
|
"""
|
|
item = _item_oder_404(db, item_id)
|
|
if item.status == Status.draft.value:
|
|
raise HTTPException(status_code=404, detail="Kleidungsstück nicht gefunden.")
|
|
if item.status != Status.available.value:
|
|
raise HTTPException(
|
|
status_code=409, detail="Dieses Kleidungsstück ist nicht mehr verfügbar."
|
|
)
|
|
token = crud.reservieren(db, item, daten.reserved_by)
|
|
return ReservierenAus(item=_als_antwort(item), reservation_token=token)
|
|
|
|
|
|
@router.post("/{item_id}/release", response_model=ItemAus)
|
|
def item_freigeben(
|
|
request: Request,
|
|
item_id: int,
|
|
token: str | None = Query(default=None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Hebt eine Reservierung auf.
|
|
|
|
Erlaubt für den Reservierenden (mit Token) und den Betreiber. Ohne
|
|
Token wäre der Endpunkt offen - dann könnte jeder Besucher fremde
|
|
Reservierungen löschen, und weil reserved_by mitgeht, bliebe nicht
|
|
einmal nachvollziehbar, dass jemand reserviert hatte.
|
|
"""
|
|
item = _item_oder_404(db, item_id)
|
|
if item.status != Status.reserved.value:
|
|
raise HTTPException(status_code=409, detail="Dieses Kleidungsstück ist nicht reserviert.")
|
|
if not security.betreiber_optional(request) and not crud.token_gueltig(item, token):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Zum Aufheben braucht es den Link aus der Reservierung.",
|
|
)
|
|
return _als_antwort(crud.freigeben(db, item))
|
|
|
|
|
|
@router.post("/{item_id}/mark-given", response_model=ItemAus,
|
|
dependencies=[security.BetreiberNoetig])
|
|
def item_abgegeben(item_id: int, db: Session = Depends(get_db)):
|
|
item = _item_oder_404(db, item_id)
|
|
return _als_antwort(crud.als_abgegeben_markieren(db, item))
|