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

115 lines
3.6 KiB
Go

// 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/<id>/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))
}
}()
}