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
146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""Bilder hochladen, ausliefern und löschen."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
|
from fastapi.responses import FileResponse
|
|
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 Item, ItemBild
|
|
from app.schemas import BildAus, ItemAus
|
|
|
|
router = APIRouter(tags=["Bilder"])
|
|
|
|
|
|
def _hochladen(datei: UploadFile) -> str:
|
|
cfg = einstellungen()
|
|
rohdaten = datei.file.read(cfg.max_upload_bytes + 1)
|
|
try:
|
|
return images.speichern(rohdaten, cfg.upload_pfad, cfg.max_upload_bytes)
|
|
except images.BildFehler as e:
|
|
raise HTTPException(status_code=422, detail=str(e)) from e
|
|
|
|
|
|
# ------------------------------------------------------------- Ausliefern ---
|
|
|
|
@router.get("/uploads/{dateiname}", response_class=FileResponse)
|
|
def bild_ausliefern(dateiname: str):
|
|
"""Liefert ein hochgeladenes Bild aus.
|
|
|
|
Der Name kommt aus der URL und ist damit Nutzereingabe - auch wenn wir
|
|
ihn ursprünglich selbst vergeben haben. Ohne die Musterprüfung liesse
|
|
sich hier jede Datei des Systems abrufen ("../../etc/passwd").
|
|
|
|
Die Bilder liegen bewusst nicht unter static/: sonst wäre jede
|
|
hochgeladene Datei unter ihrem Namen direkt abrufbar, ohne dass ein
|
|
fester Content-Type gesetzt werden kann.
|
|
"""
|
|
if not images.dateiname_gueltig(dateiname):
|
|
raise HTTPException(status_code=404, detail="Bild nicht gefunden.")
|
|
|
|
pfad = einstellungen().upload_pfad / dateiname
|
|
if not pfad.is_file():
|
|
raise HTTPException(status_code=404, detail="Bild nicht gefunden.")
|
|
|
|
return FileResponse(
|
|
pfad,
|
|
media_type="image/webp",
|
|
headers={
|
|
# Fest gesetzt, damit der Browser nichts anderes hineinliest.
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
},
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------- Hochladen ---
|
|
|
|
@router.post(
|
|
"/api/v1/items/{item_id}/images",
|
|
response_model=list[BildAus],
|
|
status_code=status.HTTP_201_CREATED,
|
|
tags=["Bilder"],
|
|
dependencies=[security.BetreiberNoetig],
|
|
)
|
|
def bilder_hochladen(
|
|
item_id: int,
|
|
dateien: list[UploadFile] = File(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Kleidungsstück nicht gefunden.")
|
|
|
|
neue: list[ItemBild] = []
|
|
for datei in dateien:
|
|
name = _hochladen(datei)
|
|
neue.append(crud.bild_anhaengen(db, item, name))
|
|
db.refresh(item)
|
|
return neue
|
|
|
|
|
|
@router.post(
|
|
"/api/v1/items/batch-upload",
|
|
response_model=list[ItemAus],
|
|
status_code=status.HTTP_201_CREATED,
|
|
tags=["Bilder"],
|
|
dependencies=[security.BetreiberNoetig],
|
|
)
|
|
def stapel_import(
|
|
dateien: list[UploadFile] = File(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Stapel-Import: pro Foto ein Entwurf.
|
|
|
|
Der Weg für den Erstbestand - erst alles mit der Kamera-App
|
|
fotografieren, dann alle Fotos auf einmal hochladen und die Angaben
|
|
später nachtragen. Die Entwürfe erscheinen nicht in der Galerie.
|
|
|
|
Ein unlesbares Bild lässt den ganzen Stapel scheitern, statt still
|
|
übersprungen zu werden: bei zwanzig Fotos fiele sonst niemandem auf,
|
|
dass eines fehlt.
|
|
"""
|
|
namen = [_hochladen(datei) for datei in dateien]
|
|
|
|
entwuerfe = crud.entwuerfe_anlegen(db, len(namen))
|
|
for entwurf, name in zip(entwuerfe, namen):
|
|
crud.bild_anhaengen(db, entwurf, name)
|
|
db.refresh(entwurf)
|
|
|
|
return [
|
|
ItemAus.model_validate(
|
|
{**e.__dict__, "display_title": e.anzeige_titel,
|
|
"kategorie": e.kategorie, "bilder": e.bilder}
|
|
)
|
|
for e in entwuerfe
|
|
]
|
|
|
|
|
|
# ----------------------------------------------------------------- Ändern ---
|
|
|
|
@router.post("/api/v1/images/{bild_id}/primary", response_model=BildAus,
|
|
tags=["Bilder"], dependencies=[security.BetreiberNoetig])
|
|
def hauptbild_setzen(bild_id: int, db: Session = Depends(get_db)):
|
|
bild = db.get(ItemBild, bild_id)
|
|
if bild is None:
|
|
raise HTTPException(status_code=404, detail="Bild nicht gefunden.")
|
|
return crud.hauptbild_setzen(db, bild)
|
|
|
|
|
|
@router.delete(
|
|
"/api/v1/images/{bild_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
tags=["Bilder"],
|
|
dependencies=[security.BetreiberNoetig],
|
|
)
|
|
def bild_loeschen(bild_id: int, db: Session = Depends(get_db)):
|
|
bild = db.get(ItemBild, bild_id)
|
|
if bild is None:
|
|
raise HTTPException(status_code=404, detail="Bild nicht gefunden.")
|
|
dateiname = crud.bild_loeschen(db, bild)
|
|
images.loeschen(einstellungen().upload_pfad, dateiname)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|