fix: allow 50 MP smartphone photos in bulk image upload

NetBox sets Pillow's MAX_IMAGE_PIXELS to 25 MP, so Pillow rejects images
above 50 MP as decompression bombs. Raise the limit to 100 MP while the
bulk upload is processed and surface the underlying Pillow error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 10:14:13 +02:00
co-authored by Claude Opus 5.5
parent c7b65f123b
commit a9fd36f31c
4 changed files with 83 additions and 26 deletions
+6
View File
@@ -541,6 +541,12 @@ Anzeigename bleibt der jeweilige ursprüngliche Dateiname erhalten. Benötigt
werden weiterhin die NetBox-Berechtigung zum Hinzufügen von Bildanhängen und werden weiterhin die NetBox-Berechtigung zum Hinzufügen von Bildanhängen und
eine Leseberechtigung für das Zielobjekt. eine Leseberechtigung für das Zielobjekt.
NetBox begrenzt Pillow global auf 25 Megapixel und lehnt Bilder ab 50
Megapixeln ab. Damit aktuelle 50-MP-Smartphone-Fotos (z. B. Google Pixel,
8160×6144) hochgeladen werden können, hebt der Mehrfach-Upload dieses Limit
während der Verarbeitung auf 100 Megapixel an. Wird ein Bild abgelehnt, zeigt
das Formular zusätzlich die genaue Fehlermeldung von Pillow an.
### Module mehrfach einbauen ### Module mehrfach einbauen
Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.14.0" __version__ = "0.14.1"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
+75 -24
View File
@@ -1,3 +1,6 @@
from contextlib import contextmanager
from io import BytesIO
from dcim.models import Device, Module, Rack from dcim.models import Device, Module, Rack
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
@@ -50,6 +53,24 @@ def _safe_return_url(request, default_name="home"):
return reverse(default_name) return reverse(default_name)
# NetBox limits Pillow to 25 MP (bomb error above 50 MP), which rejects
# current 50 MP smartphone photos such as Google Pixel images (8160x6144).
BULK_IMAGE_MAX_PIXELS = 100_000_000
@contextmanager
def raised_image_pixel_limit():
from PIL import Image
original_limit = Image.MAX_IMAGE_PIXELS
if original_limit is not None and original_limit < BULK_IMAGE_MAX_PIXELS:
Image.MAX_IMAGE_PIXELS = BULK_IMAGE_MAX_PIXELS
try:
yield
finally:
Image.MAX_IMAGE_PIXELS = original_limit
class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): class BulkImageUploadView(ContentTypePermissionRequiredMixin, View):
template_name = "netbox_utilities/bulk_image_upload.html" template_name = "netbox_utilities/bulk_image_upload.html"
@@ -65,31 +86,41 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View):
parent = self._get_parent(request, request.POST) parent = self._get_parent(request, request.POST)
form = BulkImageUploadForm(request.POST, request.FILES) form = BulkImageUploadForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
image_forms = self._build_image_forms(parent, form.cleaned_data) with raised_image_pixel_limit():
invalid = [image_form for image_form in image_forms if not image_form.is_valid()] return self._process_upload(request, form, parent)
if invalid: return self._render(request, form, parent)
for image_form in invalid:
filename = getattr(image_form.files.get("image"), "name", "Bild") def _process_upload(self, request, form, parent):
for errors in image_form.errors.values(): image_forms = self._build_image_forms(parent, form.cleaned_data)
for error in errors: invalid = [image_form for image_form in image_forms if not image_form.is_valid()]
form.add_error("images", f"{filename}: {error}") if invalid:
else: for image_form in invalid:
created = [] image = image_form.files.get("image")
try: reason = self._image_error_reason(image)
with transaction.atomic(): if reason:
for image_form in image_forms: form.add_error("images", f"{getattr(image, 'name', 'Bild')}: Pillow meldet: {reason}")
created.append(image_form.save()) for image_form in invalid:
except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error: filename = getattr(image_form.files.get("image"), "name", "Bild")
for errors in image_form.errors.values():
for error in errors:
form.add_error("images", f"{filename}: {error}")
else:
created = []
try:
with transaction.atomic():
for image_form in image_forms: for image_form in image_forms:
image_file = image_form.instance.image created.append(image_form.save())
if image_file.name and image_file._committed: except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error:
image_file.delete(save=False) for image_form in image_forms:
form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}") image_file = image_form.instance.image
else: if image_file.name and image_file._committed:
count = len(created) image_file.delete(save=False)
noun = "Bild wurde" if count == 1 else "Bilder wurden" form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}")
messages.success(request, f"{count} {noun} gleichzeitig hochgeladen.") else:
return redirect(self._return_url(request, parent)) count = len(created)
noun = "Bild wurde" if count == 1 else "Bilder wurden"
messages.success(request, f"{count} {noun} gleichzeitig hochgeladen.")
return redirect(self._return_url(request, parent))
return self._render(request, form, parent) return self._render(request, form, parent)
@staticmethod @staticmethod
@@ -109,6 +140,26 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View):
queryset = queryset.restrict(request.user, "view") queryset = queryset.restrict(request.user, "view")
return get_object_or_404(queryset, pk=object_id) return get_object_or_404(queryset, pk=object_id)
@staticmethod
def _image_error_reason(image):
"""Return the underlying Pillow error, which Django hides behind a generic message."""
from PIL import Image
try:
if hasattr(image, "temporary_file_path"):
source = image.temporary_file_path()
else:
image.seek(0)
source = BytesIO(image.read())
with Image.open(source) as opened:
opened.verify()
except Exception as error: # noqa: BLE001 - surface any Pillow failure
return f"{type(error).__name__}: {error}"
finally:
if hasattr(image, "seek"):
image.seek(0)
return None
@staticmethod @staticmethod
def _build_image_forms(parent, cleaned_data): def _build_image_forms(parent, cleaned_data):
image_forms = [] image_forms = []
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.14.0" version = "0.14.1"
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7" description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"