Files
OwnCord/Server/api/dm_handler.go
T
J3vbandClaude Fable 5 f1a673e87e fix: 35 findings from the 2026-08-22 bug hunt (#1402)
* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* style(client): prettier-format the dm-store test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): record the 2026-08-21 hunt findings as fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-22 06:34:58 +02:00

487 lines
18 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
"github.com/owncord/server/ws"
)
// DMBroadcaster is the interface needed to send WebSocket events from REST
// handlers. Satisfied by *ws.Hub.
type DMBroadcaster interface {
SendToUser(userID int64, msg []byte) bool
}
// dmVisibilityMarker is an optional DMBroadcaster capability: bump the hub's
// visibility watermark after an unsequenced, targeted event so a client that
// warm-reconnects across the gap takes the full-ready path instead of a
// sequenced-only replay that can never redeliver it. Reached by type
// assertion rather than being added to DMBroadcaster directly so the
// SendToUser-only test doubles in this package keep working. Satisfied in
// production by ws.Hub.MarkVisibilityChanged, which forwards to the same
// bumpVisibilityWatermark the WS-side dm_channel_open emitter uses
// (ws/emit.go).
type dmVisibilityMarker interface {
MarkVisibilityChanged()
}
// The production broadcaster must keep satisfying it: a type assertion that
// silently stops matching would turn the watermark bump back into the no-op
// this fixed, with nothing failing to say so — mirrors the dmVoiceEvictor
// assertion below for its sibling capability.
var _ dmVisibilityMarker = (*ws.Hub)(nil)
// markDMVisibilityChanged bumps the visibility watermark if broadcaster
// supports it. dm_channel_open/close are unsequenced and targeted, so a
// client that misses one via a dropped connection and then warm-reconnects
// never gets it redelivered by the ordinary seq-replay path — mirroring why
// the WS emitter of the same event (ws/emit.go DMChannelOpenEvent) forces
// this bump unconditionally, regardless of whether the send itself
// succeeded.
func markDMVisibilityChanged(broadcaster DMBroadcaster) {
if vm, ok := broadcaster.(dmVisibilityMarker); ok {
vm.MarkVisibilityChanged()
}
}
// dmVoiceEvictor is the DMBroadcaster capability used to evict a user's
// voice-call connection for one specific channel, leaving an unrelated call
// they may currently be in untouched (which the unconditional
// DisconnectFromVoice would not). It is kept out of DMBroadcaster itself and
// reached by type assertion so the handler stays usable with the
// SendToUser-only test doubles the package already has.
type dmVoiceEvictor interface {
DisconnectFromVoiceInChannel(ctx context.Context, userID, channelID int64) bool
}
// The production broadcaster must keep satisfying it: a type assertion that
// silently stops matching would turn the eviction back into the no-op this
// fixed, with nothing failing to say so.
var _ dmVoiceEvictor = (*ws.Hub)(nil)
// MountDMRoutes registers DM-related routes onto r.
// All routes require authentication.
// hub is used to send real-time WebSocket events on DM close.
func MountDMRoutes(r chi.Router, database *db.DB, svc *service.Services, broadcaster DMBroadcaster) {
r.Route("/api/v1/dms", func(r chi.Router) {
r.Use(AuthMiddleware(database))
r.Post("/", handleCreateDM(svc, broadcaster))
r.Post("/group", handleCreateGroupDM(svc, broadcaster))
r.Get("/", handleListDMs(svc))
r.Patch("/{channelId}", handleRenameGroupDM(svc, broadcaster))
r.Delete("/{channelId}", handleCloseDM(svc, broadcaster))
})
// User blocking routes — prevent DM creation and messaging.
r.Route("/api/v1/blocks", func(r chi.Router) {
r.Use(AuthMiddleware(database))
r.Get("/", handleListBlocks(svc))
r.Put("/{userId}", handleBlockUser(svc, broadcaster))
r.Delete("/{userId}", handleUnblockUser(svc))
})
}
// createDMRequest is the JSON body for POST /api/v1/dms.
type createDMRequest struct {
RecipientID int64 `json:"recipient_id"`
}
// createDMResponse is the JSON response for POST /api/v1/dms.
type createDMResponse struct {
ChannelID int64 `json:"channel_id"`
Recipient db.DMUser `json:"recipient"`
Created bool `json:"created"`
}
// createGroupDMRequest is the JSON body for POST /api/v1/dms/group.
type createGroupDMRequest struct {
RecipientIDs []int64 `json:"recipient_ids"`
Name string `json:"name"`
}
// renameDMRequest is the JSON body for PATCH /api/v1/dms/{channelId}.
type renameDMRequest struct {
Name string `json:"name"`
}
// listDMsResponse is the JSON response for GET /api/v1/dms.
type listDMsResponse struct {
DMChannels []db.DMChannelInfo `json:"dm_channels"`
}
// handleCreateDM creates or retrieves a DM channel with a recipient.
func handleCreateDM(svc *service.Services, broadcaster DMBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Message: "authentication required",
})
return
}
var req createDMRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST", Message: "invalid request body",
})
return
}
result, err := svc.DMs.CreateDM(r.Context(), user.ID, req.RecipientID)
if err != nil {
writeServiceError(r.Context(), w, err)
return
}
// A brand-new 1:1 DM has dm_open_state pre-seeded for BOTH users by
// GetOrCreateDMChannel (db/dm_queries.go), so the recipient's first
// OpenDM call — fired later from the sender's first message — finds
// the row already present and reports opened=false. Without this,
// nothing ever tells the recipient the DM exists: no live event, and
// no visibility-watermark bump for a warm reconnect either. Only the
// creation path needs this — CreateDM re-opening an existing DM for
// the caller only touches the caller's own dm_open_state row, which
// the caller obviously already knows about.
if result.Created {
broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, []int64{result.Recipient.ID})
}
avatarStr := ""
if result.Recipient.Avatar != nil {
avatarStr = *result.Recipient.Avatar
}
displayName := ""
if result.Recipient.DisplayName != nil {
displayName = *result.Recipient.DisplayName
}
// PresentableStatus applies the "no live connection is offline,
// whatever the row says" half of the rule ws/serve_ready.go's
// presentableMembers documents — StatusForViewer alone only
// collapses invisible to offline and would otherwise ship a
// disconnected recipient's saved idle/dnd verbatim (OC-0304).
dmUser := db.DMUser{
ID: result.Recipient.ID,
Username: result.Recipient.Username,
Avatar: avatarStr,
Status: svc.DMs.PresentableStatus(result.Recipient.ID, db.StatusForViewer(result.Recipient.Status, result.Recipient.ID, user.ID)),
DisplayName: displayName,
}
status := http.StatusOK
if result.Created {
status = http.StatusCreated
}
writeJSON(w, status, createDMResponse{
ChannelID: result.Channel.ID,
Recipient: dmUser,
Created: result.Created,
})
}
}
// handleListDMs returns all open DM channels for the authenticated user.
func handleListDMs(svc *service.Services) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Message: "authentication required",
})
return
}
channels, err := svc.DMs.ListDMs(r.Context(), user.ID)
if err != nil {
writeServiceError(r.Context(), w, err)
return
}
writeJSON(w, http.StatusOK, listDMsResponse{DMChannels: channels})
}
}
// handleCloseDM removes a DM channel from the authenticated user's open list.
func handleCloseDM(svc *service.Services, broadcaster DMBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Message: "authentication required",
})
return
}
channelID, ok := parseIDParam(w, r, "channelId")
if !ok {
return
}
result, err := svc.DMs.CloseDM(r.Context(), user.ID, channelID)
if err != nil {
writeServiceError(r.Context(), w, err)
return
}
// Notify via WebSocket so sidebar updates immediately.
if broadcaster != nil {
closeMsg := fmt.Appendf(nil, `{"type":%q,"payload":{"channel_id":%d}}`, ws.MsgTypeDMChannelClose, channelID)
if ok := broadcaster.SendToUser(user.ID, closeMsg); !ok {
slog.Debug("handleCloseDM: user not connected", "user_id", user.ID, "channel_id", channelID)
}
// dm_channel_close is unsequenced and targeted like
// dm_channel_open — see markDMVisibilityChanged.
markDMVisibilityChanged(broadcaster)
// A group leave changes the membership everyone else renders, so
// the survivors get a refreshed dm_channel_open rather than being
// left showing a member who has gone.
if result.Left && !result.ChannelDeleted {
broadcastDMOpen(r.Context(), svc, broadcaster, channelID, result.RemainingParticipantIDs)
}
// Leaving a group DM removes the caller from its membership but,
// without this, leaves them connected to its live voice call —
// they keep hearing and speaking to a room they are no longer a
// member of. Scoped to this channel so a leaver currently on an
// unrelated voice call is untouched. This also covers the
// last-participant case (ChannelDeleted): the row is already gone
// by now, so CleanupVoiceForChannel would read an FK-cascaded
// empty voice_states and do nothing, while the leaver — the only
// participant left — is evicted here.
if result.Left {
if ve, ok := broadcaster.(dmVoiceEvictor); ok {
ve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), user.ID, channelID)
}
}
}
w.WriteHeader(http.StatusNoContent)
}
}
// broadcastDMOpen sends a per-viewer dm_channel_open for channelID to each of
// targetIDs. The payload differs per addressee (`recipient`/`recipients` are
// relative to who is reading), so it is rebuilt inside the loop.
//
// Failures are logged and skipped, never surfaced: the mutation that prompted
// this has already committed, and a client that misses the event re-derives
// the same state from its next `ready`.
func broadcastDMOpen(ctx context.Context, svc *service.Services, broadcaster DMBroadcaster, channelID int64, targetIDs []int64) {
if broadcaster == nil || len(targetIDs) == 0 {
return
}
// The mutation that led here has already committed, so this fan-out must
// survive the caller's request context being cancelled after that point
// (client disconnect mid-handler) — otherwise every DMSummaryFor lookup
// below fails with context.Canceled and no participant, including ones
// otherwise unaffected by the cancellation, ever receives the open.
ctx = context.WithoutCancel(ctx)
// dm_channel_open is unsequenced and targeted — a recipient who is
// offline or drops the connection right now can never have it replayed
// to them by the ordinary seq-based resume path, so a warm reconnect must
// be forced onto the full-ready path instead. Bumped once per call,
// unconditionally (not per-recipient SendToUser result): the ws emitter
// of this same event does the same (ws/emit.go), and this covers every
// caller — group create, rename refresh, and the group-leave refresh.
markDMVisibilityChanged(broadcaster)
for _, pid := range targetIDs {
summary, pErr := svc.DMs.DMSummaryFor(ctx, pid, channelID)
if pErr != nil {
slog.Debug("broadcastDMOpen: summary unavailable", "user_id", pid, "channel_id", channelID, "err", pErr)
continue
}
msg, mErr := json.Marshal(map[string]any{
"type": "dm_channel_open",
"payload": summary,
})
if mErr != nil {
slog.Warn("broadcastDMOpen: marshal failed", "err", mErr, "channel_id", channelID)
continue
}
if ok := broadcaster.SendToUser(pid, msg); !ok {
slog.Debug("broadcastDMOpen: user not connected", "user_id", pid, "channel_id", channelID)
}
}
}
// handleCreateGroupDM creates a group DM between the caller and 2..8 others.
func handleCreateGroupDM(svc *service.Services, broadcaster DMBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Message: "authentication required",
})
return
}
var req createGroupDMRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST", Message: "invalid request body",
})
return
}
result, err := svc.DMs.CreateGroupDM(r.Context(), user.ID, req.RecipientIDs, req.Name)
if err != nil {
writeServiceError(r.Context(), w, err)
return
}
// Everyone gets the DM in their sidebar immediately, the creator
// included — the REST response is only the creator's copy, and a
// second window of theirs needs the event just as much as the others.
broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, result.ParticipantIDs)
writeJSON(w, http.StatusCreated,
db.NewDMChannelInfo(result.Channel.ID, result.Channel.Name, true, result.Participants, user.ID))
}
}
// handleRenameGroupDM sets or clears a group DM's name. Participants only —
// there is no owner, so every member holds the same authority over it.
func handleRenameGroupDM(svc *service.Services, broadcaster DMBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Message: "authentication required",
})
return
}
channelID, ok := parseIDParam(w, r, "channelId")
if !ok {
return
}
var req renameDMRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST", Message: "invalid request body",
})
return
}
if _, err := svc.DMs.RenameGroupDM(r.Context(), user.ID, channelID, req.Name); err != nil {
writeServiceError(r.Context(), w, err)
return
}
// The rename has already committed at this point, so this lookup must
// survive the caller's request context being cancelled right after
// that commit (client disconnect mid-handler) — same reasoning as
// broadcastDMOpen's own context.WithoutCancel, and the failure must be
// logged rather than silently dropping the fan-out (participants would
// keep rendering the stale name with no compensating resync, since
// dm_channel_open is unsequenced/targeted and can't be replayed).
bgCtx := context.WithoutCancel(r.Context())
participantIDs, pErr := svc.Channels.GetDMParticipantIDs(bgCtx, channelID)
if pErr != nil {
slog.Error("handleRenameGroupDM: participant lookup failed", "err", pErr, "channel_id", channelID)
} else {
broadcastDMOpen(bgCtx, svc, broadcaster, channelID, participantIDs)
}
summary, sErr := svc.DMs.DMSummaryFor(r.Context(), user.ID, channelID)
if sErr != nil {
writeServiceError(r.Context(), w, sErr)
return
}
writeJSON(w, http.StatusOK, summary)
}
}
// handleBlockUser blocks a user.
func handleBlockUser(svc *service.Services, broadcaster DMBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, _ := r.Context().Value(UserKey).(*db.User)
if user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{Error: "UNAUTHORIZED", Message: "authentication required"})
return
}
targetID, ok := parseIDParam(w, r, "userId")
if !ok {
return
}
if err := svc.Blocks.BlockUser(r.Context(), user.ID, targetID); err != nil {
writeServiceError(r.Context(), w, err)
return
}
// The block has already committed at this point, so the rest of this
// handler must survive the caller's request context being cancelled
// right after that commit (client disconnect mid-handler) — same
// reasoning as handleRenameGroupDM's own bgCtx. Without this, a
// canceled request context makes the shared-DM lookup below fail and
// get skipped, silently defeating the eviction it gates.
bgCtx := context.WithoutCancel(r.Context())
// Revocation must evict a live session, not merely block the next
// join (the same invariant the voice sweep states): without this, a
// blocked user already in the pair's 1:1 DM voice call stays in it
// indefinitely — the block gate otherwise runs only on voice_join and
// voluntary voice_token_refresh, both of which the blocked client
// controls. Group DM calls are deliberately untouched, matching
// requireDMNotBlocked's group exemption.
if ve, evictable := broadcaster.(dmVoiceEvictor); evictable {
if chID, exists, err := svc.DMs.SharedOneToOneDM(bgCtx, user.ID, targetID); err != nil {
slog.Warn("block: shared-DM lookup for voice eviction failed",
"blocker_id", user.ID, "target_id", targetID, "err", err)
} else if exists {
ve.DisconnectFromVoiceInChannel(bgCtx, targetID, chID)
}
}
writeJSON(w, http.StatusOK, map[string]string{"message": "user blocked"})
}
}
// handleUnblockUser unblocks a user.
func handleUnblockUser(svc *service.Services) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, _ := r.Context().Value(UserKey).(*db.User)
if user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{Error: "UNAUTHORIZED", Message: "authentication required"})
return
}
targetID, ok := parseIDParam(w, r, "userId")
if !ok {
return
}
if err := svc.Blocks.UnblockUser(r.Context(), user.ID, targetID); err != nil {
writeServiceError(r.Context(), w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "user unblocked"})
}
}
// handleListBlocks returns all blocked user IDs.
func handleListBlocks(svc *service.Services) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, _ := r.Context().Value(UserKey).(*db.User)
if user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{Error: "UNAUTHORIZED", Message: "authentication required"})
return
}
ids, err := svc.Blocks.ListBlocked(r.Context(), user.ID)
if err != nil {
writeServiceError(r.Context(), w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"blocked_user_ids": ids})
}
}