Phase 2: Bild-Upload, WebP-Konvertierung und Stapel-Import
Uploads werden geprüft, nach WebP gewandelt (max. 1200 px Breite) und über eine eigene Route ausgeliefert. Dazu der Stapel-Import: viele Fotos auf einmal hochladen, pro Foto entsteht ein Entwurf. Der Upload ist die grösste Angriffsfläche der Anwendung - die Galerie ist frei zugänglich, und hier landet fremdes Binärmaterial auf der Platte. Entsprechend: - Der Dateiname wird selbst erzeugt (UUID + .webp), nie aus dem Upload übernommen. Beim Ausliefern wird er zusätzlich gegen ein Muster geprüft: er kommt dort aus der URL und ist damit Nutzereingabe, auch wenn wir ihn ursprünglich vergeben haben. - Der Inhalt entscheidet, nicht die Endung - Pillow muss das Bild öffnen können. Eine als .jpg getarnte PHP-Datei fällt durch. - Pixelmasse ausdrücklich begrenzt, VOR dem Dekodieren. Pillow allein wirft erst bei der doppelten Zahl einen Fehler; dazwischen gibt es nur eine Warnung, die niemand sieht. - Gespeichert wird nur das Ergebnis. Das entfernt eingebettete Fremdinhalte und die EXIF-Daten - bei Handyfotos oft samt GPS-Koordinaten der Wohnung. Die Drehung wird vorher auf die Bildpunkte angewendet, sonst lägen Hochkant-Fotos quer. - Bilder liegen nicht unter static/, sondern werden mit festem Content-Type und nosniff ausgeliefert. HEIC wird unterstützt (pillow-heif): iPhones nehmen so auf, und ohne das fiele beim Stapel-Import jedes iPhone-Foto durch. Ein unlesbares Bild lässt bewusst den ganzen Stapel scheitern - bei zwanzig Fotos fiele sonst niemandem auf, dass eines fehlt. Beim Löschen werden die Dateien mitentfernt (ON DELETE CASCADE räumt nur die Zeilen weg), und wird das Vorschaubild gelöscht, rückt das nächste nach. 42 Tests, alle grün. Zusätzlich von Hand gegen die laufende API geprüft: 3000x2200-Foto kommt als WebP 1200x880 an, Pfadmanipulation und getarnte Dateien werden abgewiesen, Entwürfe bleiben aus der Galerie draussen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""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
|
||||
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"],
|
||||
)
|
||||
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"],
|
||||
)
|
||||
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"])
|
||||
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"],
|
||||
)
|
||||
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)
|
||||
@@ -9,7 +9,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud
|
||||
from app import crud, images
|
||||
from app.config import einstellungen
|
||||
from app.database import get_db
|
||||
from app.models import Geschlecht, Item, Saison, Status
|
||||
from app.schemas import (
|
||||
@@ -112,9 +113,11 @@ def item_aendern(item_id: int, daten: ItemAendern, db: Session = Depends(get_db)
|
||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def item_loeschen(item_id: int, db: Session = Depends(get_db)):
|
||||
item = _item_oder_404(db, item_id)
|
||||
# Die Dateien selbst löscht Phase 2 (images.py); hier fallen nur die
|
||||
# Namen an, damit die Aufräumlogik an einer Stelle liegt.
|
||||
crud.item_loeschen(db, item)
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user