Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.
Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:
Datei da, kein Eintrag Eintrag anlegen, samt Ordnerkette,
Pruefsumme und Dateityp
Eintrag da, Datei fort Eintrag entfernen, dazu Vorschaubild und
fruehere Fassungen
von aussen ersetzt Groesse und Pruefsumme uebernehmen
Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.
Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.
Zwei Fallen, die erst der Praxistest zeigte:
- Der Dateityp muss aus Endung *oder Inhalt* kommen. Beim Hochladen
schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt
niemanden mit, der ihn kennt. Dazu hat das Alpine-Abbild keine
/etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
jede Textdatei waere als application/octet-stream gelandet.
- COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
Text und bricht mit "operator does not exist: uuid = text" ab. Die
Ordnerkette wurde nie angelegt.
Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.
scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
4.9 KiB
Go
166 lines
4.9 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"
|
||
var (
|
||
bef *rescan.Report
|
||
err error
|
||
)
|
||
if anwenden {
|
||
bef, err = h.rescan.Apply(r.Context())
|
||
} else {
|
||
bef, err = h.rescan.Check(r.Context())
|
||
}
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "rescan failed")
|
||
return
|
||
}
|
||
|
||
if anwenden {
|
||
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)
|
||
}
|