408 lines
17 KiB
Go
408 lines
17 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
|
|
|
|
// 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,
|
|
|
|
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))))
|
|
|
|
// 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)))
|
|
// 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))
|
|
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}/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))
|
|
// 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")
|
|
},
|
|
// 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
|
|
},
|
|
}
|
|
}
|