package web import ( "bytes" "html/template" "io/fs" "log" "net/http" "munin/internal/auth" "munin/internal/export" "munin/internal/search" "munin/internal/store" ) // 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 { store *store.Store search *search.Registry signer *auth.Signer cookies auth.CookiePolicy limiter *auth.RateLimiter templates *template.Template workspaceID string // 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 { Store *store.Store Search *search.Registry Signer *auth.Signer Cookies auth.CookiePolicy Limiter *auth.RateLimiter Templates fs.FS WorkspaceID string 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{ store: cfg.Store, search: cfg.Search, signer: cfg.Signer, cookies: cfg.Cookies, limiter: cfg.Limiter, templates: tmpl, workspaceID: cfg.WorkspaceID, 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)) // Plattform-Verwaltung. mux.Handle("GET /admin/projects", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectList))) mux.Handle("POST /admin/projects/create", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectCreate))) mux.Handle("POST /admin/projects/delete", geschuetzt(s.requirePlatformAdminFunc(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", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditForm))) mux.Handle("POST /admin/projects/{slug}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditSave))) mux.Handle("GET /admin/audit", geschuetzt(s.requirePlatformAdminFunc(s.handleGlobalAudit))) mux.Handle("POST /admin/export", geschuetzt(s.requirePlatformAdminFunc(s.handleExport))) // Projektseiten. mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList)) mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew)) mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView)) 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("GET /p/{project}/audit", imProjekt(s.handleProjectAudit)) // 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" oder "projekte". 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 if sess.IsPlatformAdmin { projekte, err := s.store.ListProjects(r.Context(), s.workspaceID) if err != nil { log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err) } else { nav.Projects = projekte } } if project.ID != "" { tags, err := s.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 } // 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 }, } }