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>
243 lines
8.4 KiB
Go
243 lines
8.4 KiB
Go
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)
|
||
}
|