fix: resize oversized image attachments during import
This commit is contained in:
@@ -137,7 +137,11 @@ meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und
|
|||||||
Full-Depth-Belegungen werden dabei berücksichtigt.
|
Full-Depth-Belegungen werden dabei berücksichtigt.
|
||||||
Bei Bildanhängen werden Breite und Höhe direkt aus der Bilddatei im Archiv
|
Bei Bildanhängen werden Breite und Höhe direkt aus der Bilddatei im Archiv
|
||||||
ermittelt. Dadurch sind die Pflichtfelder von NetBox auch im Prüflauf und bei
|
ermittelt. Dadurch sind die Pflichtfelder von NetBox auch im Prüflauf und bei
|
||||||
Dateispeichern ohne unmittelbaren Modell-Save gesetzt.
|
Dateispeichern ohne unmittelbaren Modell-Save gesetzt. Bilder oberhalb des in
|
||||||
|
NetBox 4.6.x verwendeten Limits von 25 Millionen Pixeln werden proportional auf
|
||||||
|
höchstens 20 Millionen Pixel verkleinert und im Importbericht als Warnung
|
||||||
|
ausgewiesen. Zum Schutz des Importprozesses bleibt eine harte Quellgrenze von
|
||||||
|
100 Millionen Pixeln bestehen.
|
||||||
|
|
||||||
Auf Quelle und Ziel müssen jeweils dieselben Plugin-Versionen und Migrationen
|
Auf Quelle und Ziel müssen jeweils dieselben Plugin-Versionen und Migrationen
|
||||||
installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei
|
installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class NetBoxExportConfig(PluginConfig):
|
|||||||
name = "netbox_export"
|
name = "netbox_export"
|
||||||
verbose_name = "NetBox-Export"
|
verbose_name = "NetBox-Export"
|
||||||
description = "Portable ZIP export and import for tenants and locations"
|
description = "Portable ZIP export and import for tenants and locations"
|
||||||
version = "0.3.10"
|
version = "0.3.11"
|
||||||
author = "NetBox Export contributors"
|
author = "NetBox Export contributors"
|
||||||
base_url = "netbox-export"
|
base_url = "netbox-export"
|
||||||
min_version = "4.6.0"
|
min_version = "4.6.0"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def export_scope(
|
|||||||
"created_at": datetime.now(UTC).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"source_instance": str(InstanceIdentity.local_id()),
|
"source_instance": str(InstanceIdentity.local_id()),
|
||||||
"source_netbox_version": getattr(getattr(settings, "RELEASE", None), "version", "4.6"),
|
"source_netbox_version": getattr(getattr(settings, "RELEASE", None), "version", "4.6"),
|
||||||
"plugin_version": "0.3.10",
|
"plugin_version": "0.3.11",
|
||||||
"scope": {
|
"scope": {
|
||||||
"type": scope_type,
|
"type": scope_type,
|
||||||
"source_pk": str(scope_id),
|
"source_pk": str(scope_id),
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
|
import warnings
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from dataclasses import field as dataclass_field
|
from dataclasses import field as dataclass_field
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -10,8 +14,8 @@ from django.apps import apps
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.images import get_image_dimensions
|
|
||||||
from django.db import IntegrityError, models, transaction
|
from django.db import IntegrityError, models, transaction
|
||||||
|
from PIL import Image as PillowImage
|
||||||
|
|
||||||
from netbox_export.models import ImportedObjectMapping
|
from netbox_export.models import ImportedObjectMapping
|
||||||
|
|
||||||
@@ -53,6 +57,11 @@ EXPLICIT_IDENTITIES = {
|
|||||||
"virtualization.vminterface": ("virtual_machine", "name"),
|
"virtualization.vminterface": ("virtual_machine", "name"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NETBOX_IMAGE_MAX_PIXELS = 25_000_000
|
||||||
|
IMPORTED_IMAGE_TARGET_PIXELS = 20_000_000
|
||||||
|
IMPORTED_IMAGE_SOURCE_MAX_PIXELS = 100_000_000
|
||||||
|
_IMAGE_LIMIT_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class IdentityNotReady(Exception):
|
class IdentityNotReady(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -462,33 +471,118 @@ def _set_generic_relations(obj, record, resolver, *, allow_deferred: bool):
|
|||||||
return unresolved, missing_required
|
return unresolved, missing_required
|
||||||
|
|
||||||
|
|
||||||
def _set_files(obj, record, assets, saved_files, *, dry_run: bool):
|
def _open_image_with_bounded_override(data):
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error", PillowImage.DecompressionBombWarning)
|
||||||
|
image = PillowImage.open(stream)
|
||||||
|
return stream, image
|
||||||
|
except (PillowImage.DecompressionBombError, PillowImage.DecompressionBombWarning):
|
||||||
|
stream.close()
|
||||||
|
except Exception:
|
||||||
|
stream.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
with _IMAGE_LIMIT_LOCK:
|
||||||
|
original_limit = PillowImage.MAX_IMAGE_PIXELS
|
||||||
|
PillowImage.MAX_IMAGE_PIXELS = IMPORTED_IMAGE_SOURCE_MAX_PIXELS
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", PillowImage.DecompressionBombWarning)
|
||||||
|
image = PillowImage.open(stream)
|
||||||
|
except PillowImage.DecompressionBombError as exc:
|
||||||
|
stream.close()
|
||||||
|
raise ArchiveValidationError(
|
||||||
|
"Das Bild überschreitet die sichere Importgrenze von "
|
||||||
|
f"{IMPORTED_IMAGE_SOURCE_MAX_PIXELS:,} Pixeln."
|
||||||
|
) from exc
|
||||||
|
except Exception:
|
||||||
|
stream.close()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
PillowImage.MAX_IMAGE_PIXELS = original_limit
|
||||||
|
return stream, image
|
||||||
|
|
||||||
|
|
||||||
|
def _resized_image_save_options(image_format, image):
|
||||||
|
options = {}
|
||||||
|
if icc_profile := image.info.get("icc_profile"):
|
||||||
|
options["icc_profile"] = icc_profile
|
||||||
|
if image_format in ("JPEG", "MPO"):
|
||||||
|
options.update(quality=85, optimize=True, progressive=True)
|
||||||
|
elif image_format == "WEBP":
|
||||||
|
options.update(quality=85, method=4)
|
||||||
|
elif image_format in ("PNG", "GIF"):
|
||||||
|
options["optimize"] = True
|
||||||
|
elif image_format == "TIFF":
|
||||||
|
options["compression"] = "tiff_deflate"
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_image_asset(data):
|
||||||
|
stream, image = _open_image_with_bounded_override(data)
|
||||||
|
try:
|
||||||
|
width, height = image.size
|
||||||
|
source_pixels = width * height
|
||||||
|
if source_pixels > IMPORTED_IMAGE_SOURCE_MAX_PIXELS:
|
||||||
|
raise ArchiveValidationError(
|
||||||
|
f"Das Bild mit {source_pixels:,} Pixeln überschreitet die sichere Importgrenze von "
|
||||||
|
f"{IMPORTED_IMAGE_SOURCE_MAX_PIXELS:,} Pixeln."
|
||||||
|
)
|
||||||
|
if source_pixels <= NETBOX_IMAGE_MAX_PIXELS:
|
||||||
|
return data, width, height, None
|
||||||
|
|
||||||
|
scale = math.sqrt(IMPORTED_IMAGE_TARGET_PIXELS / source_pixels)
|
||||||
|
target_size = (max(1, math.floor(width * scale)), max(1, math.floor(height * scale)))
|
||||||
|
image.thumbnail(target_size, PillowImage.Resampling.LANCZOS, reducing_gap=3.0)
|
||||||
|
image_format = image.format or "PNG"
|
||||||
|
if image_format == "MPO":
|
||||||
|
image_format = "JPEG"
|
||||||
|
output = io.BytesIO()
|
||||||
|
image.save(output, format=image_format, **_resized_image_save_options(image_format, image))
|
||||||
|
resized_width, resized_height = image.size
|
||||||
|
resize_info = (width, height, resized_width, resized_height)
|
||||||
|
return output.getvalue(), resized_width, resized_height, resize_info
|
||||||
|
finally:
|
||||||
|
image.close()
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_files(obj, record, assets, saved_files, *, dry_run: bool, import_warnings=None):
|
||||||
for name, spec in record.get("files", {}).items():
|
for name, spec in record.get("files", {}).items():
|
||||||
if not spec or "path" not in spec or spec["path"] not in assets:
|
if not spec or "path" not in spec or spec["path"] not in assets:
|
||||||
continue
|
continue
|
||||||
filename = spec.get("name", spec["path"]).replace("\\", "/").rsplit("/", 1)[-1]
|
filename = spec.get("name", spec["path"]).replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
field = obj._meta.get_field(name)
|
field = obj._meta.get_field(name)
|
||||||
content = ContentFile(assets[spec["path"]])
|
content_data = assets[spec["path"]]
|
||||||
dimensions = None
|
dimensions = (None, None)
|
||||||
if isinstance(field, models.ImageField):
|
if isinstance(field, models.ImageField):
|
||||||
dimensions = get_image_dimensions(content)
|
content_data, width, height, resize_info = _prepare_image_asset(content_data)
|
||||||
content.seek(0)
|
dimensions = (width, height)
|
||||||
width, height = dimensions
|
|
||||||
if field.width_field and width is not None:
|
if field.width_field and width is not None:
|
||||||
setattr(obj, field.width_field, width)
|
setattr(obj, field.width_field, width)
|
||||||
if field.height_field and height is not None:
|
if field.height_field and height is not None:
|
||||||
setattr(obj, field.height_field, height)
|
setattr(obj, field.height_field, height)
|
||||||
|
if resize_info is not None and import_warnings is not None:
|
||||||
|
old_width, old_height, new_width, new_height = resize_info
|
||||||
|
record_id = record.get("id", obj._meta.label_lower)
|
||||||
|
import_warnings.append(
|
||||||
|
f"Bild für {record_id} wurde von {old_width}×{old_height} auf "
|
||||||
|
f"{new_width}×{new_height} Pixel verkleinert."
|
||||||
|
)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
continue
|
continue
|
||||||
|
content = ContentFile(content_data)
|
||||||
file_value = getattr(obj, name)
|
file_value = getattr(obj, name)
|
||||||
file_value.save(filename, content, save=False)
|
file_value.save(filename, content, save=False)
|
||||||
saved_files.append((file_value.storage, file_value.name))
|
saved_files.append((file_value.storage, file_value.name))
|
||||||
if dimensions is not None:
|
width, height = dimensions
|
||||||
width, height = dimensions
|
if isinstance(field, models.ImageField) and field.width_field and width is not None:
|
||||||
if field.width_field and width is not None:
|
setattr(obj, field.width_field, width)
|
||||||
setattr(obj, field.width_field, width)
|
if isinstance(field, models.ImageField) and field.height_field and height is not None:
|
||||||
if field.height_field and height is not None:
|
setattr(obj, field.height_field, height)
|
||||||
setattr(obj, field.height_field, height)
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_files(saved_files):
|
def _cleanup_files(saved_files):
|
||||||
@@ -594,7 +688,14 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
|
|||||||
pending.pop(record_id)
|
pending.pop(record_id)
|
||||||
progressed = True
|
progressed = True
|
||||||
continue
|
continue
|
||||||
_set_files(obj, record, parsed.assets, saved_files, dry_run=dry_run)
|
_set_files(
|
||||||
|
obj,
|
||||||
|
record,
|
||||||
|
parsed.assets,
|
||||||
|
saved_files,
|
||||||
|
dry_run=dry_run,
|
||||||
|
import_warnings=report.warnings,
|
||||||
|
)
|
||||||
if device_placement is not None:
|
if device_placement is not None:
|
||||||
_stage_device_placement(obj)
|
_stage_device_placement(obj)
|
||||||
compatibility.prepare_initial_save(obj, is_new=existing is None)
|
compatibility.prepare_initial_save(obj, is_new=existing is None)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-export"
|
name = "netbox-export"
|
||||||
version = "0.3.10"
|
version = "0.3.11"
|
||||||
description = "Portable ZIP export and import for scoped NetBox data"
|
description = "Portable ZIP export and import for scoped NetBox data"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from django.core.files.storage import InMemoryStorage
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from netbox_export.services import importer as importer_module
|
||||||
from netbox_export.services.importer import _cleanup_files, _set_files
|
from netbox_export.services.importer import _cleanup_files, _set_files
|
||||||
|
|
||||||
|
|
||||||
@@ -65,3 +66,42 @@ def test_file_import_keeps_derived_dimensions_after_storage_save(monkeypatch):
|
|||||||
|
|
||||||
_cleanup_files(saved_files)
|
_cleanup_files(saved_files)
|
||||||
assert not storage.exists(obj.image.name)
|
assert not storage.exists(obj.image.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_image_is_resized_after_pillow_bomb_error(monkeypatch):
|
||||||
|
monkeypatch.setattr(importer_module, "NETBOX_IMAGE_MAX_PIXELS", 50)
|
||||||
|
monkeypatch.setattr(importer_module, "IMPORTED_IMAGE_TARGET_PIXELS", 40)
|
||||||
|
monkeypatch.setattr(importer_module, "IMPORTED_IMAGE_SOURCE_MAX_PIXELS", 200)
|
||||||
|
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 50)
|
||||||
|
field = ImageAsset._meta.get_field("image")
|
||||||
|
storage = InMemoryStorage()
|
||||||
|
monkeypatch.setattr(field, "storage", storage)
|
||||||
|
obj = ImageAsset()
|
||||||
|
record = image_record()
|
||||||
|
record["id"] = "extras.imageattachment:17"
|
||||||
|
assets = {"assets/extras.imageattachment_17/image/server-room.png": image_bytes(11, 10)}
|
||||||
|
import_warnings = []
|
||||||
|
saved_files = []
|
||||||
|
|
||||||
|
_set_files(
|
||||||
|
obj,
|
||||||
|
record,
|
||||||
|
assets,
|
||||||
|
saved_files,
|
||||||
|
dry_run=False,
|
||||||
|
import_warnings=import_warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert obj.image_width * obj.image_height <= 40
|
||||||
|
with storage.open(obj.image.name, "rb") as stored_file, Image.open(stored_file) as stored_image:
|
||||||
|
assert stored_image.size == (obj.image_width, obj.image_height)
|
||||||
|
assert stored_image.format == "PNG"
|
||||||
|
assert import_warnings == [
|
||||||
|
(
|
||||||
|
f"Bild für extras.imageattachment:17 wurde von 11×10 auf "
|
||||||
|
f"{obj.image_width}×{obj.image_height} Pixel verkleinert."
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert Image.MAX_IMAGE_PIXELS == 50
|
||||||
|
|
||||||
|
_cleanup_files(saved_files)
|
||||||
|
|||||||
Reference in New Issue
Block a user