Files
Aether/backend/internal/admin/handler.go
T
marcandClaude Opus 5 55dcf517f2 feat(admin): Abgleich zwischen Platte und Datenbank
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>
2026-09-07 01:10:25 +02:00

493 lines
16 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 (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/gc"
"aether/internal/rescan"
"aether/internal/storage"
"aether/pkg/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
storage storage.Storage
auditor *audit.Logger
gc *gc.Collector
files *files.Store
rescan *rescan.Scanner
}
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store, scanner *rescan.Scanner) *Handler {
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore, rescan: scanner}
}
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
// Wiederkehrende (Handelnder, IP), damit die Aufrufstellen kurz bleiben.
func (h *Handler) logAdmin(r *http.Request, action, targetType, targetID, targetName, detail string) {
actor := auth.UserFromContext(r.Context())
e := audit.Event{
Action: action,
TargetType: targetType,
TargetID: targetID,
TargetName: targetName,
Detail: detail,
IP: middleware.RealIP(r),
}
if actor != nil {
e.ActorID = &actor.ID
e.ActorName = actor.Username
}
h.auditor.Log(r.Context(), e)
}
// requireAdmin is called at the start of every handler — server-side check.
func requireAdmin(r *http.Request) (*auth.User, bool) {
user := auth.UserFromContext(r.Context())
return user, user != nil && user.IsAdmin
}
// ── Users ─────────────────────────────────────────────────────────────────────
// GET /api/v1/admin/users
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
users, err := h.store.ListUsers(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, users)
}
// POST /api/v1/admin/users
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(body.Username) == "" || strings.TrimSpace(body.Password) == "" {
response.Error(w, http.StatusBadRequest, "username and password required")
return
}
if body.QuotaBytes <= 0 {
body.QuotaBytes = 10 << 30 // 10 GB default
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
user, err := h.store.CreateUser(r.Context(), strings.TrimSpace(body.Username), body.DisplayName, hash, body.IsAdmin, body.QuotaBytes)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "username already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionUserCreate, "user", user.ID.String(), user.Username,
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
response.JSON(w, http.StatusCreated, user)
}
// PATCH /api/v1/admin/users/{id}
func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
caller, ok := requireAdmin(r)
if !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
var body struct {
DisplayName string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if body.QuotaBytes <= 0 {
body.QuotaBytes = 10 << 30
}
user, err := h.store.UpdateUser(r.Context(), id, body.DisplayName, body.IsAdmin, body.QuotaBytes, caller.ID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
}
if errors.Is(err, ErrSelfDemote) {
response.Error(w, http.StatusBadRequest, "cannot remove your own admin status")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionUserUpdate, "user", user.ID.String(), user.Username,
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
response.JSON(w, http.StatusOK, user)
}
// POST /api/v1/admin/users/{id}/reset-password
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
// Accept explicit password or generate a random one.
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
newPassword := strings.TrimSpace(body.Password)
if newPassword == "" {
newPassword = randomPassword()
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
if err := h.store.ResetPassword(r.Context(), id, hash); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Protokolliert wird, DASS zurückgesetzt wurde – niemals das Passwort selbst.
h.logAdmin(r, audit.ActionUserPasswordReset, "user", id.String(), "", "")
// Return the new plain-text password to the admin (no email in this system).
response.JSON(w, http.StatusOK, map[string]string{"new_password": newPassword})
}
// DELETE /api/v1/admin/users/{id}
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
caller, ok := requireAdmin(r)
if !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
// Den Namen vor dem Löschen holen: danach lässt sich der Speicherbereich
// des Kontos nicht mehr ermitteln.
victim, err := h.store.GetUser(r.Context(), id)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if err := h.store.DeleteUser(r.Context(), id, caller.ID); errors.Is(err, ErrSelfDemote) {
response.Error(w, http.StatusBadRequest, "cannot delete your own account")
return
} else if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Erst die Datenbank, dann die Dateien: Schlägt das Aufräumen fehl, bleiben
// höchstens Dateileichen zurück – umgekehrt hätte ein Konto ohne seine
// Dateien überlebt. Die Datenbankzeilen sind per Fremdschlüssel-Kaskade weg,
// es gibt also keinen Verweis mehr auf diese Blobs.
if victim.Username != "" {
if err := h.storage.DeletePrefix(r.Context(), "users/"+victim.Username); err != nil {
log.Ctx(r.Context()).Error().Err(err).
Str("username", victim.Username).
Msg("user deleted but their files could not be removed")
}
}
h.logAdmin(r, audit.ActionUserDelete, "user", id.String(), victim.Username,
"account and all files removed")
w.WriteHeader(http.StatusNoContent)
}
// ── Groups ────────────────────────────────────────────────────────────────────
// GET /api/v1/admin/groups
func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groups, err := h.store.ListGroups(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, groups)
}
// POST /api/v1/admin/groups
func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
group, err := h.store.CreateGroup(r.Context(), strings.TrimSpace(body.Name), body.Description)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "group name already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupCreate, "group", group.ID.String(), group.Name, "")
response.JSON(w, http.StatusCreated, group)
}
// PATCH /api/v1/admin/groups/{id}
func (h *Handler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
group, err := h.store.UpdateGroup(r.Context(), id, strings.TrimSpace(body.Name), body.Description)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "group not found")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "group name already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupUpdate, "group", group.ID.String(), group.Name, "")
response.JSON(w, http.StatusOK, group)
}
// DELETE /api/v1/admin/groups/{id}
func (h *Handler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
if err := h.store.DeleteGroup(r.Context(), id); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "group not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupDelete, "group", id.String(), "", "")
w.WriteHeader(http.StatusNoContent)
}
// GET /api/v1/admin/groups/{id}/members
func (h *Handler) ListGroupMembers(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
members, err := h.store.ListGroupMembers(r.Context(), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, members)
}
// POST /api/v1/admin/groups/{id}/members
func (h *Handler) AddGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
var body struct {
UserID uuid.UUID `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.UserID == uuid.Nil {
response.Error(w, http.StatusBadRequest, "user_id required")
return
}
if err := h.store.AddGroupMember(r.Context(), groupID, body.UserID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupMemberAdd, "group", groupID.String(), "",
"user "+body.UserID.String())
w.WriteHeader(http.StatusNoContent)
}
// DELETE /api/v1/admin/groups/{id}/members/{userId}
func (h *Handler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
userID, err := uuid.Parse(chi.URLParam(r, "userId"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
if err := h.store.RemoveGroupMember(r.Context(), groupID, userID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupMemberRemove, "group", groupID.String(), "",
"user "+userID.String())
w.WriteHeader(http.StatusNoContent)
}
// ── Settings ──────────────────────────────────────────────────────────────────
// GET /api/v1/admin/settings
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) {
// Settings are readable by all authenticated users (needed for theming).
if auth.UserFromContext(r.Context()) == nil {
response.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
settings, err := h.store.GetAllSettings(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, settings)
}
// PATCH /api/v1/admin/settings
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
allowed := map[string]bool{
"cloud_name": true, "primary_color": true, "accent_color": true,
}
changed := []string{}
for k, v := range body {
if !allowed[k] {
continue
}
if err := h.store.SetSetting(r.Context(), k, v); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
changed = append(changed, k+"="+v)
}
if len(changed) > 0 {
sort.Strings(changed) // Map-Reihenfolge ist zufällig – sonst wäre das Protokoll unruhig.
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", strings.Join(changed, ", "))
}
settings, _ := h.store.GetAllSettings(r.Context())
response.JSON(w, http.StatusOK, settings)
}
// ── helpers ───────────────────────────────────────────────────────────────────
func randomPassword() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}