710 lines
24 KiB
Go
710 lines
24 KiB
Go
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)
|
||
}
|