// 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 ( "crypto/rand" "encoding/hex" "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 } // SaveTeamsConfig schreibt eine Team-Liste als YAML nach path — das Gegenstück // zu LoadTeamsConfig. Genutzt vom Admin-Webinterface (teams_admin.go) um // Änderungen (Team anlegen/umbenennen/löschen, Key neu erzeugen) dauerhaft in // teams.yaml zu speichern. func SaveTeamsConfig(path string, teams []TeamConfig) error { data, err := yaml.Marshal(teamsFile{Teams: teams}) if err != nil { return fmt.Errorf("Team-Konfiguration konnte nicht serialisiert werden: %w", err) } // Gleiche Rechte wie EnsureTeamsConfig weiter unten (0600) — teams.yaml // enthält Klartext-Keys und soll nur vom Server-Prozess lesbar sein. if err := os.WriteFile(path, data, 0600); err != nil { return fmt.Errorf("teams.yaml konnte nicht geschrieben werden: %w", err) } return nil } // EnsureTeamsConfig legt beim allerersten Start automatisch eine teams.yaml mit // einem einzelnen Team an, falls unter path noch keine Datei existiert — so lässt // sich der Server im Ein-Personen-Betrieb sofort starten, ohne vorher von Hand // teams.yaml.example zu kopieren und einen Key einzutragen. // // Existiert path bereits (auch mit ungültigem Inhalt), passiert nichts — das // bleibt ein Fehler von LoadTeamsConfig weiter unten. Sonst würde ein aus Versehen // leer gespeicherter Inhalt stillschweigend durch ein neues Default-Team ersetzt. func EnsureTeamsConfig(path string) error { if _, err := os.Stat(path); err == nil { return nil } else if !os.IsNotExist(err) { return fmt.Errorf("teams.yaml konnte nicht geprüft werden: %w", err) } key, err := randomTeamKey() if err != nil { return fmt.Errorf("Key für Standard-Team konnte nicht erzeugt werden: %w", err) } content := fmt.Sprintf(`# Automatisch beim ersten Start angelegt (siehe handler.EnsureTeamsConfig). # Für mehrere Teams: weitere Einträge unter "teams:" ergänzen — Format siehe # teams.yaml.example. teams: - id: default name: "Mein Wiki" key: %q `, key) if err := os.WriteFile(path, []byte(content), 0600); err != nil { return fmt.Errorf("teams.yaml konnte nicht angelegt werden: %w", err) } log.Printf("teams.yaml nicht gefunden — Standard-Team \"default\" angelegt (Datei: %s)", path) log.Printf("Zugriffs-Key zum Einloggen: %s", key) return nil } // randomTeamKey erzeugt einen zufälligen, hex-kodierten Zugriffskey für ein neu // angelegtes Team. func randomTeamKey() (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), 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)) } }() }