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, }) }