munin/internal/web/handlers_admin.go

710 lines
24 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"
"fmt"
"log"
"net/http"
"net/url"
"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
Frei int
// EigenesLimit 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.
EigenesLimit 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)
projekte, 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, projekte)
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",
Bereich: "projekte",
Flash: r.URL.Query().Get("hinweis"),
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"))
beschreibung := strings.TrimSpace(r.FormValue("description"))
if slug == "" {
slug = store.Slugify(name)
}
projekt, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, beschreibung)
if err != nil {
s.renderProjectListWithError(w, r, err.Error())
return
}
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: projekt.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/"+projekt.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")
projekt, 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") != projekt.Slug {
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "",
"Zum Löschen muss der Projekt-Bezeichner \""+projekt.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: projekt.Slug,
})
http.Redirect(w, r, "/admin/projects?hinweis=Projekt+gel%C3%B6scht", http.StatusSeeOther)
}
// renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung.
func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, meldung string) {
ws, sess := s.workspaceAndSession(r)
projekte, 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, projekte)
s.render(w, r, "projects.html", http.StatusBadRequest, pageData{
Title: "Projekte",
Bereich: "projekte",
Error: meldung,
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, projekte []store.Project) ([]projectListItem, error) {
ws, _ := s.workspaceAndSession(r)
items := make([]projectListItem, 0, len(projekte))
// 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.
nutzung, err := ws.Store.UsageSummaries(r.Context(), "", time.Now())
if err != nil {
return items, err
}
for _, p := range projekte {
u := nutzung[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch
item := projectListItem{
Project: p,
Entries: u.Entries,
Usage: u,
EigenesLimit: p.WriteLimitHourly,
}
if s.writeBudget != nil {
item.Frei, 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) {
projekt, ok := s.projectFromSlug(w, r)
if !ok {
return
}
s.renderProjectEdit(w, r, http.StatusOK, projekt, "", "")
}
// 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)
projekt, ok := s.projectFromSlug(w, r)
if !ok {
return
}
name := strings.TrimSpace(r.FormValue("name"))
beschreibung := strings.TrimSpace(r.FormValue("description"))
if name == "" {
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", "Der Name darf nicht leer sein.")
return
}
limit := projekt.WriteLimitHourly
if sess.CanEditWriteBudget() {
roh := strings.TrimSpace(r.FormValue("write_limit_hourly"))
var err error
limit, err = strconv.Atoi(roh)
if err != nil || limit < 0 {
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "",
"Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).")
return
}
}
aenderungen := projektAenderungen(projekt, name, beschreibung, limit)
if len(aenderungen) == 0 {
s.renderProjectEdit(w, r, http.StatusOK, projekt, "Nichts geändert.", "")
return
}
if err := ws.Store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{
Name: name,
Description: beschreibung,
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: projekt.ID, Actor: sess.ActorName(),
Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: strings.Join(aenderungen, "; "),
})
http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(projekt.Slug+" gespeichert"),
http.StatusSeeOther)
}
// projektAenderungen beschreibt, was sich gegenüber dem gespeicherten Stand
// ändert — fürs Protokoll, und um ein Speichern ohne Änderung zu erkennen.
func projektAenderungen(alt store.Project, name, beschreibung string, limit int) []string {
var aenderungen []string
if name != alt.Name {
aenderungen = append(aenderungen, fmt.Sprintf("Name: %q → %q", alt.Name, name))
}
if beschreibung != alt.Description {
aenderungen = append(aenderungen, fmt.Sprintf("Beschreibung: %q → %q", alt.Description, beschreibung))
}
if limit != alt.WriteLimitHourly {
aenderungen = append(aenderungen,
fmt.Sprintf("write_limit_hourly: %d → %d", alt.WriteLimitHourly, limit))
}
return aenderungen
}
// 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)
projekt, 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 projekt, true
}
// renderProjectEdit zeigt die Bearbeitungsseite eines Projekts.
func (s *Server) renderProjectEdit(w http.ResponseWriter, r *http.Request, status int,
projekt store.Project, flash, fehler string) {
items, err := s.projectListItems(r, []store.Project{projekt})
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: projekt.Name,
Bereich: "projekte",
Flash: flash,
Error: fehler,
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_hilfe.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
// BaldAbgelaufen zählt gültige Keys, die innerhalb von
// store.ExpiresSoonWithin ablaufen — für den Hinweis über der Tabelle.
BaldAbgelaufen int
// Heute ist das frühestmögliche Ablaufdatum im Formular (morgen wäre
// strenger, aber "heute" heißt: bis heute Abend).
Heute string
// Jetzt ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro
// Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen.
Jetzt time.Time
// Ungenutzt 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.
Ungenutzt 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"))
rolle := auth.Role(r.FormValue("role"))
if !rolle.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 ablauf time.Time
if text := strings.TrimSpace(r.FormValue("expires")); text != "" {
tag, err := time.ParseInLocation(auditDatum, text, time.Local)
if err != nil {
s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Das Ablaufdatum ist kein gültiges Datum.")
return
}
ablauf = tag.AddDate(0, 0, 1)
}
klartext, err := auth.GenerateKey()
if err != nil {
s.serverError(w, "Key konnte nicht erzeugt werden", err)
return
}
erzeugt, err := ws.Store.CreateAPIKeyWithExpiry(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle), ablauf)
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(), erzeugt.Hash, sess.WorkspaceID); err != nil {
log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", erzeugt.ID, err)
}
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(),
Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: keyDetail(erzeugt),
})
s.renderKeyList(w, r, http.StatusOK,
keyListData{NewKey: klartext, NewKeyName: erzeugt.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, daten keyListData, hinweis, fehler 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
}
daten.Keys = keys
daten.ServerURL = serverURL(r)
daten.Jetzt = time.Now()
daten.Heute = daten.Jetzt.Format(auditDatum)
for _, k := range keys {
if k.Unused(daten.Jetzt) {
daten.Ungenutzt++
}
if k.ExpiresSoon(daten.Jetzt) {
daten.BaldAbgelaufen++
}
}
s.render(w, r, "keys.html", status, pageData{
Title: "Zugänge – " + project.Name,
Bereich: "zugaenge",
Flash: hinweis,
Error: fehler,
Data: daten,
})
}
// ── 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 auditFormular
Aktionen []auditAktion
Zugaenge []string
Gefiltert bool // mindestens ein Filter ist gesetzt
}
// auditFormular sind die Filterwerte der Protokollseite, als Text wie im
// Formular — Datumsangaben im Format JJJJ-MM-TT.
type auditFormular struct {
Vorgang string
Wer string
Projekt string // nur im Protokoll über alle Projekte
Von string
Bis string
}
// auditAktion ist ein Vorgang samt verständlichem Namen für die Auswahlliste.
type auditAktion struct{ Action, Label string }
// auditDatum ist das Format der Datumsfelder im Filter (input type=date).
const auditDatum = "2006-01-02"
// leseAuditFilter ü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 leseAuditFilter(r *http.Request, filter *store.AuditFilter, global bool) auditFormular {
q := r.URL.Query()
f := auditFormular{
Vorgang: strings.TrimSpace(q.Get("vorgang")),
Wer: strings.TrimSpace(q.Get("wer")),
Von: strings.TrimSpace(q.Get("von")),
Bis: strings.TrimSpace(q.Get("bis")),
}
for _, a := range store.AuditActions {
if a.Action == f.Vorgang {
filter.Action = f.Vorgang
}
}
if filter.Action == "" {
f.Vorgang = ""
}
filter.Actor = f.Wer
if global {
f.Projekt = strings.TrimSpace(q.Get("projekt"))
filter.ProjectID = f.Projekt
}
// Die Tage gelten in der Zeitzone des Servers — derselben, in der die
// Tabelle die Zeitpunkte anzeigt.
if t, err := time.ParseInLocation(auditDatum, f.Von, time.Local); err == nil {
filter.Since = t
} else {
f.Von = ""
}
if t, err := time.ParseInLocation(auditDatum, f.Bis, time.Local); err == nil {
filter.Until = t.AddDate(0, 0, 1)
} else {
f.Bis = ""
}
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, titel 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.
zugaenge, err := ws.Store.AuditActors(ctx, filter.ProjectID)
if err != nil {
s.serverError(w, "Protokoll konnte nicht geladen werden", err)
return
}
formular := leseAuditFilter(r, &filter, global)
ereignisse, 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.
projektNamen, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID)
if err != nil {
s.serverError(w, "Projektnamen konnten nicht geladen werden", err)
return
}
projektIDs := make([]string, 0, len(projektNamen))
for id := range projektNamen {
projektIDs = append(projektIDs, id)
}
keyNamen, err := ws.Store.KeyNames(ctx, projektIDs)
if err != nil {
s.serverError(w, "Key-Namen konnten nicht geladen werden", err)
return
}
s.render(w, r, "audit.html", http.StatusOK, pageData{
Title: titel,
Bereich: "protokoll",
Data: auditData{
Events: ereignisse,
ProjectNames: projektNamen,
KeyNames: keyNamen,
Global: global,
Filter: formular,
Aktionen: auditAktionen(),
Zugaenge: zugaenge,
Gefiltert: formular != auditFormular{},
},
})
}
// auditAktionen übersetzt store.AuditActions in den Typ der Seite.
func auditAktionen() []auditAktion {
liste := make([]auditAktion, 0, len(store.AuditActions))
for _, a := range store.AuditActions {
liste = append(liste, auditAktion{Action: a.Action, Label: a.Label})
}
return liste
}
// ── 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.
eintrag, 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, eintrag)
if err != nil {
s.serverError(w, "Export fehlgeschlagen", err)
return
}
meldung := fmt.Sprintf("Export abgeschlossen: %d Dateien geschrieben", stats.Written)
if stats.Removed > 0 {
meldung += fmt.Sprintf(", %d verwaiste entfernt", stats.Removed)
}
http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}