diff --git a/README.md b/README.md index 3c8e805..20d8bd9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit - Breite Editoransicht und Fokusmodus innerhalb des NetBox-Layouts - Bilder direkt vom eigenen Gerät in die Dokumentation hochladen - Formatierte Word-Inhalte inklusive Tabellen und unterstützten Zwischenablage-Bildern einfügen +- Mehrere Dokumentationen mit Ordnern, Zuordnungen und Anhängen als ZIP exportieren und wieder importieren - Eine Dokumentation mehreren Objekten zuordnen und umgekehrt - Unbegrenzt viele Objektzuordnungen pro Dokumentation; nur identische Doppelzuordnungen werden verhindert - Unterstützte Standardobjekte: Region, Standort, Location, Rack, Gerät, VM, VM-Cluster und Mandant/Kunde @@ -20,7 +21,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Kompatibilität -Die Version `0.3.4` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden. +Die Version `0.4.0` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden. ## Installation @@ -68,6 +69,7 @@ PLUGINS = [ PLUGINS_CONFIG = { "netbox_documentation": { "max_import_size_mb": 25, + "max_archive_size_mb": 250, "keep_imported_file": True, "allowed_object_types": [ "dcim.region", @@ -127,6 +129,19 @@ Nach Aktivierung stehen die üblichen NetBox-Plugin-Endpunkte bereit: - `/api/plugins/documentation/folders/` - `/api/plugins/documentation/attachments/` +## ZIP-Archiv + +Unter **Dokumentation → ZIP Export & Import** können mehrere Dokumentationen ausgewählt und gemeinsam heruntergeladen werden. Das Archiv enthält: + +- Dokumentinhalt und Metadaten +- vollständige Ordnerpfade +- Zuordnungen zu NetBox-Objekten +- importierte Dateien und Editor-Bilder + +Beim Import kann gewählt werden, ob Dokumentationen mit derselben Kennung aktualisiert oder als neue Kopie angelegt werden. Zuordnungen zu nicht vorhandenen beziehungsweise nicht erlaubten NetBox-Objekten werden übersprungen. Eingebettete Bild-URLs werden auf die neu gespeicherten Anhänge umgeschrieben. + +Archive werden vor dem Import auf sichere Pfade, Dateianzahl, Dateitypen, Kompressionsverhältnis und entpackte Gesamtgröße geprüft. Das Größenlimit wird mit `max_archive_size_mb` in `PLUGINS_CONFIG` festgelegt. + ## Entwicklung und Tests ```bash diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 79ba23c..5c72bc1 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.3.4" + version = "0.4.0" author = "LKE" base_url = "documentation" min_version = "4.0.0" @@ -17,6 +17,7 @@ class DocumentationConfig(PluginConfig): "virtualization.cluster", "tenancy.tenant", ], "max_import_size_mb": 25, + "max_archive_size_mb": 250, "keep_imported_file": True, } diff --git a/netbox_documentation/archive.py b/netbox_documentation/archive.py new file mode 100644 index 0000000..37fbd12 --- /dev/null +++ b/netbox_documentation/archive.py @@ -0,0 +1,197 @@ +import json +from pathlib import Path, PurePosixPath +from tempfile import SpooledTemporaryFile +from zipfile import ZIP_DEFLATED, BadZipFile, ZipFile + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.utils.text import slugify + +from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory + + +ARCHIVE_FORMAT = "netbox-documentation" +ARCHIVE_VERSION = 1 +MAX_ENTRIES = 5000 + + +class ArchiveFailure(ValueError): + pass + + +def _category_path(category): + path, seen = [], set() + while category and category.pk not in seen: + path.append(category.name) + seen.add(category.pk) + category = category.parent + return list(reversed(path)) + + +def export_documents(documents): + stream = SpooledTemporaryFile(max_size=10 * 1024 * 1024, mode="w+b") + manifest = {"format": ARCHIVE_FORMAT, "version": ARCHIVE_VERSION, "documents": []} + with ZipFile(stream, "w", compression=ZIP_DEFLATED, compresslevel=6) as archive: + for document in documents.prefetch_related("assignments__assigned_object_type", "attachments").select_related("category"): + root = f"documents/{document.pk}" + extension = "html" if document.body_format == "html" else "md" + body_path = f"{root}/content.{extension}" + archive.writestr(body_path, document.body.encode("utf-8")) + attachments = [] + for attachment in document.attachments.all(): + safe_name = Path(attachment.original_name).name or f"attachment-{attachment.pk}" + member = f"{root}/attachments/{attachment.pk}-{safe_name}" + try: + with attachment.file.open("rb") as source: + archive.writestr(member, source.read()) + except (FileNotFoundError, OSError): + continue + attachments.append({ + "path": member, "original_name": safe_name, + "content_type": attachment.content_type, "size": attachment.size, + "source_url": attachment.file.url, + }) + assignments = [{ + "app_label": item.assigned_object_type.app_label, + "model": item.assigned_object_type.model, + "object_id": item.assigned_object_id, + "note": item.note, + } for item in document.assignments.all()] + manifest["documents"].append({ + "title": document.title, "slug": document.slug, "summary": document.summary, + "body_format": document.body_format, "is_published": document.is_published, + "category_path": _category_path(document.category), "body_path": body_path, + "assignments": assignments, "attachments": attachments, + }) + archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")) + stream.seek(0) + return stream + + +def _validate_archive(archive): + infos = archive.infolist() + if len(infos) > MAX_ENTRIES: + raise ArchiveFailure(f"Das Archiv enthält mehr als {MAX_ENTRIES} Dateien.") + limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_archive_size_mb", 250) * 1024 * 1024 + if sum(info.file_size for info in infos) > limit: + raise ArchiveFailure("Die entpackte Gesamtgröße überschreitet das konfigurierte Limit.") + for info in infos: + path = PurePosixPath(info.filename) + if path.is_absolute() or ".." in path.parts: + raise ArchiveFailure("Das Archiv enthält einen unsicheren Dateipfad.") + if info.compress_size and info.file_size / info.compress_size > 200: + raise ArchiveFailure("Das Archiv enthält eine verdächtig stark komprimierte Datei.") + + +def _read_json(archive, name): + try: + return json.loads(archive.read(name).decode("utf-8")) + except (KeyError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ArchiveFailure("Das Archiv enthält kein gültiges manifest.json.") from exc + + +def _category_from_path(names): + parent = None + for name in names: + name = str(name).strip()[:100] + if not name: + continue + slug = slugify(name)[:100] or "ordner" + category = DocumentCategory.objects.filter(parent=parent, slug=slug).first() + if not category: + category = DocumentCategory.objects.create(name=name, slug=slug, parent=parent) + parent = category + return parent + + +def _unique_slug(value): + base = (slugify(value) or "dokumentation")[:180] + candidate, number = base, 2 + while Document.objects.filter(slug=candidate).exists(): + candidate = f"{base[:190-len(str(number))]}-{number}" + number += 1 + return candidate + + +@transaction.atomic +def import_archive(upload, update_existing=False): + try: + archive = ZipFile(upload) + except BadZipFile as exc: + raise ArchiveFailure("Die hochgeladene Datei ist kein gültiges ZIP-Archiv.") from exc + with archive: + _validate_archive(archive) + manifest = _read_json(archive, "manifest.json") + if manifest.get("format") != ARCHIVE_FORMAT or manifest.get("version") != ARCHIVE_VERSION: + raise ArchiveFailure("Archivformat oder Version wird nicht unterstützt.") + records = manifest.get("documents") + if not isinstance(records, list): + raise ArchiveFailure("Die Dokumentliste im Archiv ist ungültig.") + allowed = set(settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("allowed_object_types", [])) + created, updated, skipped_assignments = 0, 0, 0 + for record in records: + if not isinstance(record, dict) or not record.get("title") or not record.get("body_path"): + raise ArchiveFailure("Das Archiv enthält einen unvollständigen Dokumenteintrag.") + try: + body = archive.read(record["body_path"]).decode("utf-8") + except (KeyError, UnicodeDecodeError) as exc: + raise ArchiveFailure("Ein Dokumentinhalt fehlt oder ist nicht UTF-8-kodiert.") from exc + source_slug = str(record.get("slug") or record["title"]) + document = Document.objects.filter(slug=source_slug).first() if update_existing else None + category = _category_from_path(record.get("category_path") or []) + if document: + updated += 1 + else: + document = Document(slug=_unique_slug(source_slug)) + created += 1 + document.title = str(record["title"])[:200] + document.summary = str(record.get("summary") or "")[:500] + document.body = body + document.body_format = record.get("body_format") if record.get("body_format") in {"html", "markdown"} else "html" + document.is_published = bool(record.get("is_published", True)) + document.category = category + document.save() + for assignment in record.get("assignments") or []: + label = f"{assignment.get('app_label')}.{assignment.get('model')}" + if label not in allowed: + skipped_assignments += 1 + continue + content_type = ContentType.objects.filter(app_label=assignment.get("app_label"), model=assignment.get("model")).first() + model = content_type.model_class() if content_type else None + if not model or not model.objects.filter(pk=assignment.get("object_id")).exists(): + skipped_assignments += 1 + continue + DocumentAssignment.objects.get_or_create( + document=document, assigned_object_type=content_type, + assigned_object_id=assignment["object_id"], + defaults={"note": str(assignment.get("note") or "")[:200]}, + ) + for item in record.get("attachments") or []: + member = item.get("path") + if not member: + continue + try: + content = archive.read(member) + except KeyError as exc: + raise ArchiveFailure(f"Anhang {member} fehlt im Archiv.") from exc + from django.core.files.base import ContentFile + original_name = Path(str(item.get("original_name") or "attachment")).name[:255] + allowed_extensions = {".docx", ".xlsx", ".xlsm", ".pdf", ".md", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".webp"} + if Path(original_name).suffix.lower() not in allowed_extensions: + raise ArchiveFailure(f"Der Anhang {original_name} verwendet einen nicht erlaubten Dateityp.") + existing = document.attachments.filter(original_name=original_name, size=len(content)).first() + if existing: + if item.get("source_url"): + document.body = document.body.replace(str(item["source_url"]), existing.file.url) + continue + attachment = DocumentAttachment( + document=document, original_name=original_name, + content_type=str(item.get("content_type") or "")[:100], size=len(content), + ) + attachment.file.save(original_name, ContentFile(content), save=False) + attachment.save() + if item.get("source_url"): + document.body = document.body.replace(str(item["source_url"]), attachment.file.url) + document.save(update_fields=("body", "last_updated")) + return {"created": created, "updated": updated, "skipped_assignments": skipped_assignments} diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 7107aa7..2a1819b 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -116,3 +116,35 @@ class ImportForm(forms.Form): if upload and upload.size > limit * 1024 * 1024: self.add_error("file", f"Die Datei ist größer als {limit} MB.") return data + + +class ArchiveExportForm(forms.Form): + documents = forms.ModelMultipleChoiceField( + queryset=Document.objects.none(), widget=forms.CheckboxSelectMultiple, + label="Dokumentationen", + ) + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + queryset = Document.objects.all().order_by("title") + if user is not None: + queryset = queryset.restrict(user, "view") + self.fields["documents"].queryset = queryset + + +class ArchiveImportForm(forms.Form): + archive = forms.FileField(label="ZIP-Archiv") + update_existing = forms.BooleanField( + required=False, initial=False, + label="Bestehende Dokumentationen mit gleicher Kennung aktualisieren", + help_text="Ohne diese Option werden bei Namenskonflikten neue Dokumentationen mit einer fortlaufenden Kennung angelegt.", + ) + + def clean_archive(self): + upload = self.cleaned_data["archive"] + if not upload.name.lower().endswith(".zip"): + raise forms.ValidationError("Bitte ein vom Plugin erzeugtes ZIP-Archiv auswählen.") + limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_archive_size_mb", 250) + if upload.size > limit * 1024 * 1024: + raise forms.ValidationError(f"Das Archiv ist größer als {limit} MB.") + return upload diff --git a/netbox_documentation/navigation.py b/netbox_documentation/navigation.py index 0a1beda..575d071 100644 --- a/netbox_documentation/navigation.py +++ b/netbox_documentation/navigation.py @@ -14,6 +14,7 @@ menu = PluginMenu( PluginMenuItem(link="plugins:netbox_documentation:documentcategory_list", link_text="Ordner & Kategorien", buttons=( PluginMenuButton(link="plugins:netbox_documentation:documentcategory_add", title="Ordner erstellen", icon_class="mdi mdi-folder-plus", color=ButtonColorChoices.GREEN), )), + PluginMenuItem(link="plugins:netbox_documentation:document_archive", link_text="ZIP Export & Import"), )),), icon_class="mdi mdi-book-open-page-variant", ) diff --git a/netbox_documentation/templates/netbox_documentation/document_archive.html b/netbox_documentation/templates/netbox_documentation/document_archive.html new file mode 100644 index 0000000..5ce3393 --- /dev/null +++ b/netbox_documentation/templates/netbox_documentation/document_archive.html @@ -0,0 +1,37 @@ +{% extends 'base/layout.html' %} +{% load form_helpers %} + +{% block title %}Dokumentationsarchiv{% endblock %} + +{% block content %} +
Ausgewählte Dokumentationen gemeinsam mit Ordnern, Objektzuordnungen und Anhängen herunterladen.
+ +Ein zuvor mit diesem Plugin erzeugtes ZIP-Archiv hochladen. Nicht vorhandene NetBox-Zielobjekte werden sicher übersprungen.
+ +