go-wiki/handler/teams_config.go

185 lines
6.1 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 (
"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))
}
}()
}