iPhones fotografieren HEIC; ohne pillow-heif wies die Bildpruefung jedes Foto ab. Dazu: ein erkannter Vogel ohne bestimmbare Art wird nicht mehr als "kein Vogel" gemeldet, und der Ausschnitt fuer die Sammlung ist quadratisch.
152 lines
6.4 KiB
Python
152 lines
6.4 KiB
Python
"""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 die Instanz jedes Foto von Christians 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"
|