package web import ( "errors" "fmt" "log" "net/http" "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 Free int // OwnLimit 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. OwnLimit 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) projects, 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, projects) 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", Section: "projekte", Flash: s.hintFrom(r), 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")) description := strings.TrimSpace(r.FormValue("description")) if slug == "" { slug = store.Slugify(name) } project, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, description) if err != nil { s.renderProjectListWithError(w, r, err.Error()) return } ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.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/"+project.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") project, 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") != project.Slug { s.renderProjectEdit(w, r, http.StatusBadRequest, project, "", "Zum Löschen muss der Projekt-Bezeichner \""+project.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: project.Slug, }) http.Redirect(w, r, s.withHint("/admin/projects", "Projekt gelöscht"), http.StatusSeeOther) } // renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung. func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, message string) { ws, sess := s.workspaceAndSession(r) projects, 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, projects) s.render(w, r, "projects.html", http.StatusBadRequest, pageData{ Title: "Projekte", Section: "projekte", Error: message, 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, projects []store.Project) ([]projectListItem, error) { ws, _ := s.workspaceAndSession(r) items := make([]projectListItem, 0, len(projects)) // 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. usage, err := ws.Store.UsageSummaries(r.Context(), "", time.Now()) if err != nil { return items, err } for _, p := range projects { u := usage[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch item := projectListItem{ Project: p, Entries: u.Entries, Usage: u, OwnLimit: p.WriteLimitHourly, } if s.writeBudget != nil { item.Free, 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) { project, ok := s.projectFromSlug(w, r) if !ok { return } s.renderProjectEdit(w, r, http.StatusOK, project, "", "") } // 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) project, ok := s.projectFromSlug(w, r) if !ok { return } name := strings.TrimSpace(r.FormValue("name")) description := strings.TrimSpace(r.FormValue("description")) if name == "" { s.renderProjectEdit(w, r, http.StatusBadRequest, project, "", "Der Name darf nicht leer sein.") return } limit := project.WriteLimitHourly if sess.CanEditWriteBudget() { raw := strings.TrimSpace(r.FormValue("write_limit_hourly")) var err error limit, err = strconv.Atoi(raw) if err != nil || limit < 0 { s.renderProjectEdit(w, r, http.StatusBadRequest, project, "", "Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).") return } } changes := projectChanges(project, name, description, limit) if len(changes) == 0 { s.renderProjectEdit(w, r, http.StatusOK, project, "Nichts geändert.", "") return } if err := ws.Store.UpdateProject(r.Context(), project.ID, store.ProjectUpdate{ Name: name, Description: description, 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: project.ID, Actor: sess.ActorName(), Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: strings.Join(changes, "; "), }) http.Redirect(w, r, s.withHint("/admin/projects", project.Slug+" gespeichert"), http.StatusSeeOther) } // projectChanges beschreibt, was sich gegenüber dem gespeicherten Stand // ändert — fürs Protokoll, und um ein Speichern ohne Änderung zu erkennen. func projectChanges(old store.Project, name, description string, limit int) []string { var changes []string if name != old.Name { changes = append(changes, fmt.Sprintf("Name: %q → %q", old.Name, name)) } if description != old.Description { changes = append(changes, fmt.Sprintf("Beschreibung: %q → %q", old.Description, description)) } if limit != old.WriteLimitHourly { changes = append(changes, fmt.Sprintf("write_limit_hourly: %d → %d", old.WriteLimitHourly, limit)) } return changes } // 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) project, 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 project, true } // renderProjectEdit zeigt die Bearbeitungsseite eines Projekts. func (s *Server) renderProjectEdit(w http.ResponseWriter, r *http.Request, status int, project store.Project, flash, errs string) { items, err := s.projectListItems(r, []store.Project{project}) 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: project.Name, Section: "projekte", Flash: flash, Error: errs, 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_help.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 // ExpiringSoon zählt gültige Keys, die innerhalb von // store.ExpiresSoonWithin ablaufen — für den Hinweis über der Tabelle. ExpiringSoon int // Today ist das frühestmögliche Ablaufdatum im Formular (morgen wäre // strenger, aber "heute" heißt: bis heute Abend). Today string // Now ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro // Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen. Now time.Time // Unused 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. Unused 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")) role := auth.Role(r.FormValue("role")) if !role.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 expiry time.Time if text := strings.TrimSpace(r.FormValue("expires")); text != "" { tag, err := time.ParseInLocation(auditDate, text, time.Local) if err != nil { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Das Ablaufdatum ist kein gültiges Datum.") return } expiry = tag.AddDate(0, 0, 1) } plaintext, err := auth.GenerateKey() if err != nil { s.serverError(w, "Key konnte nicht erzeugt werden", err) return } created, err := ws.Store.CreateAPIKeyWithExpiry(r.Context(), project.ID, name, auth.HashKey(plaintext), string(role), expiry) 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(), created.Hash, sess.WorkspaceID); err != nil { log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", created.ID, err) } ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: created.ID, Actor: sess.ActorName(), Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: keyDetail(created), }) s.renderKeyList(w, r, http.StatusOK, keyListData{NewKey: plaintext, NewKeyName: created.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, data keyListData, hint, errs 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 } data.Keys = keys data.ServerURL = serverURL(r) data.Now = time.Now() data.Today = data.Now.Format(auditDate) for _, k := range keys { if k.Unused(data.Now) { data.Unused++ } if k.ExpiresSoon(data.Now) { data.ExpiringSoon++ } } s.render(w, r, "keys.html", status, pageData{ Title: "Zugänge – " + project.Name, Section: "zugaenge", Flash: hint, Error: errs, Data: data, }) } // ── 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 auditForm Actions []auditAction Keys []string Filtered bool // mindestens ein Filter ist gesetzt } // auditForm sind die Filterwerte der Protokollseite, als Text wie im // Formular — Datumsangaben im Format JJJJ-MM-TT. type auditForm struct { Event string Who string Project string // nur im Protokoll über alle Projekte From string Until string } // auditAction ist ein Vorgang samt verständlichem Namen für die Auswahlliste. type auditAction struct{ Action, Label string } // auditDate ist das Format der Datumsfelder im Filter (input type=date). const auditDate = "2006-01-02" // readAuditFilter ü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 readAuditFilter(r *http.Request, filter *store.AuditFilter, global bool) auditForm { q := r.URL.Query() f := auditForm{ Event: strings.TrimSpace(q.Get("vorgang")), Who: strings.TrimSpace(q.Get("wer")), From: strings.TrimSpace(q.Get("von")), Until: strings.TrimSpace(q.Get("bis")), } for _, a := range store.AuditActions { if a.Action == f.Event { filter.Action = f.Event } } if filter.Action == "" { f.Event = "" } filter.Actor = f.Who if global { f.Project = strings.TrimSpace(q.Get("projekt")) filter.ProjectID = f.Project } // Die Tage gelten in der Zeitzone des Servers — derselben, in der die // Tabelle die Zeitpunkte anzeigt. if t, err := time.ParseInLocation(auditDate, f.From, time.Local); err == nil { filter.Since = t } else { f.From = "" } if t, err := time.ParseInLocation(auditDate, f.Until, time.Local); err == nil { filter.Until = t.AddDate(0, 0, 1) } else { f.Until = "" } 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, title 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. keys, err := ws.Store.AuditActors(ctx, filter.ProjectID) if err != nil { s.serverError(w, "Protokoll konnte nicht geladen werden", err) return } form := readAuditFilter(r, &filter, global) events, 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. projectNames, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID) if err != nil { s.serverError(w, "Projektnamen konnten nicht geladen werden", err) return } projectIDs := make([]string, 0, len(projectNames)) for id := range projectNames { projectIDs = append(projectIDs, id) } keyNames, err := ws.Store.KeyNames(ctx, projectIDs) if err != nil { s.serverError(w, "Key-Namen konnten nicht geladen werden", err) return } s.render(w, r, "audit.html", http.StatusOK, pageData{ Title: title, Section: "protokoll", Data: auditData{ Events: events, ProjectNames: projectNames, KeyNames: keyNames, Global: global, Filter: form, Actions: auditActions(), Keys: keys, Filtered: form != auditForm{}, }, }) } // auditActions übersetzt store.AuditActions in den Typ der Seite. func auditActions() []auditAction { list := make([]auditAction, 0, len(store.AuditActions)) for _, a := range store.AuditActions { list = append(list, auditAction{Action: a.Action, Label: a.Label}) } return list } // ── 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. entry, 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, entry) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return } message := fmt.Sprintf("Export abgeschlossen: %d Dateien geschrieben", stats.Written) if stats.Removed > 0 { message += fmt.Sprintf(", %d verwaiste entfernt", stats.Removed) } http.Redirect(w, r, s.withHint("/admin/projects", message), http.StatusSeeOther) }