mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.
Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.
* fix(ws): 1 defect(s) (OC-0210)
* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed
Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.
Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.
OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.
* docs: record the dm_channel_open emission on 1:1 DM creation
POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.
The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.
---------
Co-authored-by: Claude <noreply@anthropic.com>
482 lines
18 KiB
Go
482 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
|
|
}
|
|
dmUser := db.DMUser{
|
|
ID: result.Recipient.ID,
|
|
Username: result.Recipient.Username,
|
|
Avatar: avatarStr,
|
|
Status: 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})
|
|
}
|
|
}
|