munin/internal/importer/archiv.go
Thomas Adamek cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00

122 lines
3.6 KiB
Go

package importer
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"path"
"strings"
)
// Grenzen für ein hochgeladenes Archiv.
//
// Ein ZIP-Archiv kann ausgepackt ein Vielfaches seiner Größe haben. Ein
// „Zip-Bomb“ macht aus wenigen Kilobyte Gigabytes. Deshalb zählt hier, was
// tatsächlich ausgepackt wird, nicht was das Archiv über sich behauptet: Die
// Größenangaben im Inneren eines ZIP-Archivs kann jeder frei hineinschreiben.
const (
// MaxDateien ist die Höchstzahl an Markdown-Dateien je Import.
MaxDateien = 5000
// MaxDateiBytes entspricht der Grenze für ein Formular der Weboberfläche.
// Ein Eintrag, der sich nicht im Browser anlegen ließe, soll auch über
// den Import nicht hineinkommen.
MaxDateiBytes = 1 << 20 // 1 MB
// MaxGesamtBytes begrenzt den ausgepackten Inhalt insgesamt.
MaxGesamtBytes = 100 << 20 // 100 MB
)
// Datei ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet.
type Datei struct {
Pfad string
Inhalt []byte
}
// LeseZip liest alle Markdown-Dateien aus einem ZIP-Archiv.
//
// Übergangen werden:
// - alles, was nicht auf .md oder .markdown endet (Bilder, PDFs, …)
// - Dateien, deren Name mit "_" beginnt: _index.md und _workspace.md sind
// Übersichten aus dem Export, keine Einträge
// - versteckte Dateien und Ordner (".git", ".obsidian") und der Ordner
// "__MACOSX", den der Finder beim Packen dazulegt
//
// Unterordner sind erlaubt. So lässt sich ein ganzer Doku-Ordner auf einmal
// hochladen. Die Einträge landen trotzdem alle nebeneinander im Projekt,
// denn Munin kennt keine Ordner.
func LeseZip(daten []byte) ([]Datei, error) {
archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten)))
if err != nil {
return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv")
}
var dateien []Datei
var gesamt int64
for _, f := range archiv.File {
if f.FileInfo().IsDir() || !istEintragsdatei(f.Name) {
continue
}
if len(dateien) >= MaxDateien {
return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxDateien)
}
inhalt, err := leseBegrenzt(f, MaxDateiBytes)
if err != nil {
return nil, fmt.Errorf("%s: %w", f.Name, err)
}
gesamt += int64(len(inhalt))
if gesamt > MaxGesamtBytes {
return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxGesamtBytes>>20)
}
dateien = append(dateien, Datei{Pfad: f.Name, Inhalt: inhalt})
}
if len(dateien) == 0 {
return nil, errors.New("im Archiv liegt keine Markdown-Datei (.md)")
}
return dateien, nil
}
// istEintragsdatei entscheidet anhand des Pfads, ob eine Datei ein Eintrag
// werden soll (siehe LeseZip).
func istEintragsdatei(pfad string) bool {
for _, teil := range strings.Split(pfad, "/") {
if strings.HasPrefix(teil, ".") || teil == "__MACOSX" {
return false
}
}
name := path.Base(pfad)
if strings.HasPrefix(name, "_") {
return false
}
endung := strings.ToLower(path.Ext(name))
return endung == ".md" || endung == ".markdown"
}
// leseBegrenzt packt eine Datei aus und bricht ab, sobald sie größer als
// grenze wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die
// Datei zu groß.
func leseBegrenzt(f *zip.File, grenze int64) ([]byte, error) {
r, err := f.Open()
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
defer r.Close()
inhalt, err := io.ReadAll(io.LimitReader(r, grenze+1))
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
if int64(len(inhalt)) > grenze {
return nil, fmt.Errorf("größer als %d KB", grenze>>10)
}
return inhalt, nil
}