munin/internal/web/handlers_import.go
2026-10-19 10:13:56 +02:00

146 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
// Import von Markdown-Dateien in ein Projekt (AUSBAUSTUFEN.md 3.2).
//
// # Warum über die Weboberfläche und nicht als Kommandozeilenbefehl?
//
// Der Suchindex liegt im Arbeitsspeicher des laufenden Servers (siehe
// internal/search). Ein zweiter Prozess, der direkt in die Datenbank
// schreibt, könnte ihn nicht nachziehen, und die importierten Einträge blieben
// bis zum nächsten Neustart für recall unsichtbar. Hier läuft der Import im
// Server selbst und aktualisiert den Index im selben Zug.
//
// Dazu kommt: So können Projekt-Admins ihre vorhandene Dokumentation selbst
// einspielen, ohne auf den Plattform-Admin zu warten.
//
// # Rechte
//
// Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich
// tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet,
// ändert Einträge, die andere geschrieben haben.
//
// Der Workspace-Admin darf zwar Zugänge verwalten, aber keine Einträge
// schreiben — deshalb prüft die Route Session.CanImport und nicht allein die
// Key-Verwaltung.
import (
"errors"
"io"
"net/http"
"munin/internal/auth"
"munin/internal/importer"
"munin/internal/store"
)
// importData sind die Daten der Import-Seite.
type importData struct {
// Report ist nach einem Lauf gesetzt, sonst nil.
Report *importer.Report
// Preview: Der Bericht zeigt nur, was passieren würde.
Preview bool
// Mode ist die zuletzt gewählte Einstellung, damit das Formular sie nach
// einer Vorschau beibehält.
Mode importer.Mode
MaxFiles int
}
// handleImportForm zeigt das Formular zum Hochladen.
func (s *Server) handleImportForm(w http.ResponseWriter, r *http.Request) {
s.renderImport(w, r, http.StatusOK, importData{Mode: importer.ModeSkip}, "", "")
}
// handleImport nimmt ein ZIP-Archiv entgegen und importiert es, oder zeigt
// bei gesetzter Vorschau nur, was passieren würde.
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
project, _ := ProjectFrom(r)
data := importData{
Mode: importer.Mode(r.FormValue("modus")),
Preview: r.FormValue("vorschau") == "1",
}
if data.Mode != importer.ModeOverwrite {
data.Mode = importer.ModeSkip
}
// r.FormFile liest das hochgeladene Feld "archiv". Die Größe ist schon
// begrenzt (LimitBodies in middleware.go), ReadAll kann also nicht mehr
// als das Import-Limit in den Speicher holen.
file, _, err := r.FormFile("archiv")
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
s.renderImport(w, r, http.StatusRequestEntityTooLarge, data, "",
"Das Archiv ist zu groß. Bitte in mehrere kleinere aufteilen.")
return
}
s.renderImport(w, r, http.StatusBadRequest, data, "", "Bitte ein ZIP-Archiv auswählen.")
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
s.renderImport(w, r, http.StatusBadRequest, data, "", "Das Archiv konnte nicht gelesen werden.")
return
}
files, err := importer.ReadZip(content)
if err != nil {
s.renderImport(w, r, http.StatusBadRequest, data, "", "Import nicht möglich: "+err.Error()+".")
return
}
report, err := importer.Import(r.Context(), ws.Store, project.ID, files, importer.Options{
Mode: data.Mode,
Preview: data.Preview,
Author: sess.ActorName(),
})
// Auch nach einem Abbruch sind die bis dahin geschriebenen Einträge da.
// Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den
// Fehler melden.
for _, e := range report.Written {
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
}
if !data.Preview && len(report.Written) > 0 {
// Ein Protokolleintrag für den ganzen Import statt einer je Datei.
// Sonst verschwände alles andere im Protokoll unter tausend Zeilen.
// Und die Nutzungsübersicht (4.6) soll zählen, was die Teams im
// Alltag schreiben, nicht einen einmaligen Import.
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryImport, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: report.Summary(),
})
}
if err != nil {
s.serverError(w, "Import abgebrochen nach "+report.Summary(), err)
return
}
data.Report = &report
hint := "Import abgeschlossen: " + report.Summary() + "."
if data.Preview {
hint = "Vorschau, noch nichts geschrieben: " + report.Summary() +
". Zum Übernehmen dasselbe Archiv ohne Häkchen bei „Nur Vorschau“ erneut hochladen."
}
s.renderImport(w, r, http.StatusOK, data, hint, "")
}
// renderImport zeigt die Import-Seite mit optionaler Meldung.
func (s *Server) renderImport(w http.ResponseWriter, r *http.Request, status int, data importData, hint, errs string) {
project, _ := ProjectFrom(r)
data.MaxFiles = importer.MaxFiles
s.render(w, r, "import.html", status, pageData{
Title: "Import – " + project.Name,
Section: "import",
Flash: hint,
Error: errs,
Data: data,
})
}