// 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 }