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>
491 lines
16 KiB
Go
491 lines
16 KiB
Go
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)
|
||
}
|