fix: eingefügte Bilder beim ersten Speichern dauerhaft ablegen
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import re
|
||||
|
||||
|
||||
class EmbeddedImageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
DATA_IMAGE_RE = re.compile(
|
||||
r"(?P<prefix>src\s*=\s*(?P<quote>['\"]))"
|
||||
r"data:(?P<mime>image/(?:png|jpeg|gif|webp));base64,(?P<data>[^'\"]+)"
|
||||
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 "")
|
||||
@@ -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())
|
||||
|
||||
@@ -175,6 +175,6 @@
|
||||
|
||||
{% block pre_form_fields %}
|
||||
{% if not object.pk %}
|
||||
<div class="alert alert-info">Speichere die neue Dokumentation einmal. Danach können Bilder direkt über die Editor-Werkzeugleiste hochgeladen werden.</div>
|
||||
<div class="alert alert-info">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.</div>
|
||||
{% endif %}
|
||||
{% endblock pre_form_fields %}
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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'<p><img alt="Test" src="{image_data_url()}"></p>'
|
||||
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'<img src="{image_data_url()}">'
|
||||
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")
|
||||
Reference in New Issue
Block a user