munin/internal/web/handlers_trash.go
2026-10-19 10:13:56 +02:00

184 lines
5.8 KiB
Go
Raw Permalink 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
// Papierkorb je Projekt (AUSBAUSTUFEN.md 3.9).
//
// Alle drei Wege verlangen Schreibrecht, auch das bloße Ansehen. Grund: Ein
// Eintrag landet oft genau deshalb im Papierkorb, weil er etwas enthielt, das
// dort nicht hingehört — ein versehentlich abgelegtes Passwort etwa. Wer nur
// lesen darf, soll das nicht über den Papierkorb doch noch zu sehen bekommen.
//
// Wiederherstellen und endgültig Löschen gibt es bewusst NUR hier, nicht über
// MCP: Ein fehlgeleiteter Agent soll seinen Schaden nicht selbst "vollenden"
// können, indem er nach dem Löschen auch noch den Papierkorb leert.
import (
"fmt"
"net/http"
"net/url"
"munin/internal/store"
)
// trashData sind die Daten der Papierkorb-Seite.
type trashData struct {
Entries []store.TrashedEntry
// From ist der Filter "nur, was dieser Zugang gelöscht hat" — leer, wenn
// alles angezeigt wird.
From string
// Frist in Tagen, für den Hinweistext.
RetentionDays int
}
// handleTrashList zeigt den Papierkorb eines Projekts.
func (s *Server) handleTrashList(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
from := r.URL.Query().Get("von")
entries, err := ws.Store.ListTrash(r.Context(), project.ID, from)
if err != nil {
s.serverError(w, "Papierkorb konnte nicht geladen werden", err)
return
}
s.render(w, r, "trash.html", http.StatusOK, pageData{
Title: "Papierkorb – " + project.Name,
Section: "papierkorb",
Flash: s.hintFrom(r),
Data: trashData{Entries: entries, From: from, RetentionDays: store.TrashRetentionDays},
})
}
// trashSelection liest die ausgewählten Bezeichner aus dem Formular.
//
// Das Formular schickt für jeden angehakten Eintrag ein eigenes Feld "slugs"
// — mehrere Felder mit demselben Namen. r.Form["slugs"] liefert sie alle als
// Liste; r.FormValue("slugs") gäbe nur das erste zurück.
func trashSelection(r *http.Request) ([]string, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
// Doppelte Angaben (etwa ein Kästchen plus derselbe Eintrag über "alle
// wiederherstellen") nur einmal behandeln — sonst stünde der Vorgang
// zweimal im Protokoll. Die map merkt sich, was schon übernommen wurde.
seen := map[string]bool{}
var slugs []string
for _, slug := range r.Form["slugs"] {
if slug == "" || seen[slug] {
continue
}
seen[slug] = true
slugs = append(slugs, slug)
}
return slugs, nil
}
// backToTrash leitet mit einer Meldung auf die Papierkorb-Seite
// zurück — und behält dabei einen gesetzten Filter bei.
func (s *Server) backToTrash(w http.ResponseWriter, r *http.Request, projectSlug, message string) {
// url.Values baut eine Query ("a=1&b=2") und maskiert dabei jeden Wert
// korrekt — sicherer als Zeichenketten von Hand zusammenzusetzen.
values := url.Values{}
if from := r.FormValue("von"); from != "" {
values.Set("von", from)
}
if message != "" {
values.Set(hintParam, message)
values.Set(hintSigParam, s.signer.SignHint(message))
}
target := "/p/" + projectSlug + "/trash"
if len(values) > 0 {
target += "?" + values.Encode()
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// handleTrashRestore holt die ausgewählten Einträge zurück.
func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slugs, err := trashSelection(r)
if err != nil {
s.serverError(w, "Formular konnte nicht gelesen werden", err)
return
}
if len(slugs) == 0 {
s.backToTrash(w, r, project.Slug, "Nichts ausgewählt.")
return
}
back, err := ws.Store.RestoreFromTrash(r.Context(), project.ID, slugs)
if err != nil {
s.serverError(w, "Einträge konnten nicht wiederhergestellt werden", err)
return
}
// Jeder zurückgeholte Eintrag kommt wieder in den Suchindex und bekommt
// eine eigene Protokollzeile — so lässt sich im Protokoll nach einem
// einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen.
for _, e := range back {
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb,
Detail: "aus dem Papierkorb",
})
}
s.backToTrash(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge wiederhergestellt.", len(back)))
}
// handleTrashPurge löscht die ausgewählten Einträge endgültig.
func (s *Server) handleTrashPurge(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slugs, err := trashSelection(r)
if err != nil {
s.serverError(w, "Formular konnte nicht gelesen werden", err)
return
}
if len(slugs) == 0 {
s.backToTrash(w, r, project.Slug, "Nichts ausgewählt.")
return
}
// Vorher festhalten, welche Bezeichner wirklich im Papierkorb liegen —
// nur die werden gelöscht und nur die gehören ins Protokoll. Eine
// untergeschobene Angabe taucht so auch dort nicht auf.
inTrash, err := ws.Store.ListTrash(r.Context(), project.ID, "")
if err != nil {
s.serverError(w, "Papierkorb konnte nicht geladen werden", err)
return
}
existing := make(map[string]bool, len(inTrash))
for _, t := range inTrash {
existing[t.Slug] = true
}
count, err := ws.Store.PurgeFromTrash(r.Context(), project.ID, slugs)
if err != nil {
s.serverError(w, "Einträge konnten nicht endgültig gelöscht werden", err)
return
}
for _, slug := range slugs {
if !existing[slug] {
continue
}
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryPurge, EntrySlug: slug, Source: store.SourceWeb,
Detail: "von Hand aus dem Papierkorb",
})
}
s.backToTrash(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge endgültig gelöscht.", count))
}