package admin import ( "fmt" "net/http" "strconv" "github.com/google/uuid" "aether/internal/audit" "aether/internal/rescan" "aether/pkg/response" ) // Die Audit-Endpunkte liegen im admin-Paket, nicht in audit: `auth` schreibt // selbst Einträge und importiert deshalb `audit` – läge der Handler dort und // bräuchte `auth` für die Admin-Prüfung, wäre der Import-Zyklus fertig. // GET /api/v1/admin/audit?action=&actor_id=&limit=&offset= func (h *Handler) ListAudit(w http.ResponseWriter, r *http.Request) { if _, ok := requireAdmin(r); !ok { response.Error(w, http.StatusForbidden, "admin required") return } f := audit.Filter{Action: r.URL.Query().Get("action")} if raw := r.URL.Query().Get("actor_id"); raw != "" { id, err := uuid.Parse(raw) if err != nil { response.Error(w, http.StatusBadRequest, "invalid actor_id") return } f.ActorID = &id } if raw := r.URL.Query().Get("limit"); raw != "" { f.Limit, _ = strconv.Atoi(raw) } if raw := r.URL.Query().Get("offset"); raw != "" { f.Offset, _ = strconv.Atoi(raw) } entries, total, err := h.auditor.List(r.Context(), f) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load audit log") return } response.JSON(w, http.StatusOK, map[string]any{ "entries": entries, "total": total, }) } // GET /api/v1/admin/audit/actions – füttert das Filter-Dropdown. func (h *Handler) ListAuditActions(w http.ResponseWriter, r *http.Request) { if _, ok := requireAdmin(r); !ok { response.Error(w, http.StatusForbidden, "admin required") return } actions, err := h.auditor.Actions(r.Context()) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load actions") return } response.JSON(w, http.StatusOK, actions) } // POST /api/v1/admin/gc – Bereinigung von Hand anstoßen. // // Der GC läuft ohnehin im Hintergrund; dieser Endpunkt macht ihn beobachtbar. // Ein Aufräumjob, dessen Wirkung man nicht nachsehen kann, ist schwer zu // vertrauen – und genau darum geht es hier, weil er Dateien löscht. func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) { if _, ok := requireAdmin(r); !ok { response.Error(w, http.StatusForbidden, "admin required") return } if h.gc == nil { response.Error(w, http.StatusServiceUnavailable, "cleanup not available") return } res, err := h.gc.Run(r.Context()) if err != nil { response.Error(w, http.StatusInternalServerError, "cleanup failed") return } h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", fmt.Sprintf("manual run: %d blobs scanned, %d removed, %d bytes freed", res.Scanned, res.Deleted, res.FreedBytes)) response.JSON(w, http.StatusOK, map[string]any{ "scanned": res.Scanned, "orphans": res.Orphans, "deleted": res.Deleted, "freed_bytes": res.FreedBytes, "skipped_young": res.SkippedYoung, }) } // GET /api/v1/admin/storage/derived – Platzbedarf der abgeleiteten Daten. // // Getrennt vom Aufräumen, weil es eine Auskunft ist und keine Handlung: Man // soll nachsehen können, ohne etwas anzustoßen. func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) { if _, ok := requireAdmin(r); !ok { response.Error(w, http.StatusForbidden, "admin required") return } if h.gc == nil { response.Error(w, http.StatusServiceUnavailable, "not available") return } usage, err := h.gc.Derived(r.Context()) if err != nil { response.Error(w, http.StatusInternalServerError, "could not read storage") return } response.JSON(w, http.StatusOK, usage) } // POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank // abgleichen. // // Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag // gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer // Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in // Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten. // // Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas. // Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was // passieren wird, kann es auch nicht verhindern. func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) { if _, ok := requireAdmin(r); !ok { response.Error(w, http.StatusForbidden, "admin required") return } if h.rescan == nil { response.Error(w, http.StatusServiceUnavailable, "rescan not available") return } anwenden := r.URL.Query().Get("anwenden") == "true" // Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen // soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint // aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen. bestaetigt := r.URL.Query().Get("bestaetigt") == "true" var ( bef *rescan.Report err error ) if anwenden { bef, err = h.rescan.Apply(r.Context(), bestaetigt) } else { bef, err = h.rescan.Check(r.Context()) } if err != nil { response.Error(w, http.StatusInternalServerError, "rescan failed") return } if anwenden { // Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein // ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit // dem Datenordner etwas nicht stimmt. if bef.Aborted { h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", "rescan aborted, nothing removed: "+bef.AbortReason) } else { h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors", bef.Adopted, bef.Corrected, bef.Removed, bef.Errors)) } } response.JSON(w, http.StatusOK, bef) }