146 lines
5 KiB
Go
146 lines
5 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 {
|
||
// Bericht ist nach einem Lauf gesetzt, sonst nil.
|
||
Bericht *importer.Bericht
|
||
|
||
// Vorschau: Der Bericht zeigt nur, was passieren würde.
|
||
Vorschau bool
|
||
|
||
// Modus ist die zuletzt gewählte Einstellung, damit das Formular sie nach
|
||
// einer Vorschau beibehält.
|
||
Modus importer.Modus
|
||
|
||
MaxDateien int
|
||
}
|
||
|
||
// handleImportForm zeigt das Formular zum Hochladen.
|
||
func (s *Server) handleImportForm(w http.ResponseWriter, r *http.Request) {
|
||
s.renderImport(w, r, http.StatusOK, importData{Modus: importer.ModusUeberspringen}, "", "")
|
||
}
|
||
|
||
// 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)
|
||
|
||
daten := importData{
|
||
Modus: importer.Modus(r.FormValue("modus")),
|
||
Vorschau: r.FormValue("vorschau") == "1",
|
||
}
|
||
if daten.Modus != importer.ModusUeberschreiben {
|
||
daten.Modus = importer.ModusUeberspringen
|
||
}
|
||
|
||
// 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.
|
||
datei, _, err := r.FormFile("archiv")
|
||
if err != nil {
|
||
var zuGross *http.MaxBytesError
|
||
if errors.As(err, &zuGross) {
|
||
s.renderImport(w, r, http.StatusRequestEntityTooLarge, daten, "",
|
||
"Das Archiv ist zu groß. Bitte in mehrere kleinere aufteilen.")
|
||
return
|
||
}
|
||
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Bitte ein ZIP-Archiv auswählen.")
|
||
return
|
||
}
|
||
defer datei.Close()
|
||
|
||
inhalt, err := io.ReadAll(datei)
|
||
if err != nil {
|
||
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Das Archiv konnte nicht gelesen werden.")
|
||
return
|
||
}
|
||
|
||
dateien, err := importer.LeseZip(inhalt)
|
||
if err != nil {
|
||
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Import nicht möglich: "+err.Error()+".")
|
||
return
|
||
}
|
||
|
||
bericht, err := importer.Importiere(r.Context(), ws.Store, project.ID, dateien, importer.Optionen{
|
||
Modus: daten.Modus,
|
||
Vorschau: daten.Vorschau,
|
||
Autor: 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 bericht.Geschrieben {
|
||
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
|
||
}
|
||
if !daten.Vorschau && len(bericht.Geschrieben) > 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: bericht.Zusammenfassung(),
|
||
})
|
||
}
|
||
if err != nil {
|
||
s.serverError(w, "Import abgebrochen nach "+bericht.Zusammenfassung(), err)
|
||
return
|
||
}
|
||
|
||
daten.Bericht = &bericht
|
||
hinweis := "Import abgeschlossen: " + bericht.Zusammenfassung() + "."
|
||
if daten.Vorschau {
|
||
hinweis = "Vorschau, noch nichts geschrieben: " + bericht.Zusammenfassung() +
|
||
". Zum Übernehmen dasselbe Archiv ohne Häkchen bei „Nur Vorschau“ erneut hochladen."
|
||
}
|
||
s.renderImport(w, r, http.StatusOK, daten, hinweis, "")
|
||
}
|
||
|
||
// renderImport zeigt die Import-Seite mit optionaler Meldung.
|
||
func (s *Server) renderImport(w http.ResponseWriter, r *http.Request, status int, daten importData, hinweis, fehler string) {
|
||
project, _ := ProjectFrom(r)
|
||
daten.MaxDateien = importer.MaxDateien
|
||
|
||
s.render(w, r, "import.html", status, pageData{
|
||
Title: "Import – " + project.Name,
|
||
Bereich: "import",
|
||
Flash: hinweis,
|
||
Error: fehler,
|
||
Data: daten,
|
||
})
|
||
}
|