diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 2a9fb73..69b6f40 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig): name = "netbox_documentation" verbose_name = "NetBox Dokumentation" description = "Wiki und Office-Dokumentation direkt in NetBox" - version = "0.7.12" + version = "0.7.13" author = "LKE" base_url = "documentation" min_version = "4.0.0" diff --git a/netbox_documentation/embedded_images.py b/netbox_documentation/embedded_images.py new file mode 100644 index 0000000..a628d55 --- /dev/null +++ b/netbox_documentation/embedded_images.py @@ -0,0 +1,54 @@ +import base64 +from io import BytesIO +import re + + +class EmbeddedImageError(ValueError): + pass + + +DATA_IMAGE_RE = re.compile( + r"(?Psrc\s*=\s*(?P['\"]))" + r"data:(?Pimage/(?:png|jpeg|gif|webp));base64,(?P[^'\"]+)" + r"(?P=quote)", + re.IGNORECASE, +) + + +def _decode(match): + payload = re.sub(r"\s+", "", match.group("data")) + try: + content = base64.b64decode(payload, validate=True) + except (ValueError, TypeError) as exc: + raise EmbeddedImageError("Ein eingefügtes Bild enthält ungültige Base64-Daten.") from exc + try: + from PIL import Image + image = Image.open(BytesIO(content)) + image.verify() + except Exception as exc: + raise EmbeddedImageError("Ein eingefügter Inhalt ist keine gültige Bilddatei.") from exc + return match.group("mime").lower(), content + + +def validate_embedded_images(html, max_total_bytes): + total = 0 + for match in DATA_IMAGE_RE.finditer(html or ""): + _, content = _decode(match) + total += len(content) + if total > max_total_bytes: + raise EmbeddedImageError("Die eingefügten Bilder überschreiten die erlaubte Gesamtgröße.") + + +def store_embedded_images(html, max_total_bytes, store): + total = 0 + + def replace(match): + nonlocal total + mime, content = _decode(match) + total += len(content) + if total > max_total_bytes: + raise EmbeddedImageError("Die eingefügten Bilder überschreiten die erlaubte Gesamtgröße.") + url = store(mime, content) + return f'{match.group("prefix")}{url}{match.group("quote")}' + + return DATA_IMAGE_RE.sub(replace, html or "") diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 9a90583..2842e64 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -1,6 +1,7 @@ from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.files.base import ContentFile from django.db.models import Q from django.utils.html import format_html from utilities.forms import get_field_value @@ -11,7 +12,8 @@ from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect from netbox.forms import NetBoxModelForm from dcim.models import Device -from .models import Document, DocumentAssignment, DocumentCategory +from .embedded_images import EmbeddedImageError, store_embedded_images, validate_embedded_images +from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory def help_label(label, description): @@ -59,6 +61,39 @@ class DocumentForm(NetBoxModelForm): self.initial["body"] = markdown(self.instance.body, extensions=("extra", "sane_lists")) self.initial["body_format"] = "html" + def clean_body(self): + body = self.cleaned_data.get("body", "") + limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25) + try: + validate_embedded_images(body, limit * 1024 * 1024) + except EmbeddedImageError as exc: + raise forms.ValidationError(str(exc)) from exc + return body + + def save(self, commit=True): + document = super().save(commit=commit) + if not commit or not document.pk or "data:image/" not in (document.body or "").lower(): + return document + + limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25) + counter = 0 + + def save_image(mime, content): + nonlocal counter + counter += 1 + extension = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp"}[mime] + name = f"editor-bild-{document.pk}-{counter}.{extension}" + attachment = DocumentAttachment( + document=document, original_name=name, content_type=mime, size=len(content), + ) + attachment.file.save(name, ContentFile(content), save=False) + attachment.save() + return attachment.file.url + + document.body = store_embedded_images(document.body, limit * 1024 * 1024, save_image) + document.save(update_fields=("body", "last_updated")) + return document + class AssignmentForm(NetBoxModelForm): assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect()) diff --git a/netbox_documentation/templates/netbox_documentation/document_edit.html b/netbox_documentation/templates/netbox_documentation/document_edit.html index cc9ada6..c6f7e75 100644 --- a/netbox_documentation/templates/netbox_documentation/document_edit.html +++ b/netbox_documentation/templates/netbox_documentation/document_edit.html @@ -175,6 +175,6 @@ {% block pre_form_fields %} {% if not object.pk %} -
Speichere die neue Dokumentation einmal. Danach können Bilder direkt über die Editor-Werkzeugleiste hochgeladen werden.
+
Eingefügte oder hineinkopierte Bilder werden beim ersten Speichern automatisch als Medien der neuen Dokumentation abgelegt. Nach dem ersten Speichern ist zusätzlich der direkte Bildupload verfügbar.
{% endif %} {% endblock pre_form_fields %} diff --git a/pyproject.toml b/pyproject.toml index de56ec9..1b44075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-documentation" -version = "0.7.12" +version = "0.7.13" description = "Integrated Markdown wiki and office document importer for NetBox" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_embedded_images.py b/tests/test_embedded_images.py new file mode 100644 index 0000000..0b0e87f --- /dev/null +++ b/tests/test_embedded_images.py @@ -0,0 +1,48 @@ +import base64 +from io import BytesIO +import importlib.util +from pathlib import Path +import sys + +from PIL import Image + + +spec = importlib.util.spec_from_file_location( + "documentation_embedded_images", + Path(__file__).parents[1] / "netbox_documentation" / "embedded_images.py", +) +embedded = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = embedded +spec.loader.exec_module(embedded) + + +def image_data_url(): + stream = BytesIO() + Image.new("RGB", (2, 2), "red").save(stream, format="PNG") + return "data:image/png;base64," + base64.b64encode(stream.getvalue()).decode() + + +def test_embedded_image_is_replaced_with_stored_url(): + html = f'

Test

' + stored = [] + + def store(mime, content): + stored.append((mime, content)) + return "/media/documentation/bild.png" + + result = embedded.store_embedded_images(html, 1024 * 1024, store) + + assert 'src="/media/documentation/bild.png"' in result + assert "data:image/" not in result + assert stored[0][0] == "image/png" + assert stored[0][1] + + +def test_embedded_images_respect_total_size_limit(): + html = f'' + try: + embedded.validate_embedded_images(html, 1) + except embedded.EmbeddedImageError as exc: + assert "Gesamtgröße" in str(exc) + else: + raise AssertionError("Größenlimit wurde nicht angewendet")