"""Bild-Uploads prüfen und nach WebP wandeln. 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. Vier Punkte sind dafür entscheidend: 1. **Der Dateiname wird selbst erzeugt**, nie aus dem Upload übernommen. Damit ist Pfad-Manipulation ("../../etc/...") von vornherein ausgeschlossen. 2. **Der Inhalt entscheidet, nicht die Endung.** Pillow muss das Bild tatsächlich öffnen können; eine als .jpg getarnte PHP-Datei fällt dabei durch. Der vom Browser gemeldete Content-Type wird nicht geglaubt. 3. **Pixelmasse begrenzen.** Ein wenige Kilobyte grosses PNG kann beim Entpacken Gigabyte belegen ("Dekompressionsbombe"). MAX_IMAGE_PIXELS deckelt das, bevor der Speicher volläuft. 4. **Nur das Ergebnis wird gespeichert**, nie das Original. Das entfernt eingebettete Fremdinhalte und die EXIF-Daten - letztere enthalten bei Handyfotos oft die GPS-Koordinaten der Wohnung. """ from __future__ import annotations import re import uuid from pathlib import Path from PIL import Image, ImageOps, UnidentifiedImageError # HEIC-Unterstützung anmelden (iPhone-Aufnahmen). try: from pillow_heif import register_heif_opener register_heif_opener() except ImportError: # pragma: no cover - nur ohne optionales Paket pass # Deckel gegen Dekompressionsbomben: 50 Megapixel sind rund doppelt so viel # wie eine aktuelle Handykamera liefert und damit unverdächtig. MAX_PIXEL = 50_000_000 Image.MAX_IMAGE_PIXELS = MAX_PIXEL MAX_BREITE = 1200 WEBP_QUALITAET = 82 # Muster der selbst erzeugten Namen. Wird beim Ausliefern geprüft: der # Dateiname kommt dort aus der URL und ist damit Nutzereingabe, auch wenn # wir ihn ursprünglich selbst vergeben haben. DATEINAME_MUSTER = re.compile(r"^[0-9a-f]{32}\.webp$") class BildFehler(ValueError): """Upload abgelehnt - die Meldung ist für Nutzende gedacht.""" def dateiname_gueltig(name: str) -> bool: return bool(DATEINAME_MUSTER.match(name)) def _oeffnen(rohdaten: bytes) -> Image.Image: from io import BytesIO try: bild = Image.open(BytesIO(rohdaten)) # verify() liest die Struktur, ohne die Pixel zu dekodieren - danach # ist das Objekt unbrauchbar, deshalb anschliessend neu öffnen. bild.verify() bild = Image.open(BytesIO(rohdaten)) # Pillow wirft von sich aus erst bei der DOPPELTEN Pixelzahl einen # Fehler; dazwischen gibt es nur eine Warnung, die niemand sieht. # Darum hier ausdrücklich prüfen - und zwar VOR load(), denn erst # das Dekodieren belegt den Speicher. breite, hoehe = bild.size if breite * hoehe > MAX_PIXEL: raise BildFehler("Das Bild hat zu viele Bildpunkte.") bild.load() return bild except BildFehler: # Eigene Meldung durchlassen. Muss vor dem ValueError-Zweig stehen: # BildFehler erbt davon und bekäme sonst die falsche Begründung. raise except Image.DecompressionBombError as e: raise BildFehler("Das Bild hat zu viele Bildpunkte.") from e except (UnidentifiedImageError, OSError, ValueError) as e: raise BildFehler("Das ist keine lesbare Bilddatei.") from e def speichern(rohdaten: bytes, ziel_ordner: Path, max_bytes: int) -> str: """Prüft, wandelt und legt ab. Gibt den erzeugten Dateinamen zurück.""" if not rohdaten: raise BildFehler("Die Datei ist leer.") if len(rohdaten) > max_bytes: raise BildFehler( f"Die Datei ist zu gross (höchstens {max_bytes // (1024 * 1024)} MB)." ) bild = _oeffnen(rohdaten) # Drehung aus den EXIF-Daten auf die Bildpunkte anwenden, bevor die # Daten wegfallen. Ohne das lägen Hochkant-Fotos in der Galerie quer. bild = ImageOps.exif_transpose(bild) # WebP kann RGB und RGBA. Alles andere (Graustufen, Palette, CMYK) # vorher umwandeln, sonst scheitert das Speichern. if bild.mode not in ("RGB", "RGBA"): bild = bild.convert("RGBA" if "A" in bild.getbands() else "RGB") if bild.width > MAX_BREITE: hoehe = round(bild.height * MAX_BREITE / bild.width) bild = bild.resize((MAX_BREITE, hoehe), Image.LANCZOS) ziel_ordner.mkdir(parents=True, exist_ok=True) name = f"{uuid.uuid4().hex}.webp" # Kein exif=, kein icc_profile: es wird ausschliesslich das Bild # geschrieben, ohne die Zusatzdaten aus dem Original. bild.save(ziel_ordner / name, "WEBP", quality=WEBP_QUALITAET, method=4) return name def loeschen(ziel_ordner: Path, name: str) -> None: """Entfernt eine Bilddatei; fehlt sie schon, ist das kein Fehler. ON DELETE CASCADE räumt nur die Zeilen in item_images weg - ohne diesen Aufruf liefe das Upload-Verzeichnis mit Waisen voll. """ if not dateiname_gueltig(name): return (ziel_ordner / name).unlink(missing_ok=True)