package admin import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "github.com/owncord/server/db" "github.com/owncord/server/service" ) // ─── User Handlers ─────────────────────────────────────────────────────────── func handleGetStats(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { stats, err := database.GetServerStats() if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats") return } if hub != nil { stats.OnlineCount = hub.ClientCount() } writeJSON(w, http.StatusOK, stats) } } func handleListUsers(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { limit := queryInt(r, "limit", 50, 1) offset := queryInt(r, "offset", 0, 0) users, err := database.ListAllUsers(limit, offset) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users") return } safe := make([]adminUserResponse, len(users)) for i := range users { safe[i] = toAdminUserResponse(users[i]) } writeJSON(w, http.StatusOK, safe) } } // patchUserRequest is the JSON body for PATCH /admin/api/users/{id}. type patchUserRequest struct { RoleID *int64 `json:"role_id"` Banned *bool `json:"banned"` BanReason *string `json:"ban_reason"` } // writeModerationErr maps ModerationService errors onto admin API responses. func writeModerationErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, service.ErrForbidden): writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) case errors.Is(err, service.ErrNotFound): writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found") case errors.Is(err, service.ErrBadRequest): writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) default: writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation action failed") } } func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := pathInt64(r, "id") if err != nil { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id") return } var req patchUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") return } user, err := database.GetUserByID(id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user") return } if user == nil { writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found") return } actor := actorFromContext(r) // Prevent admins from modifying their own role or ban status, which // could lock them out of the admin panel with no recovery path. if id == actor { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "cannot modify your own account via admin panel") return } // Ban/unban first: it routes through ModerationService, which enforces // BAN_MEMBERS + role hierarchy (the admin-auth perimeter alone does // not — any admin-panel actor could previously ban the owner). The // service also audits and refuses before the role change runs, so a // rejected ban never leaves a half-applied PATCH behind. if req.Banned != nil { if mod == nil { // Fail closed rather than fall back to an unchecked UPDATE. writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable") return } banReason := "" if req.BanReason != nil { banReason = *req.BanReason } var actionErr error if *req.Banned { actionErr = mod.BanUser(r.Context(), actor, id, banReason, nil) } else { actionErr = mod.UnbanUser(r.Context(), actor, id) } if actionErr != nil { writeModerationErr(w, actionErr) return } if *req.Banned && hub != nil { hub.BroadcastMemberBan(id) } } if req.RoleID != nil { if _, err := database.Exec(`UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role") return } slog.Info("role changed", "actor_id", actor, "target_user", user.Username, "new_role_id", *req.RoleID) if permInvalidator != nil { permInvalidator.InvalidateUser(id) } db.WriteAudit(database, actor, "role_change", "user", id, fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID)) if role, err := database.GetRoleByID(*req.RoleID); err == nil && role != nil { if hub != nil { hub.BroadcastMemberUpdate(id, role.Name) } } } updated, err := database.GetUserByID(id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user") return } writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(database, updated)) } } func handleForceLogout(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := pathInt64(r, "id") if err != nil { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id") return } if err := database.ForceLogoutUser(id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user") return } actor := actorFromContext(r) slog.Info("force logout", "actor_id", actor, "target_user_id", id) db.WriteAudit(database, actor, "force_logout", "user", id, "all sessions terminated") w.WriteHeader(http.StatusNoContent) } }