munin/internal/importer/archive.go
2026-10-19 10:13:56 +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 (
// MaxFiles ist die Höchstzahl an Markdown-Dateien je Import.
MaxFiles = 5000
// MaxFileBytes 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.
MaxFileBytes = 1 << 20 // 1 MB
// MaxTotalBytes begrenzt den ausgepackten Inhalt insgesamt.
MaxTotalBytes = 100 << 20 // 100 MB
)
// File ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet.
type File struct {
Path string
Content []byte
}
// ReadZip 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 ReadZip(data []byte) ([]File, error) {
archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv")
}
var files []File
var total int64
for _, f := range archive.File {
if f.FileInfo().IsDir() || !isEntryFile(f.Name) {
continue
}
if len(files) >= MaxFiles {
return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxFiles)
}
content, err := readLimited(f, MaxFileBytes)
if err != nil {
return nil, fmt.Errorf("%s: %w", f.Name, err)
}
total += int64(len(content))
if total > MaxTotalBytes {
return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxTotalBytes>>20)
}
files = append(files, File{Path: f.Name, Content: content})
}
if len(files) == 0 {
return nil, errors.New("im Archiv liegt keine Markdown-Datei (.md)")
}
return files, nil
}
// isEntryFile entscheidet anhand des Pfads, ob eine Datei ein Eintrag
// werden soll (siehe ReadZip).
func isEntryFile(filePath string) bool {
for _, part := range strings.Split(filePath, "/") {
if strings.HasPrefix(part, ".") || part == "__MACOSX" {
return false
}
}
name := path.Base(filePath)
if strings.HasPrefix(name, "_") {
return false
}
suffix := strings.ToLower(path.Ext(name))
return suffix == ".md" || suffix == ".markdown"
}
// readLimited packt eine Datei aus und bricht ab, sobald sie größer als
// limit wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die
// Datei zu groß.
func readLimited(f *zip.File, limit int64) ([]byte, error) {
r, err := f.Open()
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
defer r.Close()
content, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
if int64(len(content)) > limit {
return nil, fmt.Errorf("größer als %d KB", limit>>10)
}
return content, nil
}