"""Hochgeladene Bilder prüfen und sauber neu schreiben. 🔴 **Warum es diese Datei gibt.** Bis zum 14.10.2026 wanderte eine hochgeladene Datei unverändert aus ``request.FILES`` in ein ``ImageField`` und von dort auf das Volume, das ``/media/`` **ohne Anmeldung** ausliefert. Zwei Folgen, beide unsichtbar, bis es zu spät ist: 1. ⚠️ **Metadaten.** Ein Handyfoto trägt EXIF, und darin steht der Ort. Bei einer Kamera am Vogelhaus ist das folgenlos — die schreibt kein EXIF, an einem echten Bild der Live-Instanz nachgesehen. Mit der Handy-App wird es zum Garten, in dem ein Kind steht, öffentlich abrufbar. 2. ⚠️ **Was überhaupt gespeichert wird.** Django prüft bei einem ``ImageField`` nur, dass Pillow die Datei öffnen *kann*. Eine Datei kann gleichzeitig ein gültiges Bild und etwas anderes sein; unter ``/media/`` liegt sie dann auf derselben Herkunft wie das Backend. 🔑 **Deshalb nicht prüfen, sondern NEU SCHREIBEN.** Eine Prüfung sagt „sieht gültig aus" und lässt alles stehen, was sie nicht kennt. Wer die Pixel liest und neu kodiert, gibt nur weiter, was wirklich ein Bild war — Metadaten, angehängte Daten und alles Unverstandene fallen dabei von selbst weg. Das ist der Unterschied zwischen einem Filter und einem Flaschenhals. """ from __future__ import annotations import io from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile from PIL import Image, ImageOps, UnidentifiedImageError # 🔴 **Ohne das kann Pillow kein HEIC — und iPhones fotografieren HEIC.** # # Am 14.10.2026 wies eine Instanz jedes Foto vom iPhone mit 400 ab, # Minuten nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur # kannte sie das Format nicht, das die Hälfte aller Telefone erzeugt. Eine # App für iOS, die kein HEIC annimmt, nimmt nichts an. # # ⚠️ Der Import darf das Hochladen nicht zum Absturz bringen, wenn das Paket # in einer schlanken Umgebung fehlt — dann bleibt es bei JPEG und PNG, und die # Meldung an den Nutzer sagt, welches Format es war. try: # pragma: no cover - haengt an der Umgebung, nicht am Code import pillow_heif pillow_heif.register_heif_opener() HEIC_LESBAR = True except ImportError: # pragma: no cover HEIC_LESBAR = False # ⚠️ Pillow öffnet sehr große Bilder bereitwillig und belegt dabei Speicher in # der Größenordnung Breite × Höhe × 4 Bytes. Ein Bild mit absurden Maßen in # einer winzigen Datei ist der billigste Weg, einen Dienst umzuwerfen # ("decompression bomb"). Pillow warnt ab ~89 Megapixeln; wir lehnen vorher ab. MAX_PIXEL = 50_000_000 # Längere Kante nach dem Verkleinern. 2400 px ist mehr, als die Sammlung je # anzeigt, und klein genug, dass ein Album nicht die Platte füllt. MAX_KANTE = 2400 # Was wir annehmen. Bewusst eine kurze Liste bekannter Rasterformate — kein # SVG (das ist ein Dokument mit Skripten, kein Bild) und kein animiertes GIF. ERLAUBT = {"JPEG", "PNG", "WEBP", "HEIF", "HEIC", "AVIF", "MPO"} class KeinBild(Exception): """Die Datei ist kein Bild, das wir annehmen.""" def saeubern(datei: UploadedFile, name: str | None = None) -> InMemoryUploadedFile: """Liest die Pixel und gibt ein frisches JPEG ohne Metadaten zurück. Wirft ``KeinBild``, wenn die Datei kein brauchbares Bild ist. 🔑 **Die Drehung wird angewandt, nicht mitgeschleppt.** Ein Handyfoto steht oft nur deshalb richtig, weil im EXIF ein Drehwinkel steht. Wer die Metadaten wegwirft, ohne das Bild zu drehen, legt jedes Hochkantfoto auf die Seite — und zwar erst beim Anzeigen, also weit entfernt von der Ursache. """ datei.seek(0) try: bild = Image.open(datei) # ⚠️ ``verify()`` prüft die Struktur, macht das Objekt danach aber # unbrauchbar. Deshalb danach neu öffnen statt weiterzuarbeiten. bild.verify() except (UnidentifiedImageError, OSError, ValueError) as fehler: # ⚠️ Den Hinweis auf HEIC mitgeben: Ohne ihn sucht man den Fehler beim # Telefon, und er liegt an einem fehlenden Paket auf dem Server. hinweis = "" if HEIC_LESBAR else " (HEIC wird hier nicht unterstützt — pillow-heif fehlt)" raise KeinBild(f"keine lesbare Bilddatei{hinweis}: {fehler}") from fehler datei.seek(0) try: bild = Image.open(datei) if bild.format not in ERLAUBT: raise KeinBild(f"Format {bild.format!r} nehmen wir nicht an") breite, hoehe = bild.size if breite * hoehe > MAX_PIXEL: raise KeinBild(f"Bild zu groß: {breite}x{hoehe} Pixel") # Erst drehen (liest EXIF), dann alles Weitere — danach sind die # Metadaten entbehrlich. bild = ImageOps.exif_transpose(bild) # Transparenz und Palettenbilder auf RGB bringen, sonst scheitert das # Schreiben als JPEG an Bildern mit Alphakanal. if bild.mode not in ("RGB", "L"): if bild.mode in ("RGBA", "LA", "P"): grund = Image.new("RGB", bild.size, (255, 255, 255)) bild = bild.convert("RGBA") grund.paste(bild, mask=bild.split()[-1]) bild = grund else: bild = bild.convert("RGB") bild.thumbnail((MAX_KANTE, MAX_KANTE), Image.LANCZOS) ziel = io.BytesIO() # Ohne ``exif=``/``icc_profile=`` schreibt Pillow keine Metadaten mit. # Das ist die Stelle, an der der Ort verschwindet. bild.save(ziel, format="JPEG", quality=88, optimize=True) except KeinBild: raise except (OSError, ValueError) as fehler: raise KeinBild(f"Bild nicht verarbeitbar: {fehler}") from fehler finally: bild.close() if hasattr(bild, "close") else None groesse = ziel.tell() ziel.seek(0) return InMemoryUploadedFile( ziel, field_name=getattr(datei, "field_name", None), name=_jpg_name(name or datei.name or "bild"), content_type="image/jpeg", size=groesse, charset=None, ) def _jpg_name(name: str) -> str: """Dateiname mit ``.jpg``, ohne Pfadanteile. ⚠️ Nur den letzten Namensteil nehmen: Ein hochgeladener Name darf keine Verzeichnisse mitbringen. Django bereinigt das beim Speichern ebenfalls — sich darauf zu verlassen heißt aber, dass diese Funktion außerhalb von Django unsicher wäre, und sie ist einzeln testbar. """ letzter = name.replace("\\", "/").rsplit("/", 1)[-1] stamm = letzter.rsplit(".", 1)[0] or "bild" return f"{stamm[:60]}.jpg"