fix: eingefügte Bilder beim ersten Speichern dauerhaft ablegen
This commit is contained in:
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
|||||||
name = "netbox_documentation"
|
name = "netbox_documentation"
|
||||||
verbose_name = "NetBox Dokumentation"
|
verbose_name = "NetBox Dokumentation"
|
||||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||||
version = "0.7.12"
|
version = "0.7.13"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
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 import forms
|
||||||
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.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from utilities.forms import get_field_value
|
from utilities.forms import get_field_value
|
||||||
@@ -11,7 +12,8 @@ from utilities.forms.rendering import FieldSet
|
|||||||
from utilities.forms.widgets import HTMXSelect
|
from utilities.forms.widgets import HTMXSelect
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from dcim.models import Device
|
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):
|
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"] = markdown(self.instance.body, extensions=("extra", "sane_lists"))
|
||||||
self.initial["body_format"] = "html"
|
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):
|
class AssignmentForm(NetBoxModelForm):
|
||||||
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
|
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
|
||||||
|
|||||||
@@ -175,6 +175,6 @@
|
|||||||
|
|
||||||
{% block pre_form_fields %}
|
{% block pre_form_fields %}
|
||||||
{% if not object.pk %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock pre_form_fields %}
|
{% endblock pre_form_fields %}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.7.12"
|
version = "0.7.13"
|
||||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
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