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/storage" "aether/pkg/middleware" "aether/pkg/response" ) type Handler struct { store *Store storage storage.Storage auditor *audit.Logger gc *gc.Collector files *files.Store } func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler { return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore} } // 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) }