From 99dd25ec9aada647491c22f0bc3272e8df463f96 Mon Sep 17 00:00:00 2001 From: jevb Date: Fri, 27 Mar 2026 13:58:48 +0100 Subject: [PATCH] feat(server): add DM REST endpoints, WebSocket routing, and ready payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task A: REST API endpoints in api/dm_handler.go — POST /api/v1/dms (create/get DM channel), GET /api/v1/dms (list open DMs), DELETE /api/v1/dms/{channelId} (close DM). Routes registered in router.go. Task B: WebSocket DM routing — handleChatSend, handleChatEdit, handleChatDelete, and handleReaction all check channel type and use participant-based auth for DM channels instead of role permissions. DM messages delivered via SendToUser to each participant (bypassing channel-subscription model). Auto-reopens DM for recipient on new message with dm_channel_open event. New broadcastToDMParticipants helper. New WS event types: dm_channel_open, dm_channel_close. Task C: Ready payload includes dm_channels from GetUserDMChannels. Also adds GetDMParticipantIDs helper to db/dm_queries.go. --- Server/api/dm_handler.go | 207 +++++++++++++++++++++++++++++++++++++++ Server/api/router.go | 3 + Server/db/dm_queries.go | 25 +++++ Server/ws/handlers.go | 156 ++++++++++++++++++++++++----- Server/ws/messages.go | 47 +++++++++ Server/ws/serve.go | 8 ++ 6 files changed, 424 insertions(+), 22 deletions(-) create mode 100644 Server/api/dm_handler.go diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go new file mode 100644 index 00000000..df105a10 --- /dev/null +++ b/Server/api/dm_handler.go @@ -0,0 +1,207 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/db" +) + +// MountDMRoutes registers DM-related routes onto r. +// All routes require authentication. +func MountDMRoutes(r chi.Router, database *db.DB) { + r.Route("/api/v1/dms", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Post("/", handleCreateDM(database)) + r.Get("/", handleListDMs(database)) + r.Delete("/{channelId}", handleCloseDM(database)) + }) +} + +// 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"` +} + +// 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(database *db.DB) 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 + } + + if req.RecipientID <= 0 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "recipient_id must be a positive integer", + }) + return + } + + // Cannot DM yourself. + if req.RecipientID == user.ID { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "cannot create a DM with yourself", + }) + return + } + + // Verify recipient exists. + recipient, err := database.GetUserByID(req.RecipientID) + if err != nil { + slog.Error("handleCreateDM GetUserByID", "err", err, "recipient_id", req.RecipientID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to look up recipient", + }) + return + } + if recipient == nil { + writeJSON(w, http.StatusNotFound, errorResponse{ + Error: "NOT_FOUND", + Message: "recipient not found", + }) + return + } + + // Get or create the DM channel. + ch, created, err := database.GetOrCreateDMChannel(user.ID, req.RecipientID) + if err != nil { + slog.Error("handleCreateDM GetOrCreateDMChannel", "err", err, + "user_id", user.ID, "recipient_id", req.RecipientID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to create DM channel", + }) + return + } + + // Build the recipient DMUser from the fetched user. + avatarStr := "" + if recipient.Avatar != nil { + avatarStr = *recipient.Avatar + } + dmUser := db.DMUser{ + ID: recipient.ID, + Username: recipient.Username, + Avatar: avatarStr, + Status: recipient.Status, + } + + status := http.StatusOK + if created { + status = http.StatusCreated + } + + writeJSON(w, status, createDMResponse{ + ChannelID: ch.ID, + Recipient: dmUser, + Created: created, + }) + } +} + +// handleListDMs returns all open DM channels for the authenticated user. +func handleListDMs(database *db.DB) 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 := database.GetUserDMChannels(user.ID) + if err != nil { + slog.Error("handleListDMs GetUserDMChannels", "err", err, "user_id", user.ID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to list DM channels", + }) + return + } + + writeJSON(w, http.StatusOK, listDMsResponse{DMChannels: channels}) + } +} + +// handleCloseDM removes a DM channel from the authenticated user's open list. +func handleCloseDM(database *db.DB) 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 + } + + // Verify user is a participant in this DM. + isParticipant, err := database.IsDMParticipant(user.ID, channelID) + if err != nil { + slog.Error("handleCloseDM IsDMParticipant", "err", err, + "user_id", user.ID, "channel_id", channelID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to verify DM participation", + }) + return + } + if !isParticipant { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "you are not a participant in this DM", + }) + return + } + + if err := database.CloseDM(user.ID, channelID); err != nil { + slog.Error("handleCloseDM CloseDM", "err", err, + "user_id", user.ID, "channel_id", channelID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to close DM", + }) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/Server/api/router.go b/Server/api/router.go index 791455c7..210a7342 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -55,6 +55,9 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Channel and message REST routes. MountChannelRoutes(r, database) + // DM (direct message) REST routes. + MountDMRoutes(r, database) + // File upload and serving routes. store, storeErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB) if storeErr != nil { diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index 19e42d3f..444ab567 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -219,6 +219,31 @@ func (d *DB) IsDMParticipant(userID, channelID int64) (bool, error) { return true, nil } +// GetDMParticipantIDs returns all participant user IDs for a DM channel. +func (d *DB) GetDMParticipantIDs(channelID int64) ([]int64, error) { + rows, err := d.sqlDB.Query( + `SELECT user_id FROM dm_participants WHERE channel_id = ?`, + channelID, + ) + if err != nil { + return nil, fmt.Errorf("GetDMParticipantIDs: %w", err) + } + defer rows.Close() //nolint:errcheck + + var ids []int64 + for rows.Next() { + var id int64 + if scanErr := rows.Scan(&id); scanErr != nil { + return nil, fmt.Errorf("GetDMParticipantIDs scan: %w", scanErr) + } + ids = append(ids, id) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetDMParticipantIDs rows: %w", rows.Err()) + } + return ids, nil +} + // GetDMRecipient returns the other participant in a DM channel. func (d *DB) GetDMRecipient(channelID, requestingUserID int64) (*User, error) { var recipientID int64 diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index b66a8718..fb852715 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -174,13 +174,29 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { return } - // Permission check. - if !h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES") { - return + // DM channels use participant-based auth instead of role permissions. + isDM := ch.Type == "dm" + if isDM { + ok, dmErr := h.db.IsDMParticipant(c.userID, channelID) + if dmErr != nil { + slog.Error("ws handleChatSend IsDMParticipant", "err", dmErr) + c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to check DM participation")) + return + } + if !ok { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "you are not a participant in this DM")) + return + } + } else { + // Permission check for non-DM channels. + if !h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES") { + return + } } // Slow mode enforcement: moderators with MANAGE_MESSAGES bypass it. - if ch.SlowMode > 0 && !h.hasChannelPerm(c, channelID, permissions.ManageMessages) { + // DM channels do not have slow mode. + if !isDM && ch.SlowMode > 0 && !h.hasChannelPerm(c, channelID, permissions.ManageMessages) { slowKey := fmt.Sprintf("slow:%d:%d", c.userID, channelID) if !h.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { c.sendMsg(buildErrorMsg(ErrCodeSlowMode, fmt.Sprintf("channel has %ds slow mode", ch.SlowMode))) @@ -265,9 +281,38 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { // Ack sender. c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp)) - // Broadcast to channel. + // Broadcast message. broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, c.roleName, content, msg.Timestamp, p.ReplyTo, attachments) - h.BroadcastToChannel(channelID, broadcast) + + if isDM { + // DM: send directly to both participants instead of channel broadcast. + participantIDs, pErr := h.db.GetDMParticipantIDs(channelID) + if pErr != nil { + slog.Error("ws handleChatSend GetDMParticipantIDs", "err", pErr, "channel_id", channelID) + } + for _, pid := range participantIDs { + h.SendToUser(pid, broadcast) + } + + // Auto-reopen the DM for the recipient if it was closed. + for _, pid := range participantIDs { + if pid == c.userID { + continue + } + if openErr := h.db.OpenDM(pid, channelID); openErr != nil { + slog.Error("ws handleChatSend OpenDM", "err", openErr, + "recipient_id", pid, "channel_id", channelID) + continue + } + // Notify the recipient that the DM was (re)opened. + // Build the event with the sender as the recipient's "other user". + if c.user != nil { + h.SendToUser(pid, buildDMChannelOpen(channelID, c.user)) + } + } + } else { + h.BroadcastToChannel(channelID, broadcast) + } } // handleChatEdit processes a chat_edit message. @@ -309,12 +354,22 @@ func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) { return } - // Re-check that the user still has SendMessages permission on this channel. - // Editing an existing message requires the same permission as sending a new one — - // if a channel goes read-only, users cannot modify existing messages either. - if !h.hasChannelPerm(c, msg.ChannelID, permissions.SendMessages) { - c.sendMsg(buildErrorMsg(ErrCodeForbidden, "no permission to edit in this channel")) - return + // Check channel type for DM-aware permission handling. + editCh, chErr := h.db.GetChannel(msg.ChannelID) + editIsDM := chErr == nil && editCh != nil && editCh.Type == "dm" + + if editIsDM { + ok, dmErr := h.db.IsDMParticipant(c.userID, msg.ChannelID) + if dmErr != nil || !ok { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "no permission to edit in this channel")) + return + } + } else { + // Re-check that the user still has SendMessages permission on this channel. + if !h.hasChannelPerm(c, msg.ChannelID, permissions.SendMessages) { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "no permission to edit in this channel")) + return + } } // EditMessage checks ownership internally. @@ -336,7 +391,13 @@ func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) { editedAt = *msg.EditedAt } slog.Debug("message edited", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID) - h.BroadcastToChannel(msg.ChannelID, buildChatEdited(msgID, msg.ChannelID, content, editedAt)) + + editedMsg := buildChatEdited(msgID, msg.ChannelID, content, editedAt) + if editIsDM { + h.broadcastToDMParticipants(msg.ChannelID, editedMsg) + } else { + h.BroadcastToChannel(msg.ChannelID, editedMsg) + } } // handleChatDelete processes a chat_delete message. @@ -366,13 +427,26 @@ func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) { return } - // Ensure the user still has at least ReadMessages on this channel. - if !h.hasChannelPerm(c, msg.ChannelID, permissions.ReadMessages) { - c.sendMsg(buildErrorMsg(ErrCodeForbidden, "no read permission in this channel")) - return + // Check channel type for DM-aware permission handling. + delCh, chErr := h.db.GetChannel(msg.ChannelID) + delIsDM := chErr == nil && delCh != nil && delCh.Type == "dm" + + if delIsDM { + ok, dmErr := h.db.IsDMParticipant(c.userID, msg.ChannelID) + if dmErr != nil || !ok { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "no read permission in this channel")) + return + } + } else { + // Ensure the user still has at least ReadMessages on this channel. + if !h.hasChannelPerm(c, msg.ChannelID, permissions.ReadMessages) { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "no read permission in this channel")) + return + } } - isMod := h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages) + // In DMs, users can only delete their own messages (no mod override). + isMod := !delIsDM && h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages) if err := h.db.DeleteMessage(msgID, c.userID, isMod); err != nil { c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot delete this message")) return @@ -381,7 +455,13 @@ func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) { slog.Debug("message deleted", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) _ = h.db.LogAudit(c.userID, "message_delete", "message", msgID, fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) - h.BroadcastToChannel(msg.ChannelID, buildChatDeleted(msgID, msg.ChannelID)) + + deletedMsg := buildChatDeleted(msgID, msg.ChannelID) + if delIsDM { + h.broadcastToDMParticipants(msg.ChannelID, deletedMsg) + } else { + h.BroadcastToChannel(msg.ChannelID, deletedMsg) + } } // handleReaction processes reaction_add and reaction_remove messages. @@ -429,8 +509,20 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { return } - if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") { - return + // Check channel type for DM-aware permission handling. + reactCh, chErr := h.db.GetChannel(msg.ChannelID) + reactIsDM := chErr == nil && reactCh != nil && reactCh.Type == "dm" + + if reactIsDM { + ok, dmErr := h.db.IsDMParticipant(c.userID, msg.ChannelID) + if dmErr != nil || !ok { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "reaction failed")) + return + } + } else { + if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") { + return + } } action := "add" @@ -447,7 +539,12 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { return } - h.BroadcastToChannel(msg.ChannelID, buildReactionUpdate(msgID, msg.ChannelID, c.userID, p.Emoji, action)) + reactionMsg := buildReactionUpdate(msgID, msg.ChannelID, c.userID, p.Emoji, action) + if reactIsDM { + h.broadcastToDMParticipants(msg.ChannelID, reactionMsg) + } else { + h.BroadcastToChannel(msg.ChannelID, reactionMsg) + } } // handleTyping processes a typing_start message. @@ -555,6 +652,21 @@ func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) { } } +// broadcastToDMParticipants sends a message to all participants of a DM channel +// using SendToUser for each participant. This bypasses the channel-subscription +// model used by BroadcastToChannel, which is correct for DMs since users may +// not be "focused" on the DM channel. +func (h *Hub) broadcastToDMParticipants(channelID int64, msg []byte) { + participantIDs, err := h.db.GetDMParticipantIDs(channelID) + if err != nil { + slog.Error("broadcastToDMParticipants GetDMParticipantIDs", "err", err, "channel_id", channelID) + return + } + for _, pid := range participantIDs { + h.SendToUser(pid, msg) + } +} + // handleChannelFocus sets which channel the client is currently viewing, // so channel-scoped broadcasts (chat messages, typing) reach them. // Also updates read_states so unread counts decrease when the user views a channel. diff --git a/Server/ws/messages.go b/Server/ws/messages.go index ac069846..4089b669 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -142,6 +142,25 @@ type serverRestartPayload struct { DelaySeconds int `json:"delay_seconds"` } +// dmChannelOpenPayload is sent when a DM is opened/reopened for a user. +type dmChannelOpenPayload struct { + ChannelID int64 `json:"channel_id"` + Recipient dmUserPayload `json:"recipient"` +} + +// dmChannelClosePayload is sent when a user closes a DM. +type dmChannelClosePayload struct { + ChannelID int64 `json:"channel_id"` +} + +// dmUserPayload is the public-facing shape for a DM participant in WS events. +type dmUserPayload struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + // --------------------------------------------------------------------------- // Builder helpers (kept as maps per task spec). // --------------------------------------------------------------------------- @@ -408,6 +427,34 @@ func buildChannelDelete(channelID int64) []byte { }) } +// buildDMChannelOpen constructs a dm_channel_open event sent to a user. +func buildDMChannelOpen(channelID int64, recipient *db.User) []byte { + avatarStr := "" + if recipient.Avatar != nil { + avatarStr = *recipient.Avatar + } + return buildJSON(wsMsg{ + Type: "dm_channel_open", + Payload: dmChannelOpenPayload{ + ChannelID: channelID, + Recipient: dmUserPayload{ + ID: recipient.ID, + Username: recipient.Username, + Avatar: avatarStr, + Status: recipient.Status, + }, + }, + }) +} + +// buildDMChannelClose constructs a dm_channel_close event sent to a user. +func buildDMChannelClose(channelID int64) []byte { + return buildJSON(wsMsg{ + Type: "dm_channel_close", + Payload: dmChannelClosePayload{ChannelID: channelID}, + }) +} + // buildServerRestartMsg constructs a server_restart broadcast. func buildServerRestartMsg(reason string, delaySeconds int) []byte { return buildJSON(wsMsg{ diff --git a/Server/ws/serve.go b/Server/ws/serve.go index f257d173..fe95ffeb 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -313,6 +313,13 @@ func (h *Hub) buildReady(database *db.DB, userID int64) ([]byte, error) { voiceStates = []db.VoiceState{} } + // Load open DM channels for this user. + dmChannels, err := database.GetUserDMChannels(userID) + if err != nil { + slog.Warn("buildReady GetUserDMChannels", "err", err) + dmChannels = []db.DMChannelInfo{} + } + serverName, motd := h.getCachedSettings() return buildJSON(map[string]any{ @@ -322,6 +329,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64) ([]byte, error) { "members": members, "voice_states": voiceStates, "roles": roles, + "dm_channels": dmChannels, "server_name": serverName, "motd": motd, },