munin/internal/web/handlers_admin.go
2026-09-15 20:36:26 +02:00

494 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"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
// 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) {
projekte, err := s.store.ListProjects(r.Context(), s.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) {
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 := s.store.CreateProject(r.Context(), s.workspaceID, slug, name, beschreibung)
if err != nil {
s.renderProjectListWithError(w, r, err.Error())
return
}
s.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) {
id := r.FormValue("id")
projekt, err := s.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 := s.store.DeleteProject(r.Context(), id); err != nil {
s.serverError(w, "Projekt konnte nicht gelöscht werden", err)
return
}
s.search.Forget(id)
s.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) {
projekte, err := s.store.ListProjects(r.Context(), s.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 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) {
items := make([]projectListItem, 0, len(projekte))
for _, p := range projekte {
anzahl, err := s.store.CountEntries(r.Context(), p.ID)
if err != nil {
return items, err
}
item := projectListItem{
Project: p,
Entries: anzahl,
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) {
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 := s.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.
s.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) {
projekt, err := s.store.ProjectBySlug(r.Context(), s.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
}
// 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) {
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(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 := s.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
}
s.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) {
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(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 := s.store.APIKeyByID(r.Context(), id)
if err != nil || key.ProjectID != project.ID {
http.NotFound(w, r)
return
}
if err := s.store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) {
s.serverError(w, "Key konnte nicht zurückgezogen werden", err)
return
}
s.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) {
project, _ := ProjectFrom(r)
keys, err := s.store.ListAPIKeys(r.Context(), project.ID)
if err != nil {
s.serverError(w, "Keys konnten nicht geladen werden", err)
return
}
daten.Keys = keys
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) {
ctx := r.Context()
ereignisse, err := s.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 := s.store.ProjectNames(ctx, s.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 := s.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.
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
}
stats, err := s.exporter.ExportAll(r.Context(), s.workspaceID)
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)
}