mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): 1 defect(s) (OC-0201)
* fix(service): 1 defect(s) (OC-0202)
HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.
* fix(client): 2 defect(s) (OC-0203, OC-0224)
* fix(server): 1 defect(s) (OC-0204)
* fix(ws): 2 defect(s) (OC-0205, OC-0211)
* fix(admin): 2 defect(s) (OC-0209, OC-0212)
* fix(client): 1 defect(s) (OC-0210)
* fix(db): 1 defect(s) (OC-0213)
* fix(ws): 1 defect(s) (OC-0214)
Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.
* fix(admin): 1 defect(s) (OC-0215)
PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.
* fix(db): 1 defect(s) (OC-0216)
LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.
* fix(emoji): 1 defect(s) (OC-0217)
* fix(client): 1 defect(s) (OC-0218)
The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.
Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.
Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0219)
* fix(client): 1 defect(s) (OC-0221)
UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().
* fix(dm): 1 defect(s) (OC-0222)
* fix(client): 1 defect(s) (OC-0223)
* fix(voice): 1 defect(s) (OC-0225)
The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().
* fix(admin): 1 defect(s) (OC-0226)
handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.
* fix(admin): 1 defect(s) (OC-0227)
PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.
* fix(identity): 1 defect(s) (OC-0228)
* fix(admin): run deferred cleanup before the update restart exits
The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.
applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.
Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* test(ws): pin the live presence path against the invisible custom-status leak
OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.
This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0206)
* test(ws): silence a contextcheck false positive in the reconnect race test
RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.
golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
---------
Co-authored-by: Claude <noreply@anthropic.com>
293 lines
11 KiB
Go
293 lines
11 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
|
|
}
|
|
|
|
// Authorize the role change before applying anything else. Without
|
|
// this pre-flight, a PATCH combining banned + role_id would commit
|
|
// and broadcast the ban first and only then attempt the role change:
|
|
// if that role change was then refused (missing MANAGE_ROLES, or the
|
|
// new role outranks the actor), the handler reported the whole
|
|
// request as failed while the target was in fact banned, audited,
|
|
// and already dropped from every connected client's member list
|
|
// (OC-0215). Running every ChangeUserRole precondition up front,
|
|
// before either mutation lands, keeps the PATCH all-or-nothing from
|
|
// the caller's perspective.
|
|
if req.RoleID != nil {
|
|
if mod == nil {
|
|
// Fail closed rather than fall back to an unchecked UPDATE.
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable")
|
|
return
|
|
}
|
|
if _, _, _, err := mod.AuthorizeRoleChange(r.Context(), actor, id, *req.RoleID); err != nil {
|
|
writeModerationErr(w, err)
|
|
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
|
|
// role change, if requested, was already authorized above, so a ban
|
|
// committing here cannot be followed by a refused role change leaving
|
|
// 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 re-runs the same
|
|
// MANAGE_ROLES, actor-outranks-target, and assign-below-own-rank
|
|
// checks the AuthorizeRoleChange pre-flight above already passed
|
|
// (a second pass, not a redundant one: it catches anything that
|
|
// changed in the window between the pre-flight and here, e.g. a
|
|
// concurrent role delete), then commits 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,
|
|
})
|
|
}
|
|
}
|