// Dieses File lädt die Team-Konfiguration aus einer YAML-Datei (z.B. teams.yaml). // // Jedes Team bekommt einen eigenen Zugriffskey und einen eigenen Ordner unter // dem Daten-Wurzelverzeichnis (data//content). Die teams.yaml enthält // Klartext-Keys — genau wie ADMIN_TOKEN/MCP_TOKEN heute in .env — und gehört // deshalb NIE ins Git-Repository (siehe .gitignore). package handler import ( "fmt" "log" "os" "regexp" "time" "gopkg.in/yaml.v3" ) // teamIDPattern erlaubt nur kleine Buchstaben, Ziffern, Bindestrich und Unterstrich. // Das verhindert Path Traversal über die Team-ID (z.B. "../../etc" als ID) // und stellt sicher, dass die ID direkt als Ordnername verwendet werden kann. var teamIDPattern = regexp.MustCompile(`^[a-z0-9_-]+$`) // TeamConfig ist ein einzelner Team-Eintrag aus der YAML-Datei. type TeamConfig struct { ID string `yaml:"id"` Name string `yaml:"name"` Key string `yaml:"key"` } // teamsFile bildet die Struktur der teams.yaml ab (Wurzelelement "teams"). type teamsFile struct { Teams []TeamConfig `yaml:"teams"` } // LoadTeamsConfig liest und validiert die Team-Konfiguration aus dem angegebenen Pfad. func LoadTeamsConfig(path string) ([]TeamConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("Team-Konfiguration konnte nicht gelesen werden (%s): %w", path, err) } var parsed teamsFile if err := yaml.Unmarshal(data, &parsed); err != nil { return nil, fmt.Errorf("Team-Konfiguration ist kein gültiges YAML: %w", err) } if len(parsed.Teams) == 0 { return nil, fmt.Errorf("Team-Konfiguration enthält keine Teams") } seen := make(map[string]bool, len(parsed.Teams)) for _, team := range parsed.Teams { if !teamIDPattern.MatchString(team.ID) { return nil, fmt.Errorf("ungültige Team-ID %q — erlaubt sind nur a-z, 0-9, '-' und '_'", team.ID) } if team.ID == "admin" { return nil, fmt.Errorf("Team-ID \"admin\" ist reserviert und darf nicht vergeben werden") } if team.Key == "" { return nil, fmt.Errorf("Team %q hat keinen Key gesetzt", team.ID) } if seen[team.ID] { return nil, fmt.Errorf("Team-ID %q ist mehrfach vergeben", team.ID) } seen[team.ID] = true } return parsed.Teams, nil } // StartTeamsConfigWatcher prüft alle interval einmal die Änderungszeit der Team- // Konfigurationsdatei und lädt sie bei einer Änderung neu in die Registry — so // wirken neue/geänderte/entfernte Teams ohne Serverneustart. // // Ein einfacher mtime-Poll reicht hier völlig aus (eine einzelne kleine Datei, // Änderungen sind selten) und braucht keine zusätzliche Dependency wie fsnotify. // Bei einem Fehler (Datei kurzzeitig unlesbar während des Speicherns, ungültiges // YAML) wird die Änderung übersprungen und beim nächsten Tick erneut versucht — // der Server läuft mit der zuletzt gültigen Konfiguration weiter. func StartTeamsConfigWatcher(path string, registry *TeamRegistry, interval time.Duration) { var lastModTime time.Time if info, err := os.Stat(path); err == nil { lastModTime = info.ModTime() } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { info, err := os.Stat(path) if err != nil { continue } if !info.ModTime().After(lastModTime) { continue } lastModTime = info.ModTime() configs, err := LoadTeamsConfig(path) if err != nil { log.Printf("teams.yaml geändert, aber ungültig — Änderung wird ignoriert: %v", err) continue } if err := registry.Reload(configs); err != nil { log.Printf("teams.yaml: Reload fehlgeschlagen: %v", err) continue } log.Printf("teams.yaml neu geladen (%d Teams)", len(configs)) } }() }