Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert sich nichts. Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus, sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines Dokuments, für das man OnlyOffice gewählt hat. Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung nicht im Namen, eine neue Fassung überschreibt. files.preview_version sagt, ob das Abgelegte noch passt. Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu, zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung 401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine Fehlerseite im Zwischenspeicher. Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz: erster Abruf 1,05 s -> zweiter 0,009 s erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei GC: herrenloses PDF von vorgestern weg, echtes bleibt endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415 Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten (Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent – ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille Überraschung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
3.5 KiB
Go
121 lines
3.5 KiB
Go
package admin
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"aether/internal/audit"
|
||
"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)
|
||
}
|