package web import ( "bytes" "html/template" "io/fs" "log" "net/http" "time" "munin/internal/auth" "munin/internal/export" "munin/internal/stats" "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 // startedAt ist der Zeitpunkt, zu dem der Server gebaut wurde — für die // Laufzeit auf der Systemseite. startedAt time.Time // stats zählt die Kennzahlen aller Workspaces für die Systemseite // und merkt sie sich ein paar Minuten (siehe internal/stats). stats *stats.Collector // 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, startedAt: time.Now(), stats: stats.NewCollector(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) // protected verpackt einen Handler mit der Sitzungsprüfung. protected := func(h http.HandlerFunc) http.Handler { return s.requireSession(h) } // inProject ergänzt die Auflösung des Projekts aus der URL. inProject := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireProject(h)) } // withWriteAccess ergänzt die Rollenprüfung fürs Schreiben. withWriteAccess := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireProject(s.requireWrite(h))) } mux.Handle("GET /{$}", protected(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", protected(s.handleHelp)) mux.Handle("GET /hilfe/{seite}", protected(s.handleHelp)) // Workspace-Auswahl des Plattform-Admins — erreichbar auch ohne bereits // gewählten Workspace, siehe handleWorkspaceList. mux.Handle("GET /admin/workspaces", protected(s.requirePlatformAdminFunc(s.handleWorkspaceList))) mux.Handle("POST /admin/workspaces/create", protected(s.requirePlatformAdminFunc(s.handleWorkspaceCreate))) mux.Handle("POST /admin/workspaces/select", protected(s.requirePlatformAdminFunc(s.handleWorkspaceSelect))) mux.Handle("POST /admin/workspaces/delete", protected(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", protected(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm))) mux.Handle("POST /admin/workspaces/{id}/edit", protected(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave))) // Systemseite: Speicher, Verbindungen, Fassung — nur der Plattform-Admin. mux.Handle("GET /admin/system", protected(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", protected(s.requirePlatformAdminFunc(s.handleWorkspaceAdminCreate))) mux.Handle("POST /admin/workspaces/{id}/admins/revoke", protected(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 inWorkspace. inWorkspace := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h))) } projectManagement := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireWorkspace(s.requireProjectManagement(h))) } mux.Handle("GET /admin/projects", projectManagement(s.handleProjectList)) mux.Handle("POST /admin/projects/create", projectManagement(s.handleProjectCreate)) mux.Handle("POST /admin/projects/delete", projectManagement(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", projectManagement(s.handleProjectEditForm)) mux.Handle("POST /admin/projects/{slug}/edit", projectManagement(s.handleProjectEditSave)) mux.Handle("GET /admin/audit", projectManagement(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", projectManagement(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", projectManagement(s.handleOverview)) mux.Handle("POST /admin/export", inWorkspace(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", projectManagement(s.handleWorkspaceDownload)) // Projektseiten. mux.Handle("GET /p/{project}/{$}", inProject(s.handleEntryList)) mux.Handle("GET /p/{project}/new", withWriteAccess(s.handleEntryNew)) mux.Handle("POST /p/{project}/preview", withWriteAccess(s.handleEntryPreview)) mux.Handle("GET /p/{project}/entries/suggest", withWriteAccess(s.handleEntrySuggest)) mux.Handle("GET /p/{project}/entries/similar", withWriteAccess(s.handleEntrySimilar)) mux.Handle("GET /p/{project}/e/{slug}", inProject(s.handleEntryView)) mux.Handle("GET /p/{project}/e/{slug}/versions", inProject(s.handleEntryVersions)) mux.Handle("GET /p/{project}/e/{slug}/versions/{version}", inProject(s.handleEntryDiff)) mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", withWriteAccess(s.handleEntryRestore)) mux.Handle("GET /p/{project}/graph", inProject(s.handleEntryGraph)) mux.Handle("GET /p/{project}/graph/data", inProject(s.handleEntryGraphData)) mux.Handle("GET /p/{project}/tags", inProject(s.handleTagList)) mux.Handle("GET /p/{project}/e/{slug}/edit", withWriteAccess(s.handleEntryEdit)) mux.Handle("POST /p/{project}/e/{slug}/save", withWriteAccess(s.handleEntrySave)) mux.Handle("POST /p/{project}/e/{slug}/delete", withWriteAccess(s.handleEntryDelete)) mux.Handle("POST /p/{project}/save", withWriteAccess(s.handleEntrySave)) mux.Handle("POST /p/{project}/entries/bulk-tags", withWriteAccess(s.handleEntryBulkTags)) mux.Handle("POST /p/{project}/entries/bulk-delete", withWriteAccess(s.handleEntryBulkDelete)) // Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_trash.go. mux.Handle("GET /p/{project}/trash", withWriteAccess(s.handleTrashList)) mux.Handle("POST /p/{project}/trash/restore", withWriteAccess(s.handleTrashRestore)) mux.Handle("POST /p/{project}/trash/purge", withWriteAccess(s.handleTrashPurge)) mux.Handle("GET /p/{project}/audit", inProject(s.handleProjectAudit)) mux.Handle("GET /p/{project}/usage", inProject(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 // Section markiert den aktiven Punkt in der Seitenleiste: // etwa "eintraege", "graph", "schlagworte", "zugaenge", "protokoll", // "projekte" oder "hilfe". Section string // Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit // nicht jeder Handler daran denken muss. Nav navData // ActiveTag markiert das gewählte Schlagwort in der Seitenleiste. ActiveTag 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 // TagsTotal 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). TagsTotal 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.ActiveTag) var buf bytes.Buffer if err := s.templates.ExecuteTemplate(&buf, 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(buf.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, activeTag 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() { projects, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err) } else { nav.Projects = projects } } 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 = truncateTags(tags, navTagLimit, activeTag) nav.TagsTotal = len(tags) } } return nav } // truncateTags nimmt die ersten limit Schlagworte aus tags (die schon nach // Häufigkeit sortiert sind). Gehört active nicht dazu, wird es hinten // angehängt, damit der gewählte Filter sichtbar bleibt. func truncateTags(tags []store.TagCount, limit int, active 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. short := make([]store.TagCount, limit, limit+1) copy(short, tags[:limit]) if active == "" { return short } for _, t := range short { if t.Tag == active { return short } } for _, t := range tags[limit:] { if t.Tag == active { return append(short, t) } } return short } // 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, context string, err error) { log.Printf("%s: %v", context, 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{ // datetime formatiert einen Zeitpunkt für die Anzeige. "datetime": func(t interface{ Format(string) string }) string { return t.Format("02.01.2006 15:04") }, // lastDay 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. "lastDay": func(expiry time.Time) string { return expiry.Add(-time.Second).Local().Format("02.01.2006") }, // byteSize zeigt eine Zahl von Bytes lesbar an: "812 KB", "1,4 GB". "byteSize": byteSize, // join fügt Schlagworte zu einer Zeile zusammen. "join": func(parts []string, sep string) string { out := "" for i, t := range parts { if i > 0 { out += sep } out += t } return out }, } }