package web import ( "errors" "fmt" "log" "net/http" "net/url" "strconv" "strings" "time" "munin/internal/auth" "munin/internal/store" ) // projectListData sind die Daten der Projektübersicht. type projectListData struct { Projects []projectListItem } // projectListItem ist eine Zeile der Projektübersicht. type projectListItem struct { Project store.Project Entries int // Usage sind die Kennzahlen der Nutzungsübersicht (AUSBAUSTUFEN.md 4.6), // hier als Spalten: So fällt dem Plattform-Admin auf, welches Team Munin // gar nicht benutzt. Usage store.UsageSummary // Limit ist die geltende Obergrenze für schreibende MCP-Aufrufe je Stunde, // Frei der Rest im laufenden Fenster. Limit 0 heißt "unbegrenzt". // // Die geltende Grenze steht hier und nicht im Template, weil erst das // Schreibbudget die Auflösung kennt: eigene Angabe des Projekts, sonst der // Standard aus der Umgebung. Limit int Frei int // EigenesLimit ist die im Projekt hinterlegte Zahl — 0 bedeutet "keine // eigene Angabe". Steht im Formularfeld, damit dort der gesetzte Wert // erscheint und nicht die aufgelöste Grenze. EigenesLimit int } // handleProjectList zeigt alle Projekte des Workspace — für Plattform- und // Workspace-Admin. func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Projekte konnten nicht geladen werden", err) return } items, err := s.projectListItems(r, projekte) if err != nil { s.serverError(w, "Einträge konnten nicht gezählt werden", err) return } s.render(w, r, "projects.html", http.StatusOK, pageData{ Title: "Projekte", Bereich: "projekte", Flash: r.URL.Query().Get("hinweis"), Data: projectListData{Projects: items}, }) } // handleProjectCreate legt ein neues Projekt an. func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) name := strings.TrimSpace(r.FormValue("name")) slug := strings.TrimSpace(r.FormValue("slug")) beschreibung := strings.TrimSpace(r.FormValue("description")) if slug == "" { slug = store.Slugify(name) } projekt, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, beschreibung) if err != nil { s.renderProjectListWithError(w, r, err.Error()) return } ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: projekt.ID, Actor: sess.ActorName(), Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), }) // Direkt zur Key-Verwaltung: Ein Projekt ohne Key ist nutzlos, und der erste // Handgriff nach dem Anlegen ist immer, einen Zugang zu erzeugen. http.Redirect(w, r, "/p/"+projekt.Slug+"/keys", http.StatusSeeOther) } // handleProjectDelete löscht ein Projekt samt Einträgen und Keys. func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) id := r.FormValue("id") projekt, err := ws.Store.ProjectByID(r.Context(), id) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { s.serverError(w, "Projekt konnte nicht geladen werden", err) return } // Sicherheitsabfrage im Formular: Der eingegebene Bezeichner muss stimmen. // Ein Klick auf "Löschen" reicht nicht — dahinter verschwinden alle Einträge // des Projekts unwiederbringlich. if r.FormValue("confirm") != projekt.Slug { s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", "Zum Löschen muss der Projekt-Bezeichner \""+projekt.Slug+"\" zur Bestätigung eingegeben werden.") return } if err := ws.Store.DeleteProject(r.Context(), id); err != nil { s.serverError(w, "Projekt konnte nicht gelöscht werden", err) return } ws.Search.Forget(id) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: id, Actor: sess.ActorName(), Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: projekt.Slug, }) http.Redirect(w, r, "/admin/projects?hinweis=Projekt+gel%C3%B6scht", http.StatusSeeOther) } // renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung. func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, meldung string) { ws, sess := s.workspaceAndSession(r) projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Projekte konnten nicht geladen werden", err) return } // Fehler beim Zählen sind hier gleichgültig: Auf dem Weg hierher ist bereits // etwas schiefgegangen, und die Fehlermeldung ist wichtiger als die Zahlen. items, _ := s.projectListItems(r, projekte) s.render(w, r, "projects.html", http.StatusBadRequest, pageData{ Title: "Projekte", Bereich: "projekte", Error: meldung, Data: projectListData{Projects: items}, }) } // projectListItems reichert die Projekte mit Eintragszahl, Nutzung und // Schreibbudget an. // // Eine eigene Funktion, weil die Übersicht an zwei Stellen gerendert wird — mit // und ohne Fehlermeldung. Ohne sie liefen beide Fassungen früher oder später // auseinander. func (s *Server) projectListItems(r *http.Request, projekte []store.Project) ([]projectListItem, error) { ws, _ := s.workspaceAndSession(r) items := make([]projectListItem, 0, len(projekte)) // Die Kennzahlen aller Projekte in einem Rutsch (zwei Abfragen), statt // je Projekt einzeln nachzufragen — bei 40 Projekten wären das sonst 80. // Die Eintragszahl steckt mit darin. nutzung, err := ws.Store.UsageSummaries(r.Context(), "", time.Now()) if err != nil { return items, err } for _, p := range projekte { u := nutzung[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch item := projectListItem{ Project: p, Entries: u.Entries, Usage: u, EigenesLimit: p.WriteLimitHourly, } if s.writeBudget != nil { item.Frei, item.Limit = s.writeBudget.Remaining(p.ID, p.WriteLimitHourly) } items = append(items, item) } return items, nil } // projectEditData sind die Daten der Bearbeitungsseite eines Projekts. type projectEditData struct { Item projectListItem } // handleProjectEditForm zeigt das Bearbeitungsformular eines Projekts. // // Anders als bei den früheren Formularen auf der Übersichtsseite steht hier ein // EINZELNES Projekt fest — mit seinen tatsächlichen Werten in den Feldern. Ein // Auswahlfeld über allen Projekten plus leerem Eingabefeld sieht bequemer aus, // hat aber eine Falle: Wer speichert, ohne ein Feld anzufassen, überschreibt den // bestehenden Wert mit der Vorgabe des Formulars. func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) { projekt, ok := s.projectFromSlug(w, r) if !ok { return } s.renderProjectEdit(w, r, http.StatusOK, projekt, "", "") } // handleProjectEditSave speichert Name, Beschreibung und Obergrenze. // // Die Obergrenze (Schreibbudget) darf nur der Plattform-Admin ändern. Beim // Workspace-Admin wird das Feld nicht nur im Formular ausgeblendet, sondern // hier ignoriert — ein von Hand nachgebautes Formular ändert sie also nicht. func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) projekt, ok := s.projectFromSlug(w, r) if !ok { return } name := strings.TrimSpace(r.FormValue("name")) beschreibung := strings.TrimSpace(r.FormValue("description")) if name == "" { s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", "Der Name darf nicht leer sein.") return } limit := projekt.WriteLimitHourly if sess.CanEditWriteBudget() { roh := strings.TrimSpace(r.FormValue("write_limit_hourly")) var err error limit, err = strconv.Atoi(roh) if err != nil || limit < 0 { s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", "Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).") return } } aenderungen := projektAenderungen(projekt, name, beschreibung, limit) if len(aenderungen) == 0 { s.renderProjectEdit(w, r, http.StatusOK, projekt, "Nichts geändert.", "") return } if err := ws.Store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{ Name: name, Description: beschreibung, WriteLimitHourly: limit, }); err != nil { s.serverError(w, "Projekt konnte nicht geändert werden", err) return } // Was sich geändert hat, steht im Detail — sonst sagt das Protokoll nur, // DASS jemand etwas geändert hat. Gerade bei der Obergrenze ist das der // interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine // Betriebsentscheidung. ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: projekt.ID, Actor: sess.ActorName(), Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: strings.Join(aenderungen, "; "), }) http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(projekt.Slug+" gespeichert"), http.StatusSeeOther) } // projektAenderungen beschreibt, was sich gegenüber dem gespeicherten Stand // ändert — fürs Protokoll, und um ein Speichern ohne Änderung zu erkennen. func projektAenderungen(alt store.Project, name, beschreibung string, limit int) []string { var aenderungen []string if name != alt.Name { aenderungen = append(aenderungen, fmt.Sprintf("Name: %q → %q", alt.Name, name)) } if beschreibung != alt.Description { aenderungen = append(aenderungen, fmt.Sprintf("Beschreibung: %q → %q", alt.Description, beschreibung)) } if limit != alt.WriteLimitHourly { aenderungen = append(aenderungen, fmt.Sprintf("write_limit_hourly: %d → %d", alt.WriteLimitHourly, limit)) } return aenderungen } // projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf. // // Eigene Funktion statt der Middleware requireProject: Die liegt unter // /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier sind ohnehin // nur Plattform- und Workspace-Admin unterwegs (siehe Routes), und der // Bezeichner steht unter einem anderen Platzhalter. Gesucht wird nur in der // Datei des eigenen Workspace. func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) { ws, sess := s.workspaceAndSession(r) projekt, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, r.PathValue("slug")) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return store.Project{}, false } if err != nil { s.serverError(w, "Projekt konnte nicht geladen werden", err) return store.Project{}, false } return projekt, true } // renderProjectEdit zeigt die Bearbeitungsseite eines Projekts. func (s *Server) renderProjectEdit(w http.ResponseWriter, r *http.Request, status int, projekt store.Project, flash, fehler string) { items, err := s.projectListItems(r, []store.Project{projekt}) if err != nil { s.serverError(w, "Einträge konnten nicht gezählt werden", err) return } s.render(w, r, "project-edit.html", status, pageData{ Title: projekt.Name, Bereich: "projekte", Flash: flash, Error: fehler, Data: projectEditData{Item: items[0]}, }) } // ── Key-Verwaltung ─────────────────────────────────────────────────────────── // keyListData sind die Daten der Key-Verwaltung. type keyListData struct { Keys []store.APIKey // NewKey wird nur unmittelbar nach dem Anlegen gesetzt. Der Klartext steht // nirgends gespeichert — nur der Hash liegt in der Datenbank —, deshalb wird // er hier genau einmal angezeigt. NewKey string NewKeyName string // ServerURL ist die Adresse dieses Servers für die fertigen // Konfigurationsblöcke, etwa "https://munin.example.org". Dieselbe Funktion // wie in der Hilfe (serverURL in handlers_hilfe.go) — gerade hier zählt es, // weil der Key nur dieses eine Mal zu sehen ist und der Block ohne // Nacharbeit kopierbar sein soll. ServerURL string // BaldAbgelaufen zählt gültige Keys, die innerhalb von // store.ExpiresSoonWithin ablaufen — für den Hinweis über der Tabelle. BaldAbgelaufen int // Heute ist das frühestmögliche Ablaufdatum im Formular (morgen wäre // strenger, aber "heute" heißt: bis heute Abend). Heute string // Jetzt ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro // Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen. Jetzt time.Time // Ungenutzt zählt die aktiven Keys, die seit über 90 Tagen niemand benutzt // hat. Ist die Zahl größer als null, steht über der Tabelle ein Hinweis. Ungenutzt int } // handleKeyList zeigt die Keys eines Projekts. func (s *Server) handleKeyList(w http.ResponseWriter, r *http.Request) { s.renderKeyList(w, r, http.StatusOK, keyListData{}, "", "") } // handleKeyCreate erzeugt einen neuen Key und zeigt ihn genau einmal an. func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) project, _ := ProjectFrom(r) name := strings.TrimSpace(r.FormValue("name")) rolle := auth.Role(r.FormValue("role")) if !rolle.Valid() { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Unbekannte Rolle.") return } // Ablaufdatum (optional, AUSBAUSTUFEN.md 4.7 Stufe 2): Der Key gilt bis // einschließlich des gewählten Tages, also bis zum Beginn des nächsten. var ablauf time.Time if text := strings.TrimSpace(r.FormValue("expires")); text != "" { tag, err := time.ParseInLocation(auditDatum, text, time.Local) if err != nil { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Das Ablaufdatum ist kein gültiges Datum.") return } ablauf = tag.AddDate(0, 0, 1) } klartext, err := auth.GenerateKey() if err != nil { s.serverError(w, "Key konnte nicht erzeugt werden", err) return } erzeugt, err := ws.Store.CreateAPIKeyWithExpiry(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle), ablauf) if err != nil { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error()) return } // Der Routing-Index muss im selben Zug nachgezogen werden — sonst lässt sich // der Key künftig nicht mehr seinem Workspace zuordnen (siehe // AUSBAUSTUFEN.md 4.4). Schlägt das fehl, bleibt der Key in dieser // Workspace-Datei trotzdem gültig; nur das schnelle Routing über mehrere // Workspaces hinweg wäre für ihn kaputt. Deshalb kein Abbruch, nur ein // Log-Eintrag zum Nachziehen. if err := s.control.IndexKey(r.Context(), erzeugt.Hash, sess.WorkspaceID); err != nil { log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", erzeugt.ID, err) } ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(), Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: keyDetail(erzeugt), }) s.renderKeyList(w, r, http.StatusOK, keyListData{NewKey: klartext, NewKeyName: erzeugt.Name}, "Key angelegt. Er wird nur jetzt angezeigt — bitte gleich übernehmen.", "") } // keyDetail beschreibt einen neuen Key fürs Protokoll: Name, Rolle und — // falls gesetzt — der letzte gültige Tag. func keyDetail(k store.APIKey) string { text := k.Name + " (" + k.Role if !k.ExpiresAt.IsZero() { text += ", gültig bis " + k.ExpiresAt.Add(-time.Second).Local().Format("02.01.2006") } return text + ")" } // handleKeyRevoke zieht einen Key zurück. func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) project, _ := ProjectFrom(r) id := r.FormValue("id") // Nachsehen, ob der Key wirklich zu diesem Projekt gehört. Ohne diese // Prüfung könnte ein Projekt-Admin über eine untergeschobene ID den Key eines // fremden Projekts sperren. key, err := ws.Store.APIKeyByID(r.Context(), id) if err != nil || key.ProjectID != project.ID { http.NotFound(w, r) return } if err := ws.Store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) { s.serverError(w, "Key konnte nicht zurückgezogen werden", err) return } if err := s.control.RemoveKeyIndex(r.Context(), key.Hash); err != nil { log.Printf("Key %s konnte nicht aus dem Routing-Index entfernt werden: %v", id, err) } ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: id, Actor: sess.ActorName(), Action: store.ActionKeyRevoked, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: key.Name, }) s.renderKeyList(w, r, http.StatusOK, keyListData{}, "Key zurückgezogen. Er gilt ab sofort nicht mehr.", "") } // renderKeyList zeigt die Key-Verwaltung mit optionaler Meldung. func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, daten keyListData, hinweis, fehler string) { ws, _ := s.workspaceAndSession(r) project, _ := ProjectFrom(r) keys, err := ws.Store.ListAPIKeys(r.Context(), project.ID) if err != nil { s.serverError(w, "Keys konnten nicht geladen werden", err) return } daten.Keys = keys daten.ServerURL = serverURL(r) daten.Jetzt = time.Now() daten.Heute = daten.Jetzt.Format(auditDatum) for _, k := range keys { if k.Unused(daten.Jetzt) { daten.Ungenutzt++ } if k.ExpiresSoon(daten.Jetzt) { daten.BaldAbgelaufen++ } } s.render(w, r, "keys.html", status, pageData{ Title: "Zugänge – " + project.Name, Bereich: "zugaenge", Flash: hinweis, Error: fehler, Data: daten, }) } // ── Audit ──────────────────────────────────────────────────────────────────── // auditData sind die Daten der Protokollansicht. type auditData struct { Events []store.AuditEvent ProjectNames map[string]string KeyNames map[string]string Global bool // Filter (AUSBAUSTUFEN.md 5.11): die gewählten Werte, so wie sie wieder // ins Formular gehören, und die Auswahl für die Listen. Filter auditFormular Aktionen []auditAktion Zugaenge []string Gefiltert bool // mindestens ein Filter ist gesetzt } // auditFormular sind die Filterwerte der Protokollseite, als Text wie im // Formular — Datumsangaben im Format JJJJ-MM-TT. type auditFormular struct { Vorgang string Wer string Projekt string // nur im Protokoll über alle Projekte Von string Bis string } // auditAktion ist ein Vorgang samt verständlichem Namen für die Auswahlliste. type auditAktion struct{ Action, Label string } // auditDatum ist das Format der Datumsfelder im Filter (input type=date). const auditDatum = "2006-01-02" // leseAuditFilter übernimmt die Filter aus der Adresse in filter. // // Unbekannte oder kaputte Werte werden ignoriert statt gemeldet: Wer an der // Adresse herumbastelt, bekommt das ungefilterte Protokoll, keine // Fehlerseite. "bis" schließt den ganzen Tag ein — wer „bis 16.10." wählt, // meint den 16. mit. func leseAuditFilter(r *http.Request, filter *store.AuditFilter, global bool) auditFormular { q := r.URL.Query() f := auditFormular{ Vorgang: strings.TrimSpace(q.Get("vorgang")), Wer: strings.TrimSpace(q.Get("wer")), Von: strings.TrimSpace(q.Get("von")), Bis: strings.TrimSpace(q.Get("bis")), } for _, a := range store.AuditActions { if a.Action == f.Vorgang { filter.Action = f.Vorgang } } if filter.Action == "" { f.Vorgang = "" } filter.Actor = f.Wer if global { f.Projekt = strings.TrimSpace(q.Get("projekt")) filter.ProjectID = f.Projekt } // Die Tage gelten in der Zeitzone des Servers — derselben, in der die // Tabelle die Zeitpunkte anzeigt. if t, err := time.ParseInLocation(auditDatum, f.Von, time.Local); err == nil { filter.Since = t } else { f.Von = "" } if t, err := time.ParseInLocation(auditDatum, f.Bis, time.Local); err == nil { filter.Until = t.AddDate(0, 0, 1) } else { f.Bis = "" } return f } // handleProjectAudit zeigt das Protokoll eines Projekts. func (s *Server) handleProjectAudit(w http.ResponseWriter, r *http.Request) { project, _ := ProjectFrom(r) s.renderAudit(w, r, store.AuditFilter{ProjectID: project.ID}, false, "Protokoll – "+project.Name) } // handleGlobalAudit zeigt das Protokoll aller Projekte des Workspace — für // Plattform- und Workspace-Admin. func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) { s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll") } // renderAudit lädt Ereignisse samt Klarnamen und rendert die Tabelle. func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, titel string) { ws, sess := s.workspaceAndSession(r) ctx := r.Context() // Die Zugänge für die Auswahl: im Projektprotokoll die dieses Projekts, // sonst alle — vor dem Übernehmen der Filter, damit die Liste nicht vom // gewählten Projekt abhängt. zugaenge, err := ws.Store.AuditActors(ctx, filter.ProjectID) if err != nil { s.serverError(w, "Protokoll konnte nicht geladen werden", err) return } formular := leseAuditFilter(r, &filter, global) ereignisse, err := ws.Store.ListAudit(ctx, filter) if err != nil { s.serverError(w, "Protokoll konnte nicht geladen werden", err) return } // IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen // Kennungen stehen. projektNamen, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID) if err != nil { s.serverError(w, "Projektnamen konnten nicht geladen werden", err) return } projektIDs := make([]string, 0, len(projektNamen)) for id := range projektNamen { projektIDs = append(projektIDs, id) } keyNamen, err := ws.Store.KeyNames(ctx, projektIDs) if err != nil { s.serverError(w, "Key-Namen konnten nicht geladen werden", err) return } s.render(w, r, "audit.html", http.StatusOK, pageData{ Title: titel, Bereich: "protokoll", Data: auditData{ Events: ereignisse, ProjectNames: projektNamen, KeyNames: keyNamen, Global: global, Filter: formular, Aktionen: auditAktionen(), Zugaenge: zugaenge, Gefiltert: formular != auditFormular{}, }, }) } // auditAktionen übersetzt store.AuditActions in den Typ der Seite. func auditAktionen() []auditAktion { liste := make([]auditAktion, 0, len(store.AuditActions)) for _, a := range store.AuditActions { liste = append(liste, auditAktion{Action: a.Action, Label: a.Label}) } return liste } // ── Export ─────────────────────────────────────────────────────────────────── // handleExport schreibt alle Projekte als Markdown-Dateien auf die Platte. // // Der Export läuft ohnehin täglich (siehe main.go). Der Knopf hier ist für den // Fall gedacht, dass jemand unmittelbar vor einer Sicherung oder nach einer // größeren Änderung einen aktuellen Stand haben möchte. // // Exportiert wird nur der gerade gewählte Workspace — derselbe, dessen // Projekte auf der Seite mit dem Knopf stehen. Der tägliche Lauf in main.go // übernimmt alle. func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { if s.exporter == nil { http.Error(w, "Der Export ist nicht eingerichtet (EXPORT_DIR fehlt).", http.StatusNotFound) return } ws, sess := s.workspaceAndSession(r) // Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung: Er // landet in _workspace.md, und nach einem Umbenennen soll dort der neue // stehen, nicht der beim Anmelden gemerkte. eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Workspace konnte nicht gelesen werden", err) return } stats, err := s.exporter.ExportWorkspace(r.Context(), ws.Store, eintrag) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return } meldung := fmt.Sprintf("Export abgeschlossen: %d Dateien geschrieben", stats.Written) if stats.Removed > 0 { meldung += fmt.Sprintf(", %d verwaiste entfernt", stats.Removed) } http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther) }