go-wiki/handler/teams_admin.go

338 lines
10 KiB
Go

// Dieses File implementiert das Admin-Webinterface zur Team-Verwaltung unter
// /admin/teams: Teams anlegen, umbenennen, den Zugriffskey neu erzeugen und
// Teams löschen — alles direkt aus dem Browser statt von Hand in teams.yaml.
//
// Nur mit dem globalen Admin-Zugang erreichbar (siehe IsAdminFromContext).
// Jede Änderung schreibt sofort teams.yaml neu (SaveTeamsConfig) und lädt die
// laufende TeamRegistry per Reload() neu — ohne Serverneustart, siehe
// registry.go. Der bestehende mtime-Watcher (StartTeamsConfigWatcher) würde
// dieselbe Änderung sonst erst mit bis zu 5 Sekunden Verzögerung übernehmen.
package handler
import (
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"sync"
)
// teamsAdminTemplateData bündelt die Daten für das teams-admin.html-Template.
type teamsAdminTemplateData struct {
Teams []*Team
Error string
Notice string
// CurrentTeam ist gesetzt, wenn der Admin gerade in ein Team umgeschaltet hat
// (Cookie "admin:<id>", siehe HandleSwitchTeam) — das Template zeigt dann oben
// einen "Zurück zu <Team>"-Link zu "/" an, damit der Admin nicht erst wieder
// über die Tabelle suchen muss.
CurrentTeam *Team
// NewKey wird nur direkt nach dem Anlegen eines Teams oder dem Neu-Erzeugen
// eines Keys gesetzt. Der Klartext-Key steht sonst nirgends im Web — nur
// in teams.yaml auf dem Server — deshalb zeigen wir ihn hier genau einmal an.
NewKey string
NewKeyTeamID string
}
// TeamsAdminHandler verwaltet das Admin-Webinterface für Teams.
type TeamsAdminHandler struct {
configPath string
registry *TeamRegistry
audit *AuditLogger
template *template.Template
// mu verhindert, dass zwei gleichzeitige Änderungen (zwei Browser-Tabs,
// zwei Admins) sich beim Lesen+Schreiben von teams.yaml gegenseitig
// überschreiben ("Lost Update"). Änderungen an teams.yaml sind selten,
// ein einfacher Mutex reicht völlig — genau wie beim mtime-Watcher.
mu sync.Mutex
}
// NewTeamsAdminHandler lädt das Template und erstellt einen neuen TeamsAdminHandler.
func NewTeamsAdminHandler(templatesFS fs.FS, configPath string, registry *TeamRegistry, audit *AuditLogger) (*TeamsAdminHandler, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/teams-admin.html")
if err != nil {
return nil, err
}
return &TeamsAdminHandler{
configPath: configPath,
registry: registry,
audit: audit,
template: tmpl,
}, nil
}
// requireAdmin schreibt bei fehlendem Admin-Zugang eine 403-Antwort und gibt
// false zurück — der Aufrufer muss dann sofort zurückkehren.
func (h *TeamsAdminHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if !IsAdminFromContext(r.Context()) {
http.Error(w, "Nur für den Admin-Zugang verfügbar", http.StatusForbidden)
return false
}
return true
}
// render zeigt die Team-Übersicht an. Die Team-Liste wird immer frisch aus der
// Registry gelesen, damit sie auch nach einer soeben gespeicherten Änderung
// aktuell ist. CurrentTeam kommt aus dem Request-Context — falls der Admin
// bereits per /switch-team in ein Team gewechselt ist.
func (h *TeamsAdminHandler) render(w http.ResponseWriter, r *http.Request, data teamsAdminTemplateData) {
data.Teams = h.registry.All()
if team, ok := TeamFromContext(r.Context()); ok {
data.CurrentTeam = team
}
h.template.Execute(w, data)
}
// HandleTeamsPage verarbeitet GET /admin/teams — zeigt die Team-Übersicht und
// das Formular zum Anlegen eines neuen Teams. Admins landen hier direkt nach
// dem Login (siehe HandleLogin) statt erst ein Team wählen zu müssen.
func (h *TeamsAdminHandler) HandleTeamsPage(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
h.render(w, r, teamsAdminTemplateData{})
}
// HandleCreateTeam verarbeitet POST /admin/teams/create.
func (h *TeamsAdminHandler) HandleCreateTeam(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
name = id
}
h.mu.Lock()
defer h.mu.Unlock()
configs, err := LoadTeamsConfig(h.configPath)
if err != nil {
h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)})
return
}
// Dieselben Regeln wie beim Laden von teams.yaml (siehe LoadTeamsConfig) —
// eine ungültige ID darf gar nicht erst gespeichert werden.
if !teamIDPattern.MatchString(id) {
h.render(w, r, teamsAdminTemplateData{Error: "Ungültige Team-ID — erlaubt sind nur a-z, 0-9, '-' und '_'."})
return
}
if id == "admin" {
h.render(w, r, teamsAdminTemplateData{Error: `Team-ID "admin" ist reserviert und darf nicht vergeben werden.`})
return
}
for _, cfg := range configs {
if cfg.ID == id {
h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("Team-ID %q gibt es bereits.", id)})
return
}
}
key, err := randomTeamKey()
if err != nil {
h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("Zugriffskey konnte nicht erzeugt werden: %v", err)})
return
}
configs = append(configs, TeamConfig{ID: id, Name: name, Key: key})
if err := h.saveAndReload(configs); err != nil {
h.render(w, r, teamsAdminTemplateData{Error: err.Error()})
return
}
h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_created", Source: "web"})
h.render(w, r, teamsAdminTemplateData{
Notice: fmt.Sprintf("Team %q angelegt.", id),
NewKey: key,
NewKeyTeamID: id,
})
}
// HandleRenameTeam verarbeitet POST /admin/teams/rename — ändert nur den
// Anzeigenamen, ID und Zugriffskey bleiben unverändert.
func (h *TeamsAdminHandler) HandleRenameTeam(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
id := r.FormValue("id")
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
h.render(w, r, teamsAdminTemplateData{Error: "Name darf nicht leer sein."})
return
}
h.mu.Lock()
defer h.mu.Unlock()
configs, err := LoadTeamsConfig(h.configPath)
if err != nil {
h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)})
return
}
found := false
for i := range configs {
if configs[i].ID == id {
configs[i].Name = name
found = true
break
}
}
if !found {
h.render(w, r, teamsAdminTemplateData{Error: "Unbekanntes Team."})
return
}
if err := h.saveAndReload(configs); err != nil {
h.render(w, r, teamsAdminTemplateData{Error: err.Error()})
return
}
h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_renamed", Source: "web"})
h.render(w, r, teamsAdminTemplateData{Notice: fmt.Sprintf("Team %q umbenannt.", id)})
}
// HandleRegenerateKey verarbeitet POST /admin/teams/regenerate-key — erzeugt
// für ein bestehendes Team einen neuen, zufälligen Zugriffskey. Der alte Key
// wird damit sofort ungültig (bestehende Logins/API-Clients mit dem alten Key
// müssen den neuen Key eintragen).
func (h *TeamsAdminHandler) HandleRegenerateKey(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
id := r.FormValue("id")
h.mu.Lock()
defer h.mu.Unlock()
configs, err := LoadTeamsConfig(h.configPath)
if err != nil {
h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)})
return
}
key, err := randomTeamKey()
if err != nil {
h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("Zugriffskey konnte nicht erzeugt werden: %v", err)})
return
}
found := false
for i := range configs {
if configs[i].ID == id {
configs[i].Key = key
found = true
break
}
}
if !found {
h.render(w, r, teamsAdminTemplateData{Error: "Unbekanntes Team."})
return
}
if err := h.saveAndReload(configs); err != nil {
h.render(w, r, teamsAdminTemplateData{Error: err.Error()})
return
}
h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_key_regenerated", Source: "web"})
h.render(w, r, teamsAdminTemplateData{
Notice: fmt.Sprintf("Neuer Zugriffskey für Team %q erzeugt.", id),
NewKey: key,
NewKeyTeamID: id,
})
}
// HandleDeleteTeam verarbeitet POST /admin/teams/delete. Die Markdown-Dateien
// des Teams unter data/<id>/content bleiben auf der Platte erhalten (siehe
// Doku-Kommentar an TeamRegistry.Reload) — nur der Zugriff über Login/API/MCP
// wird entfernt.
func (h *TeamsAdminHandler) HandleDeleteTeam(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
id := r.FormValue("id")
h.mu.Lock()
defer h.mu.Unlock()
configs, err := LoadTeamsConfig(h.configPath)
if err != nil {
h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)})
return
}
// Das letzte verbleibende Team darf nicht gelöscht werden — eine leere
// teams.yaml ist ungültig (siehe LoadTeamsConfig) und würde den Server
// beim nächsten Neustart nicht mehr starten lassen.
if len(configs) <= 1 {
h.render(w, r, teamsAdminTemplateData{Error: "Das letzte verbleibende Team kann nicht gelöscht werden."})
return
}
remaining := make([]TeamConfig, 0, len(configs)-1)
found := false
for _, cfg := range configs {
if cfg.ID == id {
found = true
continue
}
remaining = append(remaining, cfg)
}
if !found {
h.render(w, r, teamsAdminTemplateData{Error: "Unbekanntes Team."})
return
}
if err := h.saveAndReload(remaining); err != nil {
h.render(w, r, teamsAdminTemplateData{Error: err.Error()})
return
}
h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_deleted", Source: "web"})
h.render(w, r, teamsAdminTemplateData{Notice: fmt.Sprintf("Team %q gelöscht.", id)})
}
// saveAndReload schreibt configs nach teams.yaml und lädt die laufende
// TeamRegistry sofort neu.
func (h *TeamsAdminHandler) saveAndReload(configs []TeamConfig) error {
if err := SaveTeamsConfig(h.configPath, configs); err != nil {
return err
}
if err := h.registry.Reload(configs); err != nil {
return fmt.Errorf("Registry konnte nicht neu geladen werden: %w", err)
}
return nil
}