go-wiki/handler/wiki.go

436 lines
15 KiB
Go

// 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-Verwaltung,
// die jetzt auch die Team-Auswahl übernimmt) 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, "/admin/teams", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Den URL-Pfad in einen Dateipfad umwandeln.
// Aus "/" wird "<team>/content/index.md", aus "/projekte/setup" wird "<team>/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, "/admin/teams", 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, "/", "_")
}