mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020) * fix(db): 1 defect(s) (OC-0096) * fix(admin): 1 defect(s) (OC-0097) * fix(auth): 2 defect(s) (OC-0099, OC-0021) * fix(voice): 1 defect(s) (OC-0018) * fix(admin): 1 defect(s) (OC-0045) * fix(api): 1 defect(s) (OC-0103) * fix(client): 1 defect(s) (OC-0105) * fix(client): 1 defect(s) (OC-0107) * fix(api): 1 defect(s) (OC-0109) * fix(api): 1 defect(s) (OC-0112) * test(admin): compare restore bytes with bytes.Equal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0095, OC-0014) OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext. OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token. * fix(profile): 2 defect(s) (OC-0100, OC-0102) * fix(service): 1 defect(s) (OC-0022) Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate. * fix(api): 1 defect(s) (OC-0048) * chore(workflows): correct stale model labels in bughunt-fix phase details Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(client): 1 defect(s) (OC-0015) * fix(voice): 1 defect(s) (OC-0002) * test: fix two CI-only failures in the batch-4 test suite The delete-account broadcast test now observes member_ban on a second client's socket: the hub broadcasts and then force-disconnects the target, so on a slow runner the close could beat the target's own copy of the frame. The observer is also the party the event exists for. The voice e2e mock now echoes the real joined channel id on voice_leave (it hardcoded channel_id 0, which the dispatcher's channel-matched self-leave teardown correctly ignores), and the rejoin test waits for the mock's delayed echoes to settle before clicking the row again — clicking inside the echo window toggled a leave instead of a join. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
268 lines
9.7 KiB
Go
268 lines
9.7 KiB
Go
package admin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"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(r.Context())
|
|
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, 500)
|
|
offset := queryInt(r, "offset", 0, 0, math.MaxInt32)
|
|
|
|
users, err := database.ListAllUsers(r.Context(), 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"`
|
|
// BanDurationHours makes the ban temporary: it expires this many hours
|
|
// from now (login re-checks via IsEffectivelyBanned). Omitted or 0 =
|
|
// permanent. Only meaningful with banned=true.
|
|
BanDurationHours *int `json:"ban_duration_hours"`
|
|
}
|
|
|
|
// maxBanDurationHours caps temporary bans at one year; anything longer is
|
|
// effectively permanent and should be issued as such.
|
|
const maxBanDurationHours = 24 * 365
|
|
|
|
// memberUnbanBroadcaster is an optional capability of HubBroadcaster: tell
|
|
// every connected client a user is back in the roster after an unban, the
|
|
// mirror of BroadcastMemberBan. It is checked with a type assertion instead
|
|
// of being added to HubBroadcaster directly (admin/types.go, not owned by
|
|
// this change) so this fix does not force every HubBroadcaster
|
|
// implementation — production and test doubles alike — to gain the method
|
|
// before it compiles. See the batch report's cross_batch note: *ws.Hub needs
|
|
// BroadcastMemberUnban(userID int64) wired up for this to take effect at
|
|
// runtime; until then the assertion below simply misses and the handler's
|
|
// existing (pre-fix) behavior is unchanged.
|
|
type memberUnbanBroadcaster interface {
|
|
BroadcastMemberUnban(userID int64)
|
|
}
|
|
|
|
// 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(r.Context(), 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 banExpires *time.Time
|
|
if req.BanDurationHours != nil && *req.BanDurationHours != 0 {
|
|
hours := *req.BanDurationHours
|
|
if hours < 0 || hours > maxBanDurationHours {
|
|
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "ban_duration_hours must be between 1 and 8760")
|
|
return
|
|
}
|
|
t := time.Now().Add(time.Duration(hours) * time.Hour)
|
|
banExpires = &t
|
|
}
|
|
var actionErr error
|
|
if *req.Banned {
|
|
actionErr = mod.BanUser(r.Context(), actor, id, banReason, banExpires)
|
|
} else {
|
|
actionErr = mod.UnbanUser(r.Context(), actor, id)
|
|
}
|
|
if actionErr != nil {
|
|
writeModerationErr(w, actionErr)
|
|
return
|
|
}
|
|
switch {
|
|
case *req.Banned && hub != nil:
|
|
hub.BroadcastMemberBan(id)
|
|
case !*req.Banned && hub != nil:
|
|
// Ban had no WS event on the way out (member_ban hard-deletes
|
|
// the row client-side); unban needs one on the way back in, or
|
|
// every already-connected client keeps the user missing from
|
|
// its member store while a freshly connecting client sees them.
|
|
if mub, ok := hub.(memberUnbanBroadcaster); ok {
|
|
mub.BroadcastMemberUnban(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.RoleID != nil {
|
|
// Routed through ModerationService, which enforces MANAGE_ROLES,
|
|
// the actor-outranks-target rule, and the assign-below-own-rank
|
|
// rule (without it any admin could promote anyone to Owner), and
|
|
// writes the audit row.
|
|
if mod == nil {
|
|
// Fail closed rather than fall back to an unchecked UPDATE.
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable")
|
|
return
|
|
}
|
|
newRole, err := mod.ChangeUserRole(r.Context(), actor, id, *req.RoleID)
|
|
if err != nil {
|
|
writeModerationErr(w, err)
|
|
return
|
|
}
|
|
if permInvalidator != nil {
|
|
permInvalidator.InvalidateUser(id)
|
|
}
|
|
// Use the role ChangeUserRole already loaded and validated rather
|
|
// than re-reading it: a re-read can race a concurrent role delete
|
|
// (or a transient read error) and silently skip this whole
|
|
// fan-out, leaving the demoted user's socket subscribed to
|
|
// channels it can no longer read (OC-0045). The role change
|
|
// itself already committed, so the fan-out must not be
|
|
// conditional on anything past that point.
|
|
if hub != nil {
|
|
hub.BroadcastMemberUpdate(id, newRole.Name)
|
|
// BroadcastMemberUpdate only revokes subscriptions the new
|
|
// role can no longer read (hub_broadcast.go's
|
|
// revokeUnreadableChannels); it never grants the ones the
|
|
// new role newly gained READ_MESSAGES on. Without this,
|
|
// a promoted user's sidebar is missing channels until
|
|
// their next reconnect, unlike a role permission edit or
|
|
// a role delete, which both re-derive visibility fully.
|
|
hub.RefreshAllChannelVisibility()
|
|
}
|
|
}
|
|
|
|
updated, err := database.GetUserByID(r.Context(), id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(r.Context(), database, updated))
|
|
}
|
|
}
|
|
|
|
// handleForceLogout revokes every session of the target user. The route is
|
|
// gated on KICK_MEMBERS; ModerationService additionally enforces the
|
|
// actor-outranks-target hierarchy and writes the audit row.
|
|
func handleForceLogout(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
|
|
}
|
|
if mod == nil {
|
|
// Fail closed rather than cut sessions without a hierarchy check.
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable")
|
|
return
|
|
}
|
|
|
|
if err := mod.ForceLogout(r.Context(), actorFromContext(r), id); err != nil {
|
|
writeModerationErr(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleGetMe describes the calling principal so the admin panel can hide the
|
|
// surfaces its role cannot use. Perimeter-level: every authenticated principal
|
|
// may read its own permissions.
|
|
func handleGetMe() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, userOK := r.Context().Value(adminUserKey).(*db.User)
|
|
role, roleOK := r.Context().Value(adminRoleKey).(*db.Role)
|
|
if !userOK || user == nil || !roleOK || role == nil {
|
|
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, adminMeResponse{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
RoleID: role.ID,
|
|
RoleName: role.Name,
|
|
RolePosition: role.Position,
|
|
Permissions: role.Permissions,
|
|
IsOwner: role.Position >= permissions.OwnerRolePosition,
|
|
})
|
|
}
|
|
}
|