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)) }