munin/internal/web/server.go

450 lines
19 KiB
Go

package web
import (
"bytes"
"html/template"
"io/fs"
"log"
"net/http"
"time"
"munin/internal/auth"
"munin/internal/export"
"munin/internal/statistik"
"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
// gestartet ist der Zeitpunkt, zu dem der Server gebaut wurde — für die
// Laufzeit auf der Systemseite.
gestartet time.Time
// statistik zählt die Kennzahlen aller Workspaces für die Systemseite
// und merkt sie sich ein paar Minuten (siehe internal/statistik).
statistik *statistik.Sammler
// adminFingerprint steckt in jeder Sitzung des Plattform-Admins und wird
// bei jedem Request verglichen. Ändert sich das ADMIN_TOKEN, passt er
// nicht mehr, und alle Admin-Sitzungen enden (siehe auth.PlatformSubject).
adminFingerprint 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
}
adminTokenHash := auth.HashKey(cfg.AdminToken)
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: adminTokenHash,
gestartet: time.Now(),
statistik: statistik.NewSammler(cfg.Control, cfg.Workspaces),
adminFingerprint: cfg.Signer.Fingerprint(adminTokenHash),
}, 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))))
// Zwei Prüfungen für Container-Plattformen, Load-Balancer und Monitoring.
// Beide geben bewusst nichts über den Zustand der Projekte preis — eine
// Statusseite wäre eine unnötige Auskunftsstelle nach außen.
//
// /live Läuft der Prozess? Antwortet immer, solange er läuft. Daran
// hängt der Neustart des Containers — er soll NICHT neu starten,
// nur weil die Datenbank kurz weg ist; das hülfe nicht.
// /ready Kann er Anfragen bedienen? Prüft die Datenbank. Daran hängt,
// ob ein Load-Balancer Anfragen schickt und ob das Monitoring
// Alarm schlägt.
//
// /healthz ist der frühere Name von /live und bleibt, damit vorhandene
// Healthchecks und Monitore nicht ins Leere laufen.
mux.HandleFunc("GET /live", handleLive)
mux.HandleFunc("GET /healthz", handleLive)
mux.HandleFunc("GET /ready", s.handleReady)
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)))
// Systemseite: Speicher, Verbindungen, Fassung — nur der Plattform-Admin.
mux.Handle("GET /admin/system", geschuetzt(s.requirePlatformAdminFunc(s.handleSystem)))
// Workspace-Admins eines Workspace anlegen und zurückziehen — nur der
// Plattform-Admin, siehe handlers_workspace_admins.go.
mux.Handle("POST /admin/workspaces/{id}/admins/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminCreate)))
mux.Handle("POST /admin/workspaces/{id}/admins/revoke", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminRevoke)))
// Verwaltung eines Workspace. requireWorkspace stellt sicher, dass der
// Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte
// verwaltet — siehe requireWorkspace in middleware.go.
//
// Zwei Stufen: Projekte und das Protokoll aller Projekte darf auch der
// Workspace-Admin (AUSBAUSTUFEN.md 4.8), alles Übrige bleibt beim
// Plattform-Admin. Neue Routen gehören im Zweifel nach imWorkspace.
imWorkspace := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h)))
}
projektVerwaltung := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireWorkspace(s.requireProjectManagement(h)))
}
mux.Handle("GET /admin/projects", projektVerwaltung(s.handleProjectList))
mux.Handle("POST /admin/projects/create", projektVerwaltung(s.handleProjectCreate))
mux.Handle("POST /admin/projects/delete", projektVerwaltung(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", projektVerwaltung(s.handleProjectEditForm))
mux.Handle("POST /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditSave))
mux.Handle("GET /admin/audit", projektVerwaltung(s.handleGlobalAudit))
// Suche über alle Projekte des Workspace — wer alle Projekte verwalten
// darf, darf auch in allen suchen (AUSBAUSTUFEN.md 5.13).
mux.Handle("GET /admin/search", projektVerwaltung(s.handleWorkspaceSearch))
// Überblick des Workspace: was zu tun ist, Aktivität, Projekte
// (AUSBAUSTUFEN.md 5.17). Das Gegenstück zur Systemseite für den
// Workspace-Admin.
mux.Handle("GET /admin/overview", projektVerwaltung(s.handleUeberblick))
mux.Handle("POST /admin/export", imWorkspace(s.handleExport))
// Download als ZIP: unabhängig von EXPORT_DIR, für alle, die Projekte
// verwalten — also auch den Workspace-Admin (siehe handlers_download.go).
mux.Handle("GET /admin/export.zip", projektVerwaltung(s.handleWorkspaceDownload))
// 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}/entries/similar", mitSchreibrecht(s.handleEntrySimilar))
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}/tags", imProjekt(s.handleTagList))
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))
mux.Handle("POST /p/{project}/entries/bulk-delete", mitSchreibrecht(s.handleEntryBulkDelete))
// 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}/export.zip", s.requireSession(s.requireProject(s.requireProjectDownload(http.HandlerFunc(s.handleProjectDownload)))))
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
// Import: Rolle admin mit Schreibrecht, siehe Session.CanImport und
// handlers_import.go. Die größere Body-Grenze für POST setzt LimitBodies
// (middleware.go).
mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireImport(http.HandlerFunc(s.handleImportForm)))))
mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireImport(http.HandlerFunc(s.handleImport)))))
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:
// etwa "eintraege", "graph", "schlagworte", "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 bei Plattform- und Workspace-Admin gefüllt — ein
// Projekt-Key hat ohnehin nur Zugriff auf sein eigenes Projekt.
Projects []store.Project
// Tags sind die häufigsten Schlagworte des aktuellen Projekts (höchstens
// navTagLimit Stück), häufigste zuerst. Ist gerade ein Schlagwort gewählt,
// das nicht dazugehört, steht es zusätzlich am Ende — sonst wäre der
// aktive Filter in der Seitenleiste unsichtbar.
Tags []store.TagCount
// TagsGesamt ist die Zahl aller Schlagworte des Projekts. Ist sie größer
// als len(Tags), zeigt die Seitenleiste einen Link auf die vollständige
// Liste (/p/{project}/tags).
TagsGesamt int
}
// navTagLimit begrenzt die Schlagworte in der Seitenleiste. Ohne Grenze wird
// die Leiste in großen Projekten so lang, dass die übrigen Punkte darin
// untergehen. Alle Schlagworte stehen auf einer eigenen Seite.
const navTagLimit = 10
// 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, data.AktivesTag)
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, aktivesTag string) 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.CanManageProjects() {
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 = kuerzeTags(tags, navTagLimit, aktivesTag)
nav.TagsGesamt = len(tags)
}
}
return nav
}
// kuerzeTags nimmt die ersten limit Schlagworte aus tags (die schon nach
// Häufigkeit sortiert sind). Gehört aktiv nicht dazu, wird es hinten
// angehängt, damit der gewählte Filter sichtbar bleibt.
func kuerzeTags(tags []store.TagCount, limit int, aktiv string) []store.TagCount {
if len(tags) <= limit {
return tags
}
// Eine Kopie statt tags[:limit]: append auf ein Teilstück würde sonst
// das darunterliegende Array von tags überschreiben.
kurz := make([]store.TagCount, limit, limit+1)
copy(kurz, tags[:limit])
if aktiv == "" {
return kurz
}
for _, t := range kurz {
if t.Tag == aktiv {
return kurz
}
}
for _, t := range tags[limit:] {
if t.Tag == aktiv {
return append(kurz, t)
}
}
return kurz
}
// 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")
},
// bisTag zeigt ein Ablaufdatum als letzten gültigen Tag: Ein Key, der
// "bis einschließlich 31.12." gilt, läuft am 1.1. um 0 Uhr ab —
// angezeigt wird der 31.12.
"bisTag": func(ablauf time.Time) string {
return ablauf.Add(-time.Second).Local().Format("02.01.2006")
},
// groesse zeigt eine Zahl von Bytes lesbar an: "812 KB", "1,4 GB".
"groesse": groesse,
// 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
},
}
}