munin/internal/web/handlers_workspaces.go

305 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"errors"
"log"
"net/http"
"net/url"
"strings"
"time"
"munin/internal/store"
)
// workspaceListData sind die Daten der Workspace-Auswahl.
type workspaceListData struct {
Workspaces []workspaceItem
// Current ist die ID des gerade gewählten Workspace, falls einer gewählt
// ist — für die Hervorhebung in der Liste.
Current string
}
type workspaceItem struct {
ID string
Name string
CreatedAt string
Projects int
// Ort ist das PostgreSQL-Schema bzw. die SQLite-Datei des Workspace —
// damit der Plattform-Admin ihn in der Datenbank wiederfindet.
Ort string
// Admins zählt die aktiven Workspace-Admins, AdminsUngenutzt die davon,
// die seit über 90 Tagen niemand benutzt hat (store.WorkspaceAdmin.Unused).
Admins int
AdminsUngenutzt int
}
// handleWorkspaceList zeigt alle Workspaces zur Auswahl.
//
// Erreichbar auch ohne gewählten Workspace — genau dafür ist die Seite da:
// Eine frische Plattform-Admin-Sitzung landet hier, bevor sie überhaupt eine
// Projektseite aufrufen kann (siehe requireProject in middleware.go).
func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) {
s.renderWorkspaceList(w, r, http.StatusOK, "", "")
}
// renderWorkspaceList lädt alle Workspaces und rendert die Auswahlseite.
func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, status int, hinweis, fehler string) {
sess, _ := SessionFrom(r)
workspaces, err := s.control.ListWorkspaces(r.Context())
if err != nil {
s.serverError(w, "Workspaces konnten nicht geladen werden", err)
return
}
// Die aktiven Workspace-Admins aller Workspaces in einer Abfrage, danach
// je Workspace gezählt.
admins, err := s.control.ListActiveWorkspaceAdmins(r.Context())
if err != nil {
s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err)
return
}
jetzt := time.Now()
aktiv := make(map[string]int)
ungenutzt := make(map[string]int)
for _, a := range admins {
aktiv[a.WorkspaceID]++
if a.Unused(jetzt) {
ungenutzt[a.WorkspaceID]++
}
}
// Neueste zuerst — genau umgekehrt zu ListWorkspaces, das für
// EnsureDefaultWorkspace die älteste zuerst braucht.
items := make([]workspaceItem, len(workspaces))
for i, ws := range workspaces {
items[len(workspaces)-1-i] = workspaceItem{
ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"),
Ort: s.workspaces.Ort(ws.ID),
Projects: s.projectCount(r, ws.ID),
Admins: aktiv[ws.ID],
AdminsUngenutzt: ungenutzt[ws.ID],
}
}
s.render(w, r, "workspaces.html", status, pageData{
Title: "Workspaces",
Bereich: "workspaces",
Flash: hinweis,
Error: fehler,
Data: workspaceListData{Workspaces: items, Current: sess.WorkspaceID},
})
}
// handleWorkspaceCreate legt einen neuen Workspace an und wählt ihn gleich —
// wer eine Abteilung anlegt, will unmittelbar darin ihr erstes Projekt anlegen,
// nicht erst zurück zur Auswahl.
func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
s.renderWorkspaceList(w, r, http.StatusBadRequest, "", "Der Name darf nicht leer sein.")
return
}
ws, err := s.control.CreateWorkspace(r.Context(), name)
if err != nil {
s.serverError(w, "Workspace konnte nicht angelegt werden", err)
return
}
sess, _ := SessionFrom(r)
log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName())
s.setSessionCookie(w, r, s.plattformSubjekt(ws.ID))
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
}
// ── Bearbeiten und Löschen ───────────────────────────────────────────────────
// workspaceEditData sind die Daten der Bearbeitungsseite eines Workspace.
type workspaceEditData struct {
Workspace workspaceItem
Projects int
// Admins sind die Workspace-Admin-Zugänge, auch zurückgezogene.
Admins []store.WorkspaceAdmin
// Jetzt ist der Bezugszeitpunkt für WorkspaceAdmin.Unused im Template,
// Ungenutzt die Zahl der aktiven Zugänge, die seit über 90 Tagen niemand
// benutzt hat — wie in keyListData.
Jetzt time.Time
Ungenutzt int
// NewToken wird nur unmittelbar nach dem Anlegen gesetzt und genau
// einmal angezeigt — wie NewKey in keyListData.
NewToken string
NewTokenName string
}
// handleWorkspaceEditForm zeigt das Formular zum Umbenennen und die Gefahrenzone.
func (s *Server) handleWorkspaceEditForm(w http.ResponseWriter, r *http.Request) {
ws, ok := s.workspaceFromPath(w, r)
if !ok {
return
}
s.renderWorkspaceEdit(w, r, http.StatusOK, ws, workspaceEditData{}, "", "")
}
// handleWorkspaceEditSave benennt einen Workspace um.
func (s *Server) handleWorkspaceEditSave(w http.ResponseWriter, r *http.Request) {
ws, ok := s.workspaceFromPath(w, r)
if !ok {
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "", "Der Name darf nicht leer sein.")
return
}
if err := s.control.UpdateWorkspace(r.Context(), ws.ID, name); err != nil {
s.serverError(w, "Workspace konnte nicht umbenannt werden", err)
return
}
sess, _ := SessionFrom(r)
log.Printf("Workspace %s umbenannt in %q von %s", ws.ID, name, sess.ActorName())
http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape(name+" gespeichert"), http.StatusSeeOther)
}
// handleWorkspaceDelete löscht einen Workspace samt aller seiner Projekte,
// Einträge und Zugänge unwiederbringlich.
//
// Erst die Kontroll-Datenbank (Workspace-Zeile, Routing-Index), dann die Datei
// selbst — siehe der Kommentar an workspace.Manager.Delete. Betraf die
// Löschung den Workspace der eigenen Sitzung, wird die Sitzung auf "kein
// Workspace gewählt" zurückgesetzt statt den Admin einfach auszusperren.
func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) {
ws, ok := s.workspaceFromID(w, r, r.FormValue("id"))
if !ok {
return
}
if r.FormValue("confirm") != ws.Name {
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "",
"Zum Löschen muss der Workspace-Name \""+ws.Name+"\" zur Bestätigung eingegeben werden.")
return
}
if err := s.control.DeleteWorkspace(r.Context(), ws.ID); err != nil {
s.serverError(w, "Workspace konnte nicht gelöscht werden", err)
return
}
// Die Datei ist danach überflüssiger Ballast, kein Sicherheitsproblem mehr
// — die Kontroll-Datenbank kennt den Workspace nicht mehr, also kann ihn
// auch niemand mehr erreichen. Ein Fehler hier darf die Antwort deshalb
// nicht mehr aufhalten, nur im Log stehen.
if err := s.workspaces.Delete(ws.ID); err != nil {
log.Printf("Workspace-Datei %s konnte nicht gelöscht werden: %v", ws.ID, err)
}
sess, _ := SessionFrom(r)
log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName())
if sess.WorkspaceID == ws.ID {
s.setSessionCookie(w, r, s.plattformSubjekt(""))
}
http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape("Workspace gelöscht"), http.StatusSeeOther)
}
// projectCount zählt die Projekte eines Workspace.
//
// Dafür muss die Datei geöffnet werden (oder liegt schon im Cache des
// Managers) — bei 10–40 Workspaces auf einer Verwaltungsseite unproblematisch,
// siehe AUSBAUSTUFEN.md 4.4. Ein Fehler zeigt sich als 0 statt eine ganze
// Übersichtsseite scheitern zu lassen; das Ergebnis ist ohnehin nur eine
// Zusatzinformation, kein Sicherheitsmerkmal.
func (s *Server) projectCount(r *http.Request, workspaceID string) int {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err)
return 0
}
projekte, err := ws.Store.ListProjects(r.Context(), workspaceID)
if err != nil {
log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err)
return 0
}
return len(projekte)
}
// renderWorkspaceEdit lädt Projektzahl und Workspace-Admins und rendert die
// Bearbeitungsseite. In daten sind nur NewToken/NewTokenName von Belang, den
// Rest füllt diese Funktion.
func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int,
ws workspaceItem, daten workspaceEditData, flash, fehler string) {
admins, err := s.control.ListWorkspaceAdmins(r.Context(), ws.ID)
if err != nil {
s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err)
return
}
daten.Workspace = ws
daten.Projects = s.projectCount(r, ws.ID)
daten.Admins = admins
daten.Jetzt = time.Now()
for _, a := range admins {
if a.Unused(daten.Jetzt) {
daten.Ungenutzt++
}
}
s.render(w, r, "workspace-edit.html", status, pageData{
Title: ws.Name,
Bereich: "workspaces",
Flash: flash,
Error: fehler,
Data: daten,
})
}
// workspaceFromPath löst die Workspace-ID aus der URL auf ({id}/edit-Routen).
func (s *Server) workspaceFromPath(w http.ResponseWriter, r *http.Request) (workspaceItem, bool) {
return s.workspaceFromID(w, r, r.PathValue("id"))
}
// workspaceFromID lädt einen Workspace anhand seiner ID — gemeinsame Logik für
// die pfadbasierten Bearbeitungsrouten und die formularbasierte Löschroute
// (die folgt bewusst demselben Muster wie /admin/projects/delete).
func (s *Server) workspaceFromID(w http.ResponseWriter, r *http.Request, id string) (workspaceItem, bool) {
ws, err := s.control.WorkspaceByID(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return workspaceItem{}, false
}
if err != nil {
s.serverError(w, "Workspace konnte nicht geladen werden", err)
return workspaceItem{}, false
}
return workspaceItem{ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02")}, true
}
// handleWorkspaceSelect wechselt den Workspace der aktuellen Plattform-Admin-Sitzung.
//
// Technisch ist das eine neue Anmeldung mit demselben Subject-Schema wie beim
// Login (siehe resolveLoginKey in handlers_auth.go), nur ausgelöst durch einen
// Klick statt durch eine erneute Eingabe des ADMIN_TOKEN.
func (s *Server) handleWorkspaceSelect(w http.ResponseWriter, r *http.Request) {
id := r.FormValue("id")
if _, err := s.control.WorkspaceByID(r.Context(), id); err != nil {
http.NotFound(w, r)
return
}
s.setSessionCookie(w, r, s.plattformSubjekt(id))
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
}