435 lines
15 KiB
Go
435 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-Ü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 "<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, "/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, "/", "_")
|
|
}
|