221 lines
7.2 KiB
Go
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
|
|
}
|