300 lines
10 KiB
Go
300 lines
10 KiB
Go
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
|
||
|
||
// 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"),
|
||
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)
|
||
}
|