- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
recall/get/list/related "stale": true; get und recall geben einen
Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
(Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
Prüfvermerk und Zeitstempel aus dem Export.
Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
14 KiB
Go
340 lines
14 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
|
|
}
|
|
|
|
// 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)))))
|
|
|
|
// Import: dieselbe Rolle wie die Key-Verwaltung, siehe 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.requireKeyManagement(http.HandlerFunc(s.handleImportForm)))))
|
|
mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(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:
|
|
// "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
|
|
},
|
|
}
|
|
}
|