munin/internal/web/handlers_workspaces.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

243 lines
8.4 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"
"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
}
// 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
}
// 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"),
Projects: s.projectCount(r, 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, "platform:"+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
}
// 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, "", "")
}
// 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, "", "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, "",
"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, "platform")
}
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 die Projektzahl und rendert die Bearbeitungsseite.
func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int,
ws workspaceItem, flash, fehler string) {
s.render(w, r, "workspace-edit.html", status, pageData{
Title: ws.Name,
Bereich: "workspaces",
Flash: flash,
Error: fehler,
Data: workspaceEditData{Workspace: ws, Projects: s.projectCount(r, ws.ID)},
})
}
// 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, "platform:"+id)
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
}