709 lines
24 KiB
Go
709 lines
24 KiB
Go
package web
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"munin/internal/auth"
|
||
"munin/internal/store"
|
||
)
|
||
|
||
// projectListData sind die Daten der Projektübersicht.
|
||
type projectListData struct {
|
||
Projects []projectListItem
|
||
}
|
||
|
||
// projectListItem ist eine Zeile der Projektübersicht.
|
||
type projectListItem struct {
|
||
Project store.Project
|
||
Entries int
|
||
|
||
// Usage sind die Kennzahlen der Nutzungsübersicht (AUSBAUSTUFEN.md 4.6),
|
||
// hier als Spalten: So fällt dem Plattform-Admin auf, welches Team Munin
|
||
// gar nicht benutzt.
|
||
Usage store.UsageSummary
|
||
|
||
// Limit ist die geltende Obergrenze für schreibende MCP-Aufrufe je Stunde,
|
||
// Frei der Rest im laufenden Fenster. Limit 0 heißt "unbegrenzt".
|
||
//
|
||
// Die geltende Grenze steht hier und nicht im Template, weil erst das
|
||
// Schreibbudget die Auflösung kennt: eigene Angabe des Projekts, sonst der
|
||
// Standard aus der Umgebung.
|
||
Limit int
|
||
Free int
|
||
|
||
// OwnLimit ist die im Projekt hinterlegte Zahl — 0 bedeutet "keine
|
||
// eigene Angabe". Steht im Formularfeld, damit dort der gesetzte Wert
|
||
// erscheint und nicht die aufgelöste Grenze.
|
||
OwnLimit int
|
||
}
|
||
|
||
// handleProjectList zeigt alle Projekte des Workspace — für Plattform- und
|
||
// Workspace-Admin.
|
||
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
|
||
projects, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
|
||
if err != nil {
|
||
s.serverError(w, "Projekte konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
items, err := s.projectListItems(r, projects)
|
||
if err != nil {
|
||
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
|
||
return
|
||
}
|
||
|
||
s.render(w, r, "projects.html", http.StatusOK, pageData{
|
||
Title: "Projekte",
|
||
Section: "projekte",
|
||
Flash: s.hintFrom(r),
|
||
Data: projectListData{Projects: items},
|
||
})
|
||
}
|
||
|
||
// handleProjectCreate legt ein neues Projekt an.
|
||
func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
slug := strings.TrimSpace(r.FormValue("slug"))
|
||
description := strings.TrimSpace(r.FormValue("description"))
|
||
|
||
if slug == "" {
|
||
slug = store.Slugify(name)
|
||
}
|
||
|
||
project, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, description)
|
||
if err != nil {
|
||
s.renderProjectListWithError(w, r, err.Error())
|
||
return
|
||
}
|
||
|
||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||
ProjectID: project.ID, Actor: sess.ActorName(),
|
||
Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||
})
|
||
|
||
// Direkt zur Key-Verwaltung: Ein Projekt ohne Key ist nutzlos, und der erste
|
||
// Handgriff nach dem Anlegen ist immer, einen Zugang zu erzeugen.
|
||
http.Redirect(w, r, "/p/"+project.Slug+"/keys", http.StatusSeeOther)
|
||
}
|
||
|
||
// handleProjectDelete löscht ein Projekt samt Einträgen und Keys.
|
||
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
id := r.FormValue("id")
|
||
|
||
project, err := ws.Store.ProjectByID(r.Context(), id)
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.serverError(w, "Projekt konnte nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
// Sicherheitsabfrage im Formular: Der eingegebene Bezeichner muss stimmen.
|
||
// Ein Klick auf "Löschen" reicht nicht — dahinter verschwinden alle Einträge
|
||
// des Projekts unwiederbringlich.
|
||
if r.FormValue("confirm") != project.Slug {
|
||
s.renderProjectEdit(w, r, http.StatusBadRequest, project, "",
|
||
"Zum Löschen muss der Projekt-Bezeichner \""+project.Slug+"\" zur Bestätigung eingegeben werden.")
|
||
return
|
||
}
|
||
|
||
if err := ws.Store.DeleteProject(r.Context(), id); err != nil {
|
||
s.serverError(w, "Projekt konnte nicht gelöscht werden", err)
|
||
return
|
||
}
|
||
|
||
ws.Search.Forget(id)
|
||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||
ProjectID: id, Actor: sess.ActorName(),
|
||
Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||
Detail: project.Slug,
|
||
})
|
||
|
||
http.Redirect(w, r, s.withHint("/admin/projects", "Projekt gelöscht"), http.StatusSeeOther)
|
||
}
|
||
|
||
// renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung.
|
||
func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, message string) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
|
||
projects, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
|
||
if err != nil {
|
||
s.serverError(w, "Projekte konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
// Fehler beim Zählen sind hier gleichgültig: Auf dem Weg hierher ist bereits
|
||
// etwas schiefgegangen, und die Fehlermeldung ist wichtiger als die Zahlen.
|
||
items, _ := s.projectListItems(r, projects)
|
||
|
||
s.render(w, r, "projects.html", http.StatusBadRequest, pageData{
|
||
Title: "Projekte",
|
||
Section: "projekte",
|
||
Error: message,
|
||
Data: projectListData{Projects: items},
|
||
})
|
||
}
|
||
|
||
// projectListItems reichert die Projekte mit Eintragszahl, Nutzung und
|
||
// Schreibbudget an.
|
||
//
|
||
// Eine eigene Funktion, weil die Übersicht an zwei Stellen gerendert wird — mit
|
||
// und ohne Fehlermeldung. Ohne sie liefen beide Fassungen früher oder später
|
||
// auseinander.
|
||
func (s *Server) projectListItems(r *http.Request, projects []store.Project) ([]projectListItem, error) {
|
||
ws, _ := s.workspaceAndSession(r)
|
||
items := make([]projectListItem, 0, len(projects))
|
||
|
||
// Die Kennzahlen aller Projekte in einem Rutsch (zwei Abfragen), statt
|
||
// je Projekt einzeln nachzufragen — bei 40 Projekten wären das sonst 80.
|
||
// Die Eintragszahl steckt mit darin.
|
||
usage, err := ws.Store.UsageSummaries(r.Context(), "", time.Now())
|
||
if err != nil {
|
||
return items, err
|
||
}
|
||
|
||
for _, p := range projects {
|
||
u := usage[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch
|
||
item := projectListItem{
|
||
Project: p,
|
||
Entries: u.Entries,
|
||
Usage: u,
|
||
OwnLimit: p.WriteLimitHourly,
|
||
}
|
||
if s.writeBudget != nil {
|
||
item.Free, item.Limit = s.writeBudget.Remaining(p.ID, p.WriteLimitHourly)
|
||
}
|
||
|
||
items = append(items, item)
|
||
}
|
||
|
||
return items, nil
|
||
}
|
||
|
||
// projectEditData sind die Daten der Bearbeitungsseite eines Projekts.
|
||
type projectEditData struct {
|
||
Item projectListItem
|
||
}
|
||
|
||
// handleProjectEditForm zeigt das Bearbeitungsformular eines Projekts.
|
||
//
|
||
// Anders als bei den früheren Formularen auf der Übersichtsseite steht hier ein
|
||
// EINZELNES Projekt fest — mit seinen tatsächlichen Werten in den Feldern. Ein
|
||
// Auswahlfeld über allen Projekten plus leerem Eingabefeld sieht bequemer aus,
|
||
// hat aber eine Falle: Wer speichert, ohne ein Feld anzufassen, überschreibt den
|
||
// bestehenden Wert mit der Vorgabe des Formulars.
|
||
func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) {
|
||
project, ok := s.projectFromSlug(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
s.renderProjectEdit(w, r, http.StatusOK, project, "", "")
|
||
}
|
||
|
||
// handleProjectEditSave speichert Name, Beschreibung und Obergrenze.
|
||
//
|
||
// Die Obergrenze (Schreibbudget) darf nur der Plattform-Admin ändern. Beim
|
||
// Workspace-Admin wird das Feld nicht nur im Formular ausgeblendet, sondern
|
||
// hier ignoriert — ein von Hand nachgebautes Formular ändert sie also nicht.
|
||
func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
|
||
project, ok := s.projectFromSlug(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
description := strings.TrimSpace(r.FormValue("description"))
|
||
|
||
if name == "" {
|
||
s.renderProjectEdit(w, r, http.StatusBadRequest, project, "", "Der Name darf nicht leer sein.")
|
||
return
|
||
}
|
||
|
||
limit := project.WriteLimitHourly
|
||
if sess.CanEditWriteBudget() {
|
||
raw := strings.TrimSpace(r.FormValue("write_limit_hourly"))
|
||
var err error
|
||
limit, err = strconv.Atoi(raw)
|
||
if err != nil || limit < 0 {
|
||
s.renderProjectEdit(w, r, http.StatusBadRequest, project, "",
|
||
"Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).")
|
||
return
|
||
}
|
||
}
|
||
|
||
changes := projectChanges(project, name, description, limit)
|
||
if len(changes) == 0 {
|
||
s.renderProjectEdit(w, r, http.StatusOK, project, "Nichts geändert.", "")
|
||
return
|
||
}
|
||
|
||
if err := ws.Store.UpdateProject(r.Context(), project.ID, store.ProjectUpdate{
|
||
Name: name,
|
||
Description: description,
|
||
WriteLimitHourly: limit,
|
||
}); err != nil {
|
||
s.serverError(w, "Projekt konnte nicht geändert werden", err)
|
||
return
|
||
}
|
||
|
||
// Was sich geändert hat, steht im Detail — sonst sagt das Protokoll nur,
|
||
// DASS jemand etwas geändert hat. Gerade bei der Obergrenze ist das der
|
||
// interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine
|
||
// Betriebsentscheidung.
|
||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||
ProjectID: project.ID, Actor: sess.ActorName(),
|
||
Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||
Detail: strings.Join(changes, "; "),
|
||
})
|
||
|
||
http.Redirect(w, r, s.withHint("/admin/projects", project.Slug+" gespeichert"),
|
||
http.StatusSeeOther)
|
||
}
|
||
|
||
// projectChanges beschreibt, was sich gegenüber dem gespeicherten Stand
|
||
// ändert — fürs Protokoll, und um ein Speichern ohne Änderung zu erkennen.
|
||
func projectChanges(old store.Project, name, description string, limit int) []string {
|
||
var changes []string
|
||
|
||
if name != old.Name {
|
||
changes = append(changes, fmt.Sprintf("Name: %q → %q", old.Name, name))
|
||
}
|
||
if description != old.Description {
|
||
changes = append(changes, fmt.Sprintf("Beschreibung: %q → %q", old.Description, description))
|
||
}
|
||
if limit != old.WriteLimitHourly {
|
||
changes = append(changes,
|
||
fmt.Sprintf("write_limit_hourly: %d → %d", old.WriteLimitHourly, limit))
|
||
}
|
||
|
||
return changes
|
||
}
|
||
|
||
// projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf.
|
||
//
|
||
// Eigene Funktion statt der Middleware requireProject: Die liegt unter
|
||
// /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier sind ohnehin
|
||
// nur Plattform- und Workspace-Admin unterwegs (siehe Routes), und der
|
||
// Bezeichner steht unter einem anderen Platzhalter. Gesucht wird nur in der
|
||
// Datei des eigenen Workspace.
|
||
func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
|
||
project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, r.PathValue("slug"))
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
http.NotFound(w, r)
|
||
return store.Project{}, false
|
||
}
|
||
if err != nil {
|
||
s.serverError(w, "Projekt konnte nicht geladen werden", err)
|
||
return store.Project{}, false
|
||
}
|
||
return project, true
|
||
}
|
||
|
||
// renderProjectEdit zeigt die Bearbeitungsseite eines Projekts.
|
||
func (s *Server) renderProjectEdit(w http.ResponseWriter, r *http.Request, status int,
|
||
project store.Project, flash, errs string) {
|
||
|
||
items, err := s.projectListItems(r, []store.Project{project})
|
||
if err != nil {
|
||
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
|
||
return
|
||
}
|
||
|
||
s.render(w, r, "project-edit.html", status, pageData{
|
||
Title: project.Name,
|
||
Section: "projekte",
|
||
Flash: flash,
|
||
Error: errs,
|
||
Data: projectEditData{Item: items[0]},
|
||
})
|
||
}
|
||
|
||
// ── Key-Verwaltung ───────────────────────────────────────────────────────────
|
||
|
||
// keyListData sind die Daten der Key-Verwaltung.
|
||
type keyListData struct {
|
||
Keys []store.APIKey
|
||
|
||
// NewKey wird nur unmittelbar nach dem Anlegen gesetzt. Der Klartext steht
|
||
// nirgends gespeichert — nur der Hash liegt in der Datenbank —, deshalb wird
|
||
// er hier genau einmal angezeigt.
|
||
NewKey string
|
||
NewKeyName string
|
||
|
||
// ServerURL ist die Adresse dieses Servers für die fertigen
|
||
// Konfigurationsblöcke, etwa "https://munin.example.org". Dieselbe Funktion
|
||
// wie in der Hilfe (serverURL in handlers_help.go) — gerade hier zählt es,
|
||
// weil der Key nur dieses eine Mal zu sehen ist und der Block ohne
|
||
// Nacharbeit kopierbar sein soll.
|
||
ServerURL string
|
||
|
||
// ExpiringSoon zählt gültige Keys, die innerhalb von
|
||
// store.ExpiresSoonWithin ablaufen — für den Hinweis über der Tabelle.
|
||
ExpiringSoon int
|
||
|
||
// Today ist das frühestmögliche Ablaufdatum im Formular (morgen wäre
|
||
// strenger, aber "heute" heißt: bis heute Abend).
|
||
Today string
|
||
|
||
// Now ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro
|
||
// Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen.
|
||
Now time.Time
|
||
|
||
// Unused zählt die aktiven Keys, die seit über 90 Tagen niemand benutzt
|
||
// hat. Ist die Zahl größer als null, steht über der Tabelle ein Hinweis.
|
||
Unused int
|
||
}
|
||
|
||
// handleKeyList zeigt die Keys eines Projekts.
|
||
func (s *Server) handleKeyList(w http.ResponseWriter, r *http.Request) {
|
||
s.renderKeyList(w, r, http.StatusOK, keyListData{}, "", "")
|
||
}
|
||
|
||
// handleKeyCreate erzeugt einen neuen Key und zeigt ihn genau einmal an.
|
||
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
project, _ := ProjectFrom(r)
|
||
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
role := auth.Role(r.FormValue("role"))
|
||
|
||
if !role.Valid() {
|
||
s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Unbekannte Rolle.")
|
||
return
|
||
}
|
||
|
||
// Ablaufdatum (optional, AUSBAUSTUFEN.md 4.7 Stufe 2): Der Key gilt bis
|
||
// einschließlich des gewählten Tages, also bis zum Beginn des nächsten.
|
||
var expiry time.Time
|
||
if text := strings.TrimSpace(r.FormValue("expires")); text != "" {
|
||
tag, err := time.ParseInLocation(auditDate, text, time.Local)
|
||
if err != nil {
|
||
s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Das Ablaufdatum ist kein gültiges Datum.")
|
||
return
|
||
}
|
||
expiry = tag.AddDate(0, 0, 1)
|
||
}
|
||
|
||
plaintext, err := auth.GenerateKey()
|
||
if err != nil {
|
||
s.serverError(w, "Key konnte nicht erzeugt werden", err)
|
||
return
|
||
}
|
||
|
||
created, err := ws.Store.CreateAPIKeyWithExpiry(r.Context(), project.ID, name, auth.HashKey(plaintext), string(role), expiry)
|
||
if err != nil {
|
||
s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error())
|
||
return
|
||
}
|
||
|
||
// Der Routing-Index muss im selben Zug nachgezogen werden — sonst lässt sich
|
||
// der Key künftig nicht mehr seinem Workspace zuordnen (siehe
|
||
// AUSBAUSTUFEN.md 4.4). Schlägt das fehl, bleibt der Key in dieser
|
||
// Workspace-Datei trotzdem gültig; nur das schnelle Routing über mehrere
|
||
// Workspaces hinweg wäre für ihn kaputt. Deshalb kein Abbruch, nur ein
|
||
// Log-Eintrag zum Nachziehen.
|
||
if err := s.control.IndexKey(r.Context(), created.Hash, sess.WorkspaceID); err != nil {
|
||
log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", created.ID, err)
|
||
}
|
||
|
||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||
ProjectID: project.ID, APIKeyID: created.ID, Actor: sess.ActorName(),
|
||
Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||
Detail: keyDetail(created),
|
||
})
|
||
|
||
s.renderKeyList(w, r, http.StatusOK,
|
||
keyListData{NewKey: plaintext, NewKeyName: created.Name},
|
||
"Key angelegt. Er wird nur jetzt angezeigt — bitte gleich übernehmen.", "")
|
||
}
|
||
|
||
// keyDetail beschreibt einen neuen Key fürs Protokoll: Name, Rolle und —
|
||
// falls gesetzt — der letzte gültige Tag.
|
||
func keyDetail(k store.APIKey) string {
|
||
text := k.Name + " (" + k.Role
|
||
if !k.ExpiresAt.IsZero() {
|
||
text += ", gültig bis " + k.ExpiresAt.Add(-time.Second).Local().Format("02.01.2006")
|
||
}
|
||
return text + ")"
|
||
}
|
||
|
||
// handleKeyRevoke zieht einen Key zurück.
|
||
func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
project, _ := ProjectFrom(r)
|
||
id := r.FormValue("id")
|
||
|
||
// Nachsehen, ob der Key wirklich zu diesem Projekt gehört. Ohne diese
|
||
// Prüfung könnte ein Projekt-Admin über eine untergeschobene ID den Key eines
|
||
// fremden Projekts sperren.
|
||
key, err := ws.Store.APIKeyByID(r.Context(), id)
|
||
if err != nil || key.ProjectID != project.ID {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
if err := ws.Store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||
s.serverError(w, "Key konnte nicht zurückgezogen werden", err)
|
||
return
|
||
}
|
||
|
||
if err := s.control.RemoveKeyIndex(r.Context(), key.Hash); err != nil {
|
||
log.Printf("Key %s konnte nicht aus dem Routing-Index entfernt werden: %v", id, err)
|
||
}
|
||
|
||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||
ProjectID: project.ID, APIKeyID: id, Actor: sess.ActorName(),
|
||
Action: store.ActionKeyRevoked, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||
Detail: key.Name,
|
||
})
|
||
|
||
s.renderKeyList(w, r, http.StatusOK, keyListData{}, "Key zurückgezogen. Er gilt ab sofort nicht mehr.", "")
|
||
}
|
||
|
||
// renderKeyList zeigt die Key-Verwaltung mit optionaler Meldung.
|
||
func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, data keyListData, hint, errs string) {
|
||
ws, _ := s.workspaceAndSession(r)
|
||
project, _ := ProjectFrom(r)
|
||
|
||
keys, err := ws.Store.ListAPIKeys(r.Context(), project.ID)
|
||
if err != nil {
|
||
s.serverError(w, "Keys konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
data.Keys = keys
|
||
data.ServerURL = serverURL(r)
|
||
data.Now = time.Now()
|
||
data.Today = data.Now.Format(auditDate)
|
||
for _, k := range keys {
|
||
if k.Unused(data.Now) {
|
||
data.Unused++
|
||
}
|
||
if k.ExpiresSoon(data.Now) {
|
||
data.ExpiringSoon++
|
||
}
|
||
}
|
||
|
||
s.render(w, r, "keys.html", status, pageData{
|
||
Title: "Zugänge – " + project.Name,
|
||
Section: "zugaenge",
|
||
Flash: hint,
|
||
Error: errs,
|
||
Data: data,
|
||
})
|
||
}
|
||
|
||
// ── Audit ────────────────────────────────────────────────────────────────────
|
||
|
||
// auditData sind die Daten der Protokollansicht.
|
||
type auditData struct {
|
||
Events []store.AuditEvent
|
||
ProjectNames map[string]string
|
||
KeyNames map[string]string
|
||
Global bool
|
||
|
||
// Filter (AUSBAUSTUFEN.md 5.11): die gewählten Werte, so wie sie wieder
|
||
// ins Formular gehören, und die Auswahl für die Listen.
|
||
Filter auditForm
|
||
Actions []auditAction
|
||
Keys []string
|
||
Filtered bool // mindestens ein Filter ist gesetzt
|
||
}
|
||
|
||
// auditForm sind die Filterwerte der Protokollseite, als Text wie im
|
||
// Formular — Datumsangaben im Format JJJJ-MM-TT.
|
||
type auditForm struct {
|
||
Event string
|
||
Who string
|
||
Project string // nur im Protokoll über alle Projekte
|
||
From string
|
||
Until string
|
||
}
|
||
|
||
// auditAction ist ein Vorgang samt verständlichem Namen für die Auswahlliste.
|
||
type auditAction struct{ Action, Label string }
|
||
|
||
// auditDate ist das Format der Datumsfelder im Filter (input type=date).
|
||
const auditDate = "2006-01-02"
|
||
|
||
// readAuditFilter übernimmt die Filter aus der Adresse in filter.
|
||
//
|
||
// Unbekannte oder kaputte Werte werden ignoriert statt gemeldet: Wer an der
|
||
// Adresse herumbastelt, bekommt das ungefilterte Protokoll, keine
|
||
// Fehlerseite. "bis" schließt den ganzen Tag ein — wer „bis 16.10." wählt,
|
||
// meint den 16. mit.
|
||
func readAuditFilter(r *http.Request, filter *store.AuditFilter, global bool) auditForm {
|
||
q := r.URL.Query()
|
||
f := auditForm{
|
||
Event: strings.TrimSpace(q.Get("vorgang")),
|
||
Who: strings.TrimSpace(q.Get("wer")),
|
||
From: strings.TrimSpace(q.Get("von")),
|
||
Until: strings.TrimSpace(q.Get("bis")),
|
||
}
|
||
|
||
for _, a := range store.AuditActions {
|
||
if a.Action == f.Event {
|
||
filter.Action = f.Event
|
||
}
|
||
}
|
||
if filter.Action == "" {
|
||
f.Event = ""
|
||
}
|
||
filter.Actor = f.Who
|
||
|
||
if global {
|
||
f.Project = strings.TrimSpace(q.Get("projekt"))
|
||
filter.ProjectID = f.Project
|
||
}
|
||
|
||
// Die Tage gelten in der Zeitzone des Servers — derselben, in der die
|
||
// Tabelle die Zeitpunkte anzeigt.
|
||
if t, err := time.ParseInLocation(auditDate, f.From, time.Local); err == nil {
|
||
filter.Since = t
|
||
} else {
|
||
f.From = ""
|
||
}
|
||
if t, err := time.ParseInLocation(auditDate, f.Until, time.Local); err == nil {
|
||
filter.Until = t.AddDate(0, 0, 1)
|
||
} else {
|
||
f.Until = ""
|
||
}
|
||
return f
|
||
}
|
||
|
||
// handleProjectAudit zeigt das Protokoll eines Projekts.
|
||
func (s *Server) handleProjectAudit(w http.ResponseWriter, r *http.Request) {
|
||
project, _ := ProjectFrom(r)
|
||
s.renderAudit(w, r, store.AuditFilter{ProjectID: project.ID}, false, "Protokoll – "+project.Name)
|
||
}
|
||
|
||
// handleGlobalAudit zeigt das Protokoll aller Projekte des Workspace — für
|
||
// Plattform- und Workspace-Admin.
|
||
func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) {
|
||
s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll")
|
||
}
|
||
|
||
// renderAudit lädt Ereignisse samt Klarnamen und rendert die Tabelle.
|
||
func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, title string) {
|
||
ws, sess := s.workspaceAndSession(r)
|
||
ctx := r.Context()
|
||
|
||
// Die Zugänge für die Auswahl: im Projektprotokoll die dieses Projekts,
|
||
// sonst alle — vor dem Übernehmen der Filter, damit die Liste nicht vom
|
||
// gewählten Projekt abhängt.
|
||
keys, err := ws.Store.AuditActors(ctx, filter.ProjectID)
|
||
if err != nil {
|
||
s.serverError(w, "Protokoll konnte nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
form := readAuditFilter(r, &filter, global)
|
||
|
||
events, err := ws.Store.ListAudit(ctx, filter)
|
||
if err != nil {
|
||
s.serverError(w, "Protokoll konnte nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
// IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen
|
||
// Kennungen stehen.
|
||
projectNames, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID)
|
||
if err != nil {
|
||
s.serverError(w, "Projektnamen konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
projectIDs := make([]string, 0, len(projectNames))
|
||
for id := range projectNames {
|
||
projectIDs = append(projectIDs, id)
|
||
}
|
||
|
||
keyNames, err := ws.Store.KeyNames(ctx, projectIDs)
|
||
if err != nil {
|
||
s.serverError(w, "Key-Namen konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
s.render(w, r, "audit.html", http.StatusOK, pageData{
|
||
Title: title,
|
||
Section: "protokoll",
|
||
Data: auditData{
|
||
Events: events,
|
||
ProjectNames: projectNames,
|
||
KeyNames: keyNames,
|
||
Global: global,
|
||
|
||
Filter: form,
|
||
Actions: auditActions(),
|
||
Keys: keys,
|
||
Filtered: form != auditForm{},
|
||
},
|
||
})
|
||
}
|
||
|
||
// auditActions übersetzt store.AuditActions in den Typ der Seite.
|
||
func auditActions() []auditAction {
|
||
list := make([]auditAction, 0, len(store.AuditActions))
|
||
for _, a := range store.AuditActions {
|
||
list = append(list, auditAction{Action: a.Action, Label: a.Label})
|
||
}
|
||
return list
|
||
}
|
||
|
||
// ── Export ───────────────────────────────────────────────────────────────────
|
||
|
||
// handleExport schreibt alle Projekte als Markdown-Dateien auf die Platte.
|
||
//
|
||
// Der Export läuft ohnehin täglich (siehe main.go). Der Knopf hier ist für den
|
||
// Fall gedacht, dass jemand unmittelbar vor einer Sicherung oder nach einer
|
||
// größeren Änderung einen aktuellen Stand haben möchte.
|
||
//
|
||
// Exportiert wird nur der gerade gewählte Workspace — derselbe, dessen
|
||
// Projekte auf der Seite mit dem Knopf stehen. Der tägliche Lauf in main.go
|
||
// übernimmt alle.
|
||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||
if s.exporter == nil {
|
||
http.Error(w, "Der Export ist nicht eingerichtet (EXPORT_DIR fehlt).", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
ws, sess := s.workspaceAndSession(r)
|
||
|
||
// Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung: Er
|
||
// landet in _workspace.md, und nach einem Umbenennen soll dort der neue
|
||
// stehen, nicht der beim Anmelden gemerkte.
|
||
entry, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID)
|
||
if err != nil {
|
||
s.serverError(w, "Workspace konnte nicht gelesen werden", err)
|
||
return
|
||
}
|
||
|
||
stats, err := s.exporter.ExportWorkspace(r.Context(), ws.Store, entry)
|
||
if err != nil {
|
||
s.serverError(w, "Export fehlgeschlagen", err)
|
||
return
|
||
}
|
||
|
||
message := fmt.Sprintf("Export abgeschlossen: %d Dateien geschrieben", stats.Written)
|
||
if stats.Removed > 0 {
|
||
message += fmt.Sprintf(", %d verwaiste entfernt", stats.Removed)
|
||
}
|
||
|
||
http.Redirect(w, r, s.withHint("/admin/projects", message), http.StatusSeeOther)
|
||
}
|