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 }, } }