package web import ( "errors" "log" "net/http" "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 // Location ist das PostgreSQL-Schema bzw. die SQLite-Datei des Workspace — // damit der Plattform-Admin ihn in der Datenbank wiederfindet. Location string // Admins zählt die aktiven Workspace-Admins, AdminsUnused die davon, // die seit über 90 Tagen niemand benutzt hat (store.WorkspaceAdmin.Unused). Admins int AdminsUnused 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, s.hintFrom(r), "") } // renderWorkspaceList lädt alle Workspaces und rendert die Auswahlseite. func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, status int, hint, errs 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 } now := time.Now() active := make(map[string]int) unused := make(map[string]int) for _, a := range admins { active[a.WorkspaceID]++ if a.Unused(now) { unused[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"), Location: s.workspaces.Location(ws.ID), Projects: s.projectCount(r, ws.ID), Admins: active[ws.ID], AdminsUnused: unused[ws.ID], } } s.render(w, r, "workspaces.html", status, pageData{ Title: "Workspaces", Section: "workspaces", Flash: hint, Error: errs, 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.platformSubject(ws.ID), sess.Expires) 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 // Now 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. Now time.Time Unused 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, s.withHint("/admin/workspaces", 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.platformSubject(""), sess.Expires) } http.Redirect(w, r, s.withHint("/admin/workspaces", "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 } projects, 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(projects) } // renderWorkspaceEdit lädt Projektzahl und Workspace-Admins und rendert die // Bearbeitungsseite. In data 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, data workspaceEditData, flash, errs string) { admins, err := s.control.ListWorkspaceAdmins(r.Context(), ws.ID) if err != nil { s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err) return } data.Workspace = ws data.Projects = s.projectCount(r, ws.ID) data.Admins = admins data.Now = time.Now() for _, a := range admins { if a.Unused(data.Now) { data.Unused++ } } s.render(w, r, "workspace-edit.html", status, pageData{ Title: ws.Name, Section: "workspaces", Flash: flash, Error: errs, Data: data, }) } // 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 } sess, _ := SessionFrom(r) s.setSessionCookie(w, r, s.platformSubject(id), sess.Expires) http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) }