munin/internal/web/server.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

335 lines
14 KiB
Go

package web
import (
"bytes"
"html/template"
"io/fs"
"log"
"net/http"
"munin/internal/auth"
"munin/internal/export"
"munin/internal/store"
"munin/internal/workspace"
)
// Server bündelt alles, was die Handler brauchen.
//
// Ein Struct statt globaler Variablen: Damit ist an jeder Methode sichtbar,
// worauf sie zugreift, und im Test lässt sich ein Server mit anderen
// Bestandteilen zusammensetzen.
type Server struct {
workspaces *workspace.Manager
control *store.ControlStore
signer *auth.Signer
cookies auth.CookiePolicy
limiter *auth.RateLimiter
templates *template.Template
// exporter ist nil, wenn EXPORT_DIR nicht gesetzt ist — dann blendet die
// Oberfläche den Knopf aus, statt eine Funktion anzubieten, die nichts tut.
exporter *export.Exporter
// writeBudget deckelt die schreibenden MCP-Aufrufe je Projekt. Die
// Weboberfläche setzt es nicht durch — sie zeigt nur an, wie viel noch frei
// ist, und erlaubt dem Plattform-Admin, die Obergrenze zu ändern.
writeBudget *auth.WriteBudget
// adminTokenHash ist der Hash des ADMIN_TOKEN. Verglichen wird der Hash,
// nicht der Klartext — dann liegt das Geheimnis nicht dauerhaft im Speicher
// herum und der Vergleich läuft über dieselbe Funktion wie bei den API-Keys.
adminTokenHash string
}
// Config sammelt die Angaben zum Bauen eines Servers.
type Config struct {
Workspaces *workspace.Manager
Control *store.ControlStore
Signer *auth.Signer
Cookies auth.CookiePolicy
Limiter *auth.RateLimiter
Templates fs.FS
AdminToken string
Exporter *export.Exporter
WriteBudget *auth.WriteBudget
}
// NewServer lädt die Templates und stellt den Server zusammen.
func NewServer(cfg Config) (*Server, error) {
// FuncMap muss vor dem Parsen registriert sein — die Funktionen werden beim
// Übersetzen der Templates aufgelöst, nicht erst beim Ausführen.
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFS(cfg.Templates, "templates/*.html")
if err != nil {
return nil, err
}
return &Server{
workspaces: cfg.Workspaces,
control: cfg.Control,
signer: cfg.Signer,
cookies: cfg.Cookies,
limiter: cfg.Limiter,
templates: tmpl,
exporter: cfg.Exporter,
writeBudget: cfg.WriteBudget,
adminTokenHash: auth.HashKey(cfg.AdminToken),
}, nil
}
// Routes baut den Router der Weboberfläche.
//
// Die Muster nutzen die seit Go 1.22 eingebaute Methoden- und Platzhalter-Syntax
// ("GET /p/{project}/"): Damit übernimmt der Router die Methodenprüfung und das
// Zerlegen des Pfads, was sonst in jedem Handler von Hand passieren müsste.
func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
// Statische Dateien und Healthcheck brauchen keine Anmeldung.
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Der Healthcheck gibt bewusst nichts über den Zustand der Projekte preis —
// eine Statusseite wäre eine unnötige Auskunftsstelle nach außen.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("ok\n"))
})
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
// geschuetzt verpackt einen Handler mit der Sitzungsprüfung.
geschuetzt := func(h http.HandlerFunc) http.Handler {
return s.requireSession(h)
}
// imProjekt ergänzt die Auflösung des Projekts aus der URL.
imProjekt := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireProject(h))
}
// mitSchreibrecht ergänzt die Rollenprüfung fürs Schreiben.
mitSchreibrecht := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireProject(s.requireWrite(h)))
}
mux.Handle("GET /{$}", geschuetzt(s.handleHome))
// Eingebaute Hilfe — für jede Sitzung, auch ohne gewählten Workspace.
// "/hilfe" zeigt die Startseite, "/hilfe/{seite}" eine bestimmte Seite.
mux.Handle("GET /hilfe", geschuetzt(s.handleHilfe))
mux.Handle("GET /hilfe/{seite}", geschuetzt(s.handleHilfe))
// Workspace-Auswahl des Plattform-Admins — erreichbar auch ohne bereits
// gewählten Workspace, siehe handleWorkspaceList.
mux.Handle("GET /admin/workspaces", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceList)))
mux.Handle("POST /admin/workspaces/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceCreate)))
mux.Handle("POST /admin/workspaces/select", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceSelect)))
mux.Handle("POST /admin/workspaces/delete", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceDelete)))
// Wie bei /admin/projects/{slug}/edit: eigener Pfad statt {id} direkt unter
// /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig.
mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm)))
mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave)))
// Plattform-Verwaltung. requireWorkspace stellt sicher, dass der
// Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte
// verwaltet — siehe requireWorkspace in middleware.go.
imWorkspace := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h)))
}
mux.Handle("GET /admin/projects", imWorkspace(s.handleProjectList))
mux.Handle("POST /admin/projects/create", imWorkspace(s.handleProjectCreate))
mux.Handle("POST /admin/projects/delete", imWorkspace(s.handleProjectDelete))
// Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst
// wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen
// Projekt-Bezeichner.
mux.Handle("GET /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditForm))
mux.Handle("POST /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditSave))
mux.Handle("GET /admin/audit", imWorkspace(s.handleGlobalAudit))
mux.Handle("POST /admin/export", imWorkspace(s.handleExport))
// Projektseiten.
mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList))
mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew))
mux.Handle("POST /p/{project}/preview", mitSchreibrecht(s.handleEntryPreview))
mux.Handle("GET /p/{project}/entries/suggest", mitSchreibrecht(s.handleEntrySuggest))
mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView))
mux.Handle("GET /p/{project}/e/{slug}/versions", imProjekt(s.handleEntryVersions))
mux.Handle("GET /p/{project}/e/{slug}/versions/{version}", imProjekt(s.handleEntryDiff))
mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", mitSchreibrecht(s.handleEntryRestore))
mux.Handle("GET /p/{project}/graph", imProjekt(s.handleEntryGraph))
mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData))
mux.Handle("GET /p/{project}/e/{slug}/edit", mitSchreibrecht(s.handleEntryEdit))
mux.Handle("POST /p/{project}/e/{slug}/save", mitSchreibrecht(s.handleEntrySave))
mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete))
mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave))
mux.Handle("POST /p/{project}/entries/bulk-tags", mitSchreibrecht(s.handleEntryBulkTags))
// Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_papierkorb.go.
mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList))
mux.Handle("POST /p/{project}/trash/restore", mitSchreibrecht(s.handleTrashRestore))
mux.Handle("POST /p/{project}/trash/purge", mitSchreibrecht(s.handleTrashPurge))
mux.Handle("GET /p/{project}/audit", imProjekt(s.handleProjectAudit))
mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage))
// Key-Verwaltung.
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate)))))
mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke)))))
}
// requirePlatformAdminFunc ist die Kurzform von requirePlatformAdmin für einen
// HandlerFunc — spart bei den Routen oben eine Verschachtelungsebene.
func (s *Server) requirePlatformAdminFunc(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.requirePlatformAdmin(h).ServeHTTP(w, r)
}
}
// ── Rendern ──────────────────────────────────────────────────────────────────
// pageData ist der gemeinsame Rahmen jeder Seite. Die einzelnen Handler
// ergänzen ihre eigenen Daten im Feld Data.
type pageData struct {
Title string
Session Session
Project store.Project
CSRFToken string
Flash string // Erfolgsmeldung
Error string // Fehlermeldung
// ExportEnabled steuert, ob die Oberfläche den Export-Knopf zeigt.
ExportEnabled bool
// Bereich markiert den aktiven Punkt in der Seitenleiste:
// "eintraege", "zugaenge", "protokoll", "projekte" oder "hilfe".
Bereich string
// Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit
// nicht jeder Handler daran denken muss.
Nav navData
// AktivesTag markiert das gewählte Schlagwort in der Seitenleiste.
AktivesTag string
Data any
}
// navData sind die Inhalte der Seitenleiste.
type navData struct {
// Projects ist nur beim Plattform-Admin gefüllt — ein Projekt-Key hat
// ohnehin nur Zugriff auf sein eigenes Projekt.
Projects []store.Project
// Tags sind die Schlagworte des aktuellen Projekts, häufigste zuerst.
Tags []store.TagCount
}
// render führt ein Template aus und schickt das Ergebnis an den Browser.
//
// Gerendert wird zuerst in einen Puffer im Arbeitsspeicher. Erst wenn das
// vollständig geklappt hat, geht die Antwort raus. Andernfalls hätte der Browser
// bei einem Template-Fehler bereits HTTP 200 und eine halb gerenderte Seite
// bekommen — und niemand würde den Fehler bemerken.
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, status int, data pageData) {
if sess, ok := SessionFrom(r); ok {
data.Session = sess
data.CSRFToken = s.signer.CSRFToken(sess.Subject)
}
if project, ok := ProjectFrom(r); ok {
data.Project = project
}
data.ExportEnabled = s.exporter != nil
data.Nav = s.buildNav(r, data.Session, data.Project)
var puffer bytes.Buffer
if err := s.templates.ExecuteTemplate(&puffer, name, data); err != nil {
log.Printf("Template %q konnte nicht gerendert werden: %v", name, err)
http.Error(w, "Seite konnte nicht dargestellt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if _, err := w.Write(puffer.Bytes()); err != nil {
// Der Browser hat die Verbindung getrennt — nichts, was wir noch retten könnten.
log.Printf("Antwort konnte nicht gesendet werden: %v", err)
}
}
// buildNav lädt die Inhalte der Seitenleiste.
//
// Das kostet je Seitenaufruf ein bis zwei kleine Abfragen. Die Alternative wäre,
// dass jeder Handler die Navigation selbst mitliefert — dann wird sie irgendwo
// vergessen, und die Seitenleiste ist auf einer Unterseite plötzlich leer.
//
// Fehler brechen die Seite nicht ab: Eine unvollständige Navigation ist
// ärgerlich, aber kein Grund, den eigentlichen Inhalt nicht anzuzeigen.
func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) navData {
var nav navData
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt
// es weder Projekte noch Schlagworte zu zeigen.
ws, ok := WorkspaceFrom(r)
if !ok {
return nav
}
if sess.IsPlatformAdmin {
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
if err != nil {
log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err)
} else {
nav.Projects = projekte
}
}
if project.ID != "" {
tags, err := ws.Store.ListTags(r.Context(), project.ID)
if err != nil {
log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err)
} else {
nav.Tags = tags
}
}
return nav
}
// workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage.
//
// Nur für Handler gedacht, die über requireWorkspace oder requireProject
// erreicht werden — dort ist immer beides gesetzt (siehe middleware.go).
func (s *Server) workspaceAndSession(r *http.Request) (*workspace.Workspace, Session) {
ws, _ := WorkspaceFrom(r)
sess, _ := SessionFrom(r)
return ws, sess
}
// serverError protokolliert einen unerwarteten Fehler und meldet dem Aufrufer
// eine allgemeine Störung.
//
// Die Trennung ist Absicht: Der technische Grund gehört ins Log, wo ihn nur sieht,
// wer ohnehin Zugriff auf den Server hat. Nach außen geht nur, dass etwas
// schiefging — Fehlermeldungen aus der Tiefe verraten oft Pfade, Tabellennamen
// oder Versionsstände.
func (s *Server) serverError(w http.ResponseWriter, kontext string, err error) {
log.Printf("%s: %v", kontext, err)
http.Error(w, "Es ist ein Fehler aufgetreten. Details stehen im Server-Log.", http.StatusInternalServerError)
}
// templateFuncs stellt den Templates ein paar Hilfsfunktionen bereit.
func templateFuncs() template.FuncMap {
return template.FuncMap{
// datum formatiert einen Zeitpunkt für die Anzeige.
"datum": func(t interface{ Format(string) string }) string {
return t.Format("02.01.2006 15:04")
},
// join fügt Schlagworte zu einer Zeile zusammen.
"join": func(teile []string, trenner string) string {
out := ""
for i, t := range teile {
if i > 0 {
out += trenner
}
out += t
}
return out
},
}
}