internal/rescan ist das einzige Stück Aether, das hart löscht: DELETE FROM files, kein Papierkorb. Entfernt wird, was beim Durchgang über die Platte nicht gesehen wurde. Der Ernstfall ist nicht ausgedacht. LocalFS.Walk gibt für ein nicht vorhandenes Verzeichnis nil zurück – kein Fehler, keine Einträge. Genau der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam, oder eines Tippfehlers in DATA_PATH. Dann gilt jede Datei als verwaist, und ein einziger Aufruf mit ?anwenden=true löscht den gesamten Bestand aus der Datenbank. Die Bytes lägen noch da, aber niemand wüsste mehr, dass es sie gibt – und der GC räumte sie 24 Stunden später ab. Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC löscht dann Blobs – die Datenbank weiss hinterher noch, was fehlt. Hier fällt die Zeile selbst. Die gefährlichere Seite war die ungesicherte. Zwei Bremsen: Die Notbremse (nichts gefunden, Datenbank kennt Dateien) lässt sich nicht übersteuern. Die Mengenbremse (mehr als die Hälfte) verlangt &bestaetigt=true. Die leere Installation ist ausgenommen. Dazu umgebaut: erst sammeln, dann entscheiden, dann löschen. Vorher wurde mitten in der Schleife gelöscht; die Gesamtzahl kennt man dort erst, wenn die Hälfte schon weg ist. Der Bericht nennt den Grund und ob er bestätigbar ist – die Oberfläche soll keinen Knopf anbieten, der nichts bewirkt. Ein abgebrochener Lauf steht rot in der Karte und im Audit-Log; gerade der, denn er ist der Hinweis darauf, dass mit dem Datenordner etwas nicht stimmt. Fünf Tests, unter Mutation nachgewiesen. Der Ernstfall läuft am echten Gerät: fünf Zeilen in der Datenbank, ein Wurzelverzeichnis ohne users/ – nach Apply stehen alle fünf noch, auch mit Bestätigung. audit und thumbnails bekommen bewusst keine Go-Tests; die Begründung steht in BEFUNDE.md statt als ewig offener Punkt. Nebenbefund behoben: kalender-teilen-selbsttest.py leckte je Lauf ein App-Passwort des Besitzers. Nach achtzehn Läufen war maxAppPasswords = 20 erreicht, und zwei Suiten scheiterten mit 409, ohne dass am Code etwas kaputt war. Geprüft: go build/vet sauber, alle Go-Pakete grün, 243 Prüfungen der sieben Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
5.5 KiB
Go
178 lines
5.5 KiB
Go
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)
|
||
}
|