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