146 lines
4.9 KiB
Go
146 lines
4.9 KiB
Go
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,
|
||
})
|
||
}
|