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 — nur für den Plattform-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: "platform-admin", 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, _ := 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: "platform-admin", 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. func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { ws, _ := 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 } roh := strings.TrimSpace(r.FormValue("write_limit_hourly")) 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: "platform-admin", 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 ist ohnehin // schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner // steht unter einem anderen Platzhalter. 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 // 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 } klartext, err := auth.GenerateKey() if err != nil { s.serverError(w, "Key konnte nicht erzeugt werden", err) return } erzeugt, err := ws.Store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle)) 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: erzeugt.Name + " (" + string(rolle) + ")", }) s.renderKeyList(w, r, http.StatusOK, keyListData{NewKey: klartext, NewKeyName: erzeugt.Name}, "Key angelegt. Er wird nur jetzt angezeigt — bitte gleich übernehmen.", "") } // 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() for _, k := range keys { if k.Unused(daten.Jetzt) { daten.Ungenutzt++ } } 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 } // 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 — nur für den Plattform-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() 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, }, }) } // ── 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) }