Files
Aether/backend/internal/admin/audit_handler.go
T
marcandClaude Opus 5 9c5cda75d7 feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten
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>
2026-08-31 23:30:49 +02:00

121 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}