// Package handler enthält alle HTTP-Handler — also die Funktionen die auf // eingehende Browser-Anfragen reagieren. package handler import ( "crypto/subtle" "fmt" "html/template" "io/fs" "net/http" "os" "path/filepath" "strconv" "strings" "time" "wiki/render" ) // navItem repräsentiert einen Eintrag in der Sidebar-Navigation. // Einträge können entweder eine Seite (URL gesetzt) oder ein Ordner (Children gesetzt) sein. type navItem struct { Title string // Anzeigename, z.B. "Startseite" oder "Projekte" URL string // Link zur Seite — leer wenn es ein Ordner ist Children []navItem // Untereinträge — nur bei Ordnern befüllt } // pageTemplateData bündelt alle Daten die das page.html-Template braucht. // Das Template greift mit {{.Title}}, {{.Body}} usw. auf diese Felder zu. // // Bei einer Suchanfrage sind Query und SearchResults gesetzt — das Template // blendet dann die Ergebnisse statt des normalen Body ein. type pageTemplateData struct { Title string Body template.HTML // template.HTML sagt Go: diesen String NICHT escapen — er ist bereits sicheres HTML Nav []navItem Query string // Aktueller Suchbegriff — für die Vorausfüllung des Suchfelds SearchResults []searchResultItem // Suchergebnisse — nil = normale Seitenansicht NoResults bool // true wenn Suche durchgeführt wurde, aber nichts gefunden wurde TeamName string // Name des aktiven Teams — für die Anzeige in der Sidebar IsAdmin bool // true wenn mit dem globalen Admin-Zugang eingeloggt — zeigt den "Team wechseln"-Link } // passwordTemplateData bündelt alle Daten für das password.html-Template. type passwordTemplateData struct { Path string // Der Pfad der geschützten Seite, z.B. "projekte/internes" Error string // Fehlermeldung bei falschem Passwort (leer = kein Fehler) } // searchResultItem ist ein einzelnes Suchergebnis für die Template-Ausgabe. type searchResultItem struct { Title string // Seitentitel URL string // URL der Seite Snippet string // Textstelle um den Treffer herum } // WikiHandler ist eine Struct die alle nötigen Konfigurationen für den Wiki-Handler hält. // docsRoot und Cache gibt es nicht mehr als feste Felder — welches Team bedient wird, // entscheidet sich pro Request über das Team im Request-Context (siehe registry.go), // das die vorgeschaltete Session-Middleware dort ablegt. type WikiHandler struct { pageTemplate *template.Template // Das geladene page.html-Template passwordTemplate *template.Template // Das geladene password.html-Template // rateLimiter begrenzt Versuche gegen das Seiten-Passwort (siehe HandlePasswordSubmit) — // ohne das könnte ein Angreifer beliebig oft raten, genau wie beim Team-Login // (siehe LoginRateLimiter in ratelimit.go, hier wiederverwendet). rateLimiter *LoginRateLimiter } // NewWikiHandler erstellt einen neuen WikiHandler und lädt die Templates. // // templatesFS ist ein fs.FS — ein virtuelles Dateisystem das die Template-Dateien enthält. // In der Praxis ist das ein embed.FS aus main.go, das die Dateien direkt im Binary trägt. // fs.FS ist ein Interface (eine Schnittstelle) — das bedeutet: die Funktion akzeptiert // jeden Typ der Dateien lesen kann, nicht nur embed.FS. Das macht den Code flexibler. func NewWikiHandler(templatesFS fs.FS) (*WikiHandler, error) { // template.ParseFS liest Templates aus einem fs.FS statt vom Dateisystem. // Da das embed.FS den vollen Pfad enthält (z.B. "templates/page.html"), // geben wir genau diesen Pfad an. pageTmpl, err := template.ParseFS(templatesFS, "templates/page.html") if err != nil { return nil, err } passwordTmpl, err := template.ParseFS(templatesFS, "templates/password.html") if err != nil { return nil, err } return &WikiHandler{ pageTemplate: pageTmpl, passwordTemplate: passwordTmpl, rateLimiter: NewLoginRateLimiter(), }, nil } // ServeHTTP ist die Hauptfunktion des WikiHandlers. // Sie wird bei jedem GET-Request auf eine Wiki-Seite aufgerufen. // (w = ResponseWriter = womit wir antworten; r = Request = was der Browser geschickt hat) func (h *WikiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Das Team wurde von middleware.RequireTeamSession bereits aufgelöst und im // Context abgelegt. Kein Team → Admin ohne aktive Auswahl (zur Team-Übersicht) // oder ein Zustand der eigentlich nicht vorkommen sollte (zur Sicherheit: /login). team, ok := TeamFromContext(r.Context()) if !ok { if IsAdminFromContext(r.Context()) { http.Redirect(w, r, "/switch-team", http.StatusSeeOther) return } http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Den URL-Pfad in einen Dateipfad umwandeln. // Aus "/" wird "/content/index.md", aus "/projekte/setup" wird "/content/projekte/setup.md" filePath := h.urlToFilePath(team, r.URL.Path) // Die Markdown-Datei laden und rendern. page, err := render.LoadPage(filePath) if err != nil { // Datei nicht gefunden → 404 http.NotFound(w, r) return } // Wenn die Seite passwortgeschützt ist, müssen wir den Cookie prüfen. if page.Password != "" { if !h.isAuthenticated(r, team, r.URL.Path, page.Password) { // Nicht authentifiziert → Passwort-Formular zeigen h.showPasswordForm(w, r.URL.Path, "") return } } // Navigation aus dem Dateisystem aufbauen. nav := h.buildNav(team) // Template mit den Daten befüllen und an den Browser schicken. data := pageTemplateData{ Title: page.Title, Body: template.HTML(page.Body), // Typ-Umwandlung: string → template.HTML (kein Escaping) Nav: nav, TeamName: team.Name, IsAdmin: IsAdminFromContext(r.Context()), } h.pageTemplate.Execute(w, data) } // HandlePasswordSubmit verarbeitet das abgesendete Passwort-Formular (POST /auth/:path). func (h *WikiHandler) HandlePasswordSubmit(w http.ResponseWriter, r *http.Request) { team, ok := TeamFromContext(r.Context()) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Den Seiten-Pfad aus der URL holen. // URL ist z.B. /auth/projekte/internes → wir wollen "projekte/internes" pagePath := strings.TrimPrefix(r.URL.Path, "/auth") ip := clientIP(r) // Rate-Limiting: ohne das könnte ein Angreifer das Seiten-Passwort beliebig // oft durchprobieren (anders als beim Team-Login gab es hier bisher keine Sperre). if allowed, retryAfter := h.rateLimiter.Allow(ip); !allowed { seconds := int(retryAfter.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(seconds)) http.Error(w, fmt.Sprintf("Zu viele Versuche. Bitte in %d Sekunden erneut versuchen.", seconds), http.StatusTooManyRequests) return } // Das eingetippte Passwort aus dem Formular holen. // r.FormValue liest POST-Formulardaten — "password" ist der name="" im HTML. enteredPassword := r.FormValue("password") // Die zugehörige Markdown-Datei laden um das richtige Passwort zu kennen. filePath := h.urlToFilePath(team, pagePath) page, err := render.LoadPage(filePath) if err != nil { http.NotFound(w, r) return } // Eingetipptes Passwort konstantzeitig mit dem Passwort aus dem Frontmatter // vergleichen (wie schon bei Session-Signatur, Team-Keys und Webhook-Secret — // verhindert dass die Vergleichsdauer Rückschlüsse auf richtige Zeichen erlaubt). if subtle.ConstantTimeCompare([]byte(enteredPassword), []byte(page.Password)) != 1 { h.rateLimiter.RecordFailure(ip) // Falsches Passwort → Formular nochmal zeigen, diesmal mit Fehlermeldung h.showPasswordForm(w, pagePath, "Falsches Passwort.") return } h.rateLimiter.Reset(ip) // Richtiges Passwort → Cookie setzen damit der Browser nicht nochmal fragen muss. // Der Cookie-Wert ist das Passwort selbst (simpel, reicht für unser Usecase). // Der Team-ID-Präfix verhindert dass zwei Teams mit gleichnamigen Pfaden // (z.B. beide haben "/setup") sich den Auth-Cookie teilen. cookieName := "auth_" + team.ID + "_" + pathToCookieName(pagePath) http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: page.Password, Path: "/", Expires: time.Now().Add(30 * 24 * time.Hour), // 30 Tage gültig // HttpOnly verhindert dass JavaScript den Cookie lesen kann — gute Praxis HttpOnly: true, }) // Zur eigentlichen Seite weiterleiten. http.Redirect(w, r, pagePath, http.StatusSeeOther) } // showPasswordForm rendert das Passwort-Formular und schickt es an den Browser. func (h *WikiHandler) showPasswordForm(w http.ResponseWriter, pagePath string, errorMsg string) { // Den führenden "/" entfernen damit das Formular-Action korrekt ist cleanPath := strings.TrimPrefix(pagePath, "/") data := passwordTemplateData{ Path: cleanPath, Error: errorMsg, } h.passwordTemplate.Execute(w, data) } // isAuthenticated prüft ob der Browser einen gültigen Auth-Cookie hat. func (h *WikiHandler) isAuthenticated(r *http.Request, team *Team, pagePath string, correctPassword string) bool { cookieName := "auth_" + team.ID + "_" + pathToCookieName(pagePath) // r.Cookie(name) sucht den Cookie im Request. // Gibt einen Fehler zurück wenn der Cookie nicht existiert. cookie, err := r.Cookie(cookieName) if err != nil { // Kein Cookie vorhanden → nicht eingeloggt return false } // Cookie-Wert mit dem richtigen Passwort vergleichen. return cookie.Value == correctPassword } // urlToFilePath wandelt einen URL-Pfad in einen Dateisystem-Pfad innerhalb des // Team-Ordners um. Beispiele (Team "alpha"): // // / → data/alpha/content/index.md // /setup → data/alpha/content/setup.md // /projekte/internes → data/alpha/content/projekte/internes.md func (h *WikiHandler) urlToFilePath(team *Team, urlPath string) string { // "/" wird zur index.md if urlPath == "/" { return filepath.Join(team.DocsRoot, "index.md") } // Führenden Slash entfernen und .md anhängen cleanPath := strings.TrimPrefix(urlPath, "/") // Falls der Pfad schon auf .md endet, nicht doppelt anhängen. cleanPath = strings.TrimSuffix(cleanPath, ".md") return filepath.Join(team.DocsRoot, cleanPath+".md") } // buildNav startet die Navigation beim docs-Wurzelordner des Teams. func (h *WikiHandler) buildNav(team *Team) []navItem { return buildNavForDir(team.DocsRoot, team.DocsRoot) } // buildNavForDir liest einen Ordner und gibt dessen Navigationselemente zurück. // Dateien werden zu Links, Unterordner werden zu aufklappbaren Gruppen. // Die Funktion ruft sich selbst rekursiv für Unterordner auf. // docsRoot bleibt über die ganze Rekursion hinweg gleich — wird gebraucht um aus // einem vollen Dateipfad die relative URL zu berechnen. func buildNavForDir(dir string, docsRoot string) []navItem { // os.ReadDir liest den Inhalt eines Ordners — gibt Einträge alphabetisch sortiert zurück. entries, err := os.ReadDir(dir) if err != nil { return nil } var items []navItem for _, entry := range entries { fullPath := filepath.Join(dir, entry.Name()) if entry.IsDir() { // Unterordner: rekursiv dessen Inhalt laden children := buildNavForDir(fullPath, docsRoot) // Leere Ordner (keine sichtbaren .md-Dateien) nicht anzeigen if len(children) == 0 { continue } // Ordnername als Titel — ersten Buchstaben großschreiben folderTitle := strings.ToUpper(entry.Name()[:1]) + entry.Name()[1:] items = append(items, navItem{ Title: folderTitle, Children: children, // URL bleibt leer — Ordner sind keine Links }) continue } // Nur .md-Dateien, keine anderen Dateitypen if !strings.HasSuffix(entry.Name(), ".md") { continue } // Seite laden um Titel und Hidden-Flag aus dem Frontmatter zu lesen page, err := render.LoadPage(fullPath) if err != nil { continue } // Seiten mit hidden: true überspringen if page.Hidden { continue } // Dateipfad → URL: data/alpha/content/projekte/setup.md → /projekte/setup rel := strings.TrimPrefix(fullPath, docsRoot) rel = strings.TrimSuffix(rel, ".md") url := rel if url == "/index" { url = "/" } title := page.Title if title == "" { // Kein Titel im Frontmatter → Dateiname ohne .md als Fallback title = strings.TrimSuffix(entry.Name(), ".md") } items = append(items, navItem{Title: title, URL: url}) } return items } // HandleSearch verarbeitet GET /search?q=... und zeigt BM25-Ergebnisse im // normalen Wiki-Layout an. Das Suchfeld in der Sidebar bleibt sichtbar, // der Content-Bereich zeigt die Trefferliste statt einer Seite. func (h *WikiHandler) HandleSearch(w http.ResponseWriter, r *http.Request) { team, ok := TeamFromContext(r.Context()) if !ok { if IsAdminFromContext(r.Context()) { http.Redirect(w, r, "/switch-team", http.StatusSeeOther) return } http.Redirect(w, r, "/login", http.StatusSeeOther) return } query := strings.TrimSpace(r.URL.Query().Get("q")) nav := h.buildNav(team) // Leere Suchanfrage → einfach die Startseite zeigen if query == "" { http.Redirect(w, r, "/", http.StatusSeeOther) return } hits := team.Cache.SearchIndex.Search(query, 20) var results []searchResultItem for _, hit := range hits { page, ok := team.Cache.Get(hit.URLPath) if !ok || page.Hidden { continue } // Snippet aus dem gecacheten Rohtext — kein Dateisystem-Zugriff nötig snippet := extractSearchSnippet(page.RawBody, query) results = append(results, searchResultItem{ Title: page.Title, URL: hit.URLPath, Snippet: snippet, }) } data := pageTemplateData{ Title: "Suche: " + query, Nav: nav, Query: query, SearchResults: results, NoResults: len(results) == 0, TeamName: team.Name, IsAdmin: IsAdminFromContext(r.Context()), } h.pageTemplate.Execute(w, data) } // extractSearchSnippet gibt ~120 Zeichen um den ersten Treffer in content zurück. // Eigene Funktion damit wiki.go keine Abhängigkeit zu mcp.go bekommt. func extractSearchSnippet(content, query string) string { lower := strings.ToLower(content) idx := strings.Index(lower, strings.ToLower(query)) if idx == -1 { // Kein exakter Treffer — Anfang des Texts als Vorschau if len(content) > 160 { return strings.TrimSpace(content[:160]) + "..." } return strings.TrimSpace(content) } const radius = 120 start := idx - radius if start < 0 { start = 0 } end := idx + len(query) + radius if end > len(content) { end = len(content) } snippet := content[start:end] if start > 0 { snippet = "..." + snippet } if end < len(content) { snippet += "..." } return strings.TrimSpace(snippet) } // pathToCookieName wandelt einen URL-Pfad in einen gültigen Cookie-Namen um. // Slashes sind in Cookie-Namen nicht erlaubt, deshalb ersetzen wir sie. // /projekte/internes → projekte_internes func pathToCookieName(path string) string { clean := strings.TrimPrefix(path, "/") return strings.ReplaceAll(clean, "/", "_") }