go-wiki/handler/registry.go
2026-07-14 20:46:19 +02:00

221 lines
7.2 KiB
Go

// Dieses File implementiert die TeamRegistry — die zentrale Stelle die weiß
// welche Teams es gibt, wo ihre Markdown-Dateien liegen, und welcher Zugriffskey
// zu welchem Team gehört.
//
// Jedes Team hat seinen eigenen PageCache (siehe cache.go) und damit auch seinen
// eigenen BM25-Suchindex — Teams sind dadurch vollständig voneinander isoliert,
// ohne dass die bestehende PageCache-Logik selbst angepasst werden musste.
package handler
import (
"context"
"crypto/subtle"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
// Team bündelt alle Informationen zu einem einzelnen Team.
type Team struct {
ID string // z.B. "alpha" — auch der Ordnername unter dataRoot
Name string // Anzeigename, z.B. "Team Alpha"
Key string // Zugriffskey aus teams.yaml
DocsRoot string // z.B. "data/alpha/content"
Cache *PageCache // eigener Cache + Suchindex für dieses Team
}
// TeamRegistry hält alle Teams und ermöglicht die Auflösung eines Zugriffskeys
// zu seinem Team (oder zum globalen Admin-Zugang).
//
// teams kann sich zur Laufzeit ändern (siehe Reload, für Hot-Reload von teams.yaml)
// — mu schützt den Zugriff darauf. Ein Team-Objekt selbst wird nach dem Erstellen
// nie verändert (siehe Reload): Requests die ein *Team schon aus der Registry
// geholt haben, arbeiten also immer mit einem konsistenten, unveränderlichen Snapshot
// weiter, auch wenn zwischenzeitlich neu geladen wird.
type TeamRegistry struct {
mu sync.RWMutex
teams map[string]*Team // key: Team-ID
adminKey string
dataRoot string // gebraucht um bei Reload neue Teams anzulegen
}
// NewTeamRegistry erstellt für jedes konfigurierte Team den Datenordner (falls nötig)
// und baut den zugehörigen PageCache auf.
func NewTeamRegistry(dataRoot string, configs []TeamConfig, adminKey string) (*TeamRegistry, error) {
reg := &TeamRegistry{
teams: make(map[string]*Team, len(configs)),
adminKey: adminKey,
dataRoot: dataRoot,
}
for _, cfg := range configs {
docsRoot := filepath.Join(dataRoot, cfg.ID, "content")
if err := os.MkdirAll(docsRoot, 0755); err != nil {
return nil, fmt.Errorf("Ordner für Team %q konnte nicht erstellt werden: %w", cfg.ID, err)
}
cache, err := NewPageCache(docsRoot)
if err != nil {
return nil, fmt.Errorf("Cache für Team %q konnte nicht aufgebaut werden: %w", cfg.ID, err)
}
reg.teams[cfg.ID] = &Team{
ID: cfg.ID,
Name: cfg.Name,
Key: cfg.Key,
DocsRoot: docsRoot,
Cache: cache,
}
}
return reg, nil
}
// Reload ersetzt die Team-Liste durch eine frisch aus teams.yaml gelesene Konfiguration —
// genutzt für Hot-Reload ohne Serverneustart (siehe StartTeamsConfigWatcher).
//
// Bereits bekannte Teams behalten ihren Cache (kein unnötiger Neuaufbau des
// Suchindex bei einer reinen Namens-/Key-Änderung), neue Teams bekommen Ordner
// und Cache frisch angelegt. Aus der Config entfernte Teams verschwinden aus der
// Registry — ihre Dateien auf der Platte bleiben unangetastet.
func (r *TeamRegistry) Reload(configs []TeamConfig) error {
r.mu.Lock()
defer r.mu.Unlock()
newTeams := make(map[string]*Team, len(configs))
for _, cfg := range configs {
docsRoot := filepath.Join(r.dataRoot, cfg.ID, "content")
var cache *PageCache
if existing, ok := r.teams[cfg.ID]; ok {
cache = existing.Cache
} else {
if err := os.MkdirAll(docsRoot, 0755); err != nil {
return fmt.Errorf("Ordner für Team %q konnte nicht erstellt werden: %w", cfg.ID, err)
}
c, err := NewPageCache(docsRoot)
if err != nil {
return fmt.Errorf("Cache für Team %q konnte nicht aufgebaut werden: %w", cfg.ID, err)
}
cache = c
}
// Immer ein neues Team-Objekt anlegen statt ein bestehendes zu verändern —
// so bleiben *Team-Zeiger die laufende Requests bereits halten unverändert
// gültig (siehe Doku-Kommentar am Struct).
newTeams[cfg.ID] = &Team{
ID: cfg.ID,
Name: cfg.Name,
Key: cfg.Key,
DocsRoot: docsRoot,
Cache: cache,
}
}
r.teams = newTeams
return nil
}
// Resolve prüft einen Zugriffskey (aus dem Login-Formular oder dem Authorization-Header)
// und gibt das zugehörige Team zurück. Ist der Key der globale Admin-Token, ist isAdmin
// true und team ist nil — der Aufrufer muss dann selbst ein Team auswählen (z.B. über
// den "team"-Parameter oder die Team-Umschaltung im Web).
//
// Der Vergleich läuft über subtle.ConstantTimeCompare (wie schon in middleware/auth.go
// und webhook.go) um Timing-Angriffe zu erschweren.
func (r *TeamRegistry) Resolve(key string) (team *Team, isAdmin bool, ok bool) {
if key == "" {
return nil, false, false
}
if subtle.ConstantTimeCompare([]byte(key), []byte(r.adminKey)) == 1 {
return nil, true, true
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, t := range r.teams {
if subtle.ConstantTimeCompare([]byte(key), []byte(t.Key)) == 1 {
return t, false, true
}
}
return nil, false, false
}
// ByID gibt ein Team anhand seiner ID zurück — genutzt vom Admin-Team-Switcher
// und vom "team"-Parameter in API/MCP-Requests.
func (r *TeamRegistry) ByID(id string) (*Team, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.teams[id]
return t, ok
}
// All gibt alle Teams alphabetisch nach ID sortiert zurück.
func (r *TeamRegistry) All() []*Team {
r.mu.RLock()
defer r.mu.RUnlock()
all := make([]*Team, 0, len(r.teams))
for _, t := range r.teams {
all = append(all, t)
}
sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID })
return all
}
// RebuildAll baut den Cache jedes Teams neu auf — genutzt vom Webhook nach einem
// "git pull" über das gesamte data-Verzeichnis.
func (r *TeamRegistry) RebuildAll() error {
r.mu.RLock()
defer r.mu.RUnlock()
for id, t := range r.teams {
if err := t.Cache.build(); err != nil {
return fmt.Errorf("Cache für Team %q konnte nicht neu aufgebaut werden: %w", id, err)
}
}
return nil
}
// ── Request-Context ──────────────────────────────────────────────────────────
//
// Nach erfolgreicher Authentifizierung (Web-Session-Cookie oder Bearer-Key)
// legt die jeweilige Middleware das aufgelöste Team im Request-Context ab.
// Handler lesen es über TeamFromContext statt über ein festes Struct-Feld —
// so kann derselbe Handler je nach Request ein anderes Team bedienen.
type contextKey int
const (
teamContextKey contextKey = iota
adminContextKey
)
// WithTeam legt ein aufgelöstes Team (oder den Admin-Status) im Context ab.
// team ist nil wenn isAdmin true ist und noch kein aktives Team gewählt wurde.
func WithTeam(ctx context.Context, team *Team, isAdmin bool) context.Context {
ctx = context.WithValue(ctx, adminContextKey, isAdmin)
if team != nil {
ctx = context.WithValue(ctx, teamContextKey, team)
}
return ctx
}
// TeamFromContext liest das aktive Team aus dem Context.
func TeamFromContext(ctx context.Context) (*Team, bool) {
t, ok := ctx.Value(teamContextKey).(*Team)
return t, ok
}
// IsAdminFromContext gibt zurück ob der Request mit dem globalen Admin-Zugang authentifiziert wurde.
func IsAdminFromContext(ctx context.Context) bool {
isAdmin, _ := ctx.Value(adminContextKey).(bool)
return isAdmin
}