From a9fd36f31cdbaa3434edf08bbc8876e433febe0c Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 2 Oct 2026 10:14:13 +0200 Subject: [PATCH] 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 --- README.md | 6 +++ netbox_utilities/__init__.py | 2 +- netbox_utilities/views.py | 99 +++++++++++++++++++++++++++--------- pyproject.toml | 2 +- 4 files changed, 83 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5e640f8..3db806d 100644 --- a/README.md +++ b/README.md @@ -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 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 Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 36c3314..d36b83a 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.14.0" +__version__ = "0.14.1" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py index 84eb59e..cdff733 100644 --- a/netbox_utilities/views.py +++ b/netbox_utilities/views.py @@ -1,3 +1,6 @@ +from contextlib import contextmanager +from io import BytesIO + from dcim.models import Device, Module, Rack from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin @@ -50,6 +53,24 @@ def _safe_return_url(request, default_name="home"): 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): template_name = "netbox_utilities/bulk_image_upload.html" @@ -65,31 +86,41 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): parent = self._get_parent(request, request.POST) form = BulkImageUploadForm(request.POST, request.FILES) if form.is_valid(): - image_forms = self._build_image_forms(parent, form.cleaned_data) - invalid = [image_form for image_form in image_forms if not image_form.is_valid()] - if invalid: - for image_form in invalid: - 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: - created.append(image_form.save()) - except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error: + with raised_image_pixel_limit(): + return self._process_upload(request, form, parent) + return self._render(request, form, parent) + + def _process_upload(self, request, form, parent): + image_forms = self._build_image_forms(parent, form.cleaned_data) + invalid = [image_form for image_form in image_forms if not image_form.is_valid()] + if invalid: + for image_form in invalid: + image = image_form.files.get("image") + reason = self._image_error_reason(image) + if reason: + form.add_error("images", f"{getattr(image, 'name', 'Bild')}: Pillow meldet: {reason}") + for image_form in invalid: + 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: - image_file = image_form.instance.image - if image_file.name and image_file._committed: - image_file.delete(save=False) - form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}") - else: - 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)) + created.append(image_form.save()) + except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error: + for image_form in image_forms: + image_file = image_form.instance.image + if image_file.name and image_file._committed: + image_file.delete(save=False) + form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}") + else: + 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) @staticmethod @@ -109,6 +140,26 @@ class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): queryset = queryset.restrict(request.user, "view") 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 def _build_image_forms(parent, cleaned_data): image_forms = [] diff --git a/pyproject.toml b/pyproject.toml index 4100cf2..bba0f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] 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" readme = "README.md" requires-python = ">=3.12"