Files
Aether/backend/internal/admin/handler.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

491 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/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)
}