From e54ad4607926f1dc71bff6a3c9a03cf627bf93b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 20:47:47 +0000 Subject: [PATCH] migrate WS chat/reaction/presence handlers to service layer Handlers now delegate all business logic (validation, permission checks, DB operations) to MessageService and ChannelService instead of calling *db.DB directly. This eliminates logic duplication and enables the service layer's permission cache. https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8 --- Server/ws/handlers_chat.go | 362 +++++++-------------------------- Server/ws/handlers_presence.go | 95 ++------- Server/ws/handlers_reaction.go | 87 +------- 3 files changed, 99 insertions(+), 445 deletions(-) diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index df05136e..1d8d2e9a 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -2,11 +2,9 @@ package ws import ( "context" - "fmt" - "log/slog" - "time" + "errors" - "github.com/owncord/server/permissions" + "github.com/owncord/server/service" ) // registerChatHandlers registers all chat-related V2 message handlers. @@ -16,346 +14,134 @@ func registerChatHandlers(r *HandlerRegistry, deps ChatDeps) { r.RegisterV2(MsgTypeChatDelete, handleChatDeleteV2, deps) } -// handleChatSendV2 processes a chat_send command. +// handleChatSendV2 processes a chat_send command via the MessageService. func handleChatSendV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ChatDeps) sendCmd := cmd.(ChatSendCmd) - userID := info.UserID - channelID := sendCmd.ChannelID() - // Rate limit. - ratKey := fmt.Sprintf("chat:%d", userID) - if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) { - return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many messages"}} - } - - if channelID <= 0 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "channel_id must be a positive integer"}} - } - - ch, err := d.DB.GetChannel(channelID) - if err != nil || ch == nil { - return Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}} - } - - isDM := ch.Type == "dm" - - // Permission check. - if r := chatSendPermCheck(d, userID, channelID, isDM); r != nil { - return *r - } - - // Slow mode. - if !isDM && ch.SlowMode > 0 && !hasPerm(d.DB, d.Permissions, userID, channelID, permissions.ManageMessages) { - slowKey := fmt.Sprintf("slow:%d:%d", userID, channelID) - if d.Limiter != nil && !d.Limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { - return Result{Error: ClientError{Code: ErrCodeSlowMode, Message: fmt.Sprintf("channel has %ds slow mode", ch.SlowMode)}} - } - } - - // Validate content — check raw length before sanitizing to prevent - // CPU/memory amplification from huge payloads hitting bluemonday. - rawContent := sendCmd.Content() - attachmentIDs := sendCmd.Attachments() - if len(rawContent) > maxMessageLen*4 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message content exceeds maximum length of 4000 characters"}} - } - content := sanitizer.Sanitize(rawContent) - if content == "" && len(attachmentIDs) == 0 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message content cannot be empty"}} - } - if len([]rune(content)) > maxMessageLen { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message content exceeds maximum length of 4000 characters"}} - } - - // Attachment permission. - if !isDM && len(attachmentIDs) > 0 { - if r := requirePerm(d.DB, d.Permissions, userID, channelID, permissions.AttachFiles, "ATTACH_FILES"); r != nil { - return *r - } - } - - // Persist message. - msgID, err := d.DB.CreateMessage(channelID, userID, content, sendCmd.ReplyTo()) + result, err := d.MessageSvc.SendMessage(service.SendMessageParams{ + ChannelID: sendCmd.ChannelID(), + UserID: info.UserID, + Username: info.Username, + Avatar: info.Avatar, + RoleName: info.RoleName, + Content: sendCmd.Content(), + ReplyTo: sendCmd.ReplyTo(), + AttachmentIDs: sendCmd.Attachments(), + }) if err != nil { - slog.Error("ws handleChatSendV2 CreateMessage", "err", err) - return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to save message"}} + return serviceErrorToResult(err) } - // Link attachments. + // Build attachment data for broadcast. var attData []map[string]any - if len(attachmentIDs) > 0 { - linked, linkErr := d.DB.LinkAttachmentsToMessage(msgID, attachmentIDs) - if linkErr != nil { - slog.Error("ws handleChatSendV2 LinkAttachments", "err", linkErr, "msg_id", msgID) - if delErr := d.DB.DeleteMessage(msgID, userID, true); delErr != nil { - slog.Error("ws handleChatSendV2 DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID) - } - return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to send message with attachments"}} - } - if linked > 0 { - attMap, attErr := d.DB.GetAttachmentsByMessageIDs([]int64{msgID}) - if attErr != nil { - slog.Error("ws handleChatSendV2 GetAttachments", "err", attErr) - } else { - for _, ai := range attMap[msgID] { - attData = append(attData, map[string]any{ - "id": ai.ID, - "filename": ai.Filename, - "size": ai.Size, - "mime": ai.Mime, - "url": ai.URL, - }) - } - } - } + for _, ai := range result.Attachments { + attData = append(attData, map[string]any{ + "id": ai.ID, + "filename": ai.Filename, + "size": ai.Size, + "mime": ai.Mime, + "url": ai.URL, + }) } - // Fetch message for timestamp. - msg, err := d.DB.GetMessage(msgID) - if err != nil || msg == nil { - slog.Error("ws handleChatSendV2 GetMessage after create", "err", err) - return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to retrieve message"}} - } + reply := buildChatSendOK(info.ReqID, result.MessageID, result.Timestamp) + broadcast := buildChatMessage(result.MessageID, sendCmd.ChannelID(), info.UserID, + info.Username, info.Avatar, info.RoleName, result.Content, result.Timestamp, + sendCmd.ReplyTo(), attData) - slog.Debug("message sent", "user", info.Username, "channel_id", channelID, "msg_id", msgID) - - reply := buildChatSendOK(info.ReqID, msgID, msg.Timestamp) - broadcast := buildChatMessage(msgID, channelID, userID, info.Username, info.Avatar, info.RoleName, content, msg.Timestamp, sendCmd.ReplyTo(), attData) - - if !isDM { + if !result.IsDM { return Result{ Reply: reply, - Events: []Event{MessageSentChannelEvent{channelID: channelID, payload: broadcast}}, + Events: []Event{MessageSentChannelEvent{channelID: sendCmd.ChannelID(), payload: broadcast}}, } } - // DM path: open DM for recipients, send dm_channel_open, then sequenced message. - participantIDs, pErr := d.DB.GetDMParticipantIDs(channelID) - if pErr != nil { - slog.Error("ws handleChatSendV2 GetDMParticipantIDs", "err", pErr, "channel_id", channelID) - // Message is saved; return the ACK but skip broadcast. - return Result{Reply: reply} - } - + // DM path: build dm_channel_open events + sequenced message. var events []Event - sender, _ := d.DB.GetUserByID(userID) - for _, pid := range participantIDs { - if pid == userID { - continue - } - if openErr := d.DB.OpenDM(pid, channelID); openErr != nil { - slog.Error("ws handleChatSendV2 OpenDM", "err", openErr, "recipient_id", pid, "channel_id", channelID) - continue - } - if sender != nil { + if result.SenderUser != nil { + for _, pid := range result.OpenedDMFor { events = append(events, DMChannelOpenEvent{ targetUserID: pid, - payload: buildDMChannelOpen(channelID, sender), + payload: buildDMChannelOpen(sendCmd.ChannelID(), result.SenderUser), }) } } events = append(events, MessageSentDMEvent{ - channelID: channelID, - participantIDs: participantIDs, + channelID: sendCmd.ChannelID(), + participantIDs: result.ParticipantIDs, payload: broadcast, }) return Result{Reply: reply, Events: events} } -// chatSendPermCheck validates send permission for DM and non-DM channels. -func chatSendPermCheck(d ChatDeps, userID, channelID int64, isDM bool) *Result { - if isDM { - ok, dmErr := d.DB.IsDMParticipant(userID, channelID) - if dmErr != nil { - slog.Error("ws chatSendPermCheck IsDMParticipant", "err", dmErr) - r := Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check DM participation"}} - return &r - } - if !ok { - r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "you are not a participant in this DM"}} - return &r - } - recipient, recErr := d.DB.GetDMRecipient(channelID, userID) - if recErr == nil && recipient != nil { - blocked, blkErr := d.DB.IsEitherBlocked(userID, recipient.ID) - if blkErr != nil { - slog.Error("ws chatSendPermCheck IsEitherBlocked", "err", blkErr) - r := Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check block status"}} - return &r - } - if blocked { - r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot send messages — user is blocked"}} - return &r - } - } - return nil - } - return requirePerm(d.DB, d.Permissions, userID, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES") -} - -// handleChatEditV2 processes a chat_edit command. +// handleChatEditV2 processes a chat_edit command via the MessageService. func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ChatDeps) editCmd := cmd.(ChatEditCmd) - userID := info.UserID - msgID := editCmd.MessageID() - // Rate limit. - ratKey := fmt.Sprintf("chat_edit:%d", userID) - if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) { - return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many edits"}} + result, err := d.MessageSvc.EditMessage(info.UserID, editCmd.MessageID(), editCmd.Content()) + if err != nil { + return serviceErrorToResult(err) } - if msgID <= 0 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}} - } - - // Validate content — check raw length before sanitizing to prevent - // CPU/memory amplification from huge payloads hitting bluemonday. - rawContent := editCmd.Content() - if len(rawContent) > maxMessageLen*4 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message too long"}} - } - content := sanitizer.Sanitize(rawContent) - if content == "" { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "content cannot be empty"}} - } - if len([]rune(content)) > maxMessageLen { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message too long"}} - } - - // Fetch message (opaque error to prevent IDOR). - msg, err := d.DB.GetMessage(msgID) - if err != nil || msg == nil { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}} - } - - // BUG-126: Reject edits on soft-deleted messages. - if msg.Deleted { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}} - } - - // Channel type for DM-aware permissions. - editCh, chErr := d.DB.GetChannel(msg.ChannelID) - editIsDM := chErr == nil && editCh != nil && editCh.Type == "dm" - - if editIsDM { - ok, dmErr := d.DB.IsDMParticipant(userID, msg.ChannelID) - if dmErr != nil || !ok { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}} - } - } else if !hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.SendMessages) { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}} - } - - // EditMessage checks ownership internally. - if err := d.DB.EditMessage(msgID, userID, content); err != nil { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot edit this message"}} - } - - // Re-fetch for updated edited_at timestamp. - msg, err = d.DB.GetMessage(msgID) - if err != nil || msg == nil { - slog.Error("ws handleChatEditV2 GetMessage after edit", "err", err, "msg_id", msgID) - return Result{Error: ClientError{Code: ErrCodeInternal, Message: "edit saved but broadcast failed"}} - } - - editedAt := "" - if msg.EditedAt != nil { - editedAt = *msg.EditedAt - } - slog.Debug("message edited", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID) - - editedPayload := buildChatEdited(msgID, msg.ChannelID, content, editedAt) - if editIsDM { - participantIDs, pErr := d.DB.GetDMParticipantIDs(msg.ChannelID) - if pErr != nil { - slog.Error("handleChatEditV2 GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) - return Result{} - } + editedPayload := buildChatEdited(result.MessageID, result.ChannelID, result.Content, result.EditedAt) + if result.IsDM { return Result{Events: []Event{MessageEditedDMEvent{ - channelID: msg.ChannelID, - participantIDs: participantIDs, + channelID: result.ChannelID, + participantIDs: result.ParticipantIDs, payload: editedPayload, }}} } return Result{Events: []Event{MessageEditedChannelEvent{ - channelID: msg.ChannelID, + channelID: result.ChannelID, payload: editedPayload, }}} } -// handleChatDeleteV2 processes a chat_delete command. +// handleChatDeleteV2 processes a chat_delete command via the MessageService. func handleChatDeleteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ChatDeps) deleteCmd := cmd.(ChatDeleteCmd) - userID := info.UserID - msgID := deleteCmd.MessageID() - // Rate limit. - ratKey := fmt.Sprintf("chat_delete:%d", userID) - if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) { - return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many deletes"}} + result, err := d.MessageSvc.DeleteMessage(info.UserID, deleteCmd.MessageID()) + if err != nil { + return serviceErrorToResult(err) } - if msgID <= 0 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}} - } - - // Fetch message (opaque error to prevent IDOR). - msg, err := d.DB.GetMessage(msgID) - if err != nil || msg == nil { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}} - } - - // Channel type for DM-aware permissions. - delCh, chErr := d.DB.GetChannel(msg.ChannelID) - delIsDM := chErr == nil && delCh != nil && delCh.Type == "dm" - - if delIsDM { - ok, dmErr := d.DB.IsDMParticipant(userID, msg.ChannelID) - if dmErr != nil || !ok { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}} - } - } else { - // Mod override: ManageMessages allows deleting any message. - // Own-message delete requires SendMessages (a muted user cannot delete). - isMsgOwner := msg.UserID == userID - canManage := hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.ManageMessages) - canDelete := canManage || (isMsgOwner && hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.SendMessages)) - if !canDelete { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}} - } - } - - // In DMs, users can only delete their own messages (no mod override). - isMod := !delIsDM && hasPerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.ManageMessages) - if err := d.DB.DeleteMessage(msgID, userID, isMod); err != nil { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "cannot delete this message"}} - } - - slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) - _ = d.DB.LogAudit(userID, "message_delete", "message", msgID, - fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) - - deletedPayload := buildChatDeleted(msgID, msg.ChannelID) - if delIsDM { - participantIDs, pErr := d.DB.GetDMParticipantIDs(msg.ChannelID) - if pErr != nil { - slog.Error("handleChatDeleteV2 GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) - return Result{} - } + deletedPayload := buildChatDeleted(result.MessageID, result.ChannelID) + if result.IsDM { return Result{Events: []Event{MessageDeletedDMEvent{ - channelID: msg.ChannelID, - participantIDs: participantIDs, + channelID: result.ChannelID, + participantIDs: result.ParticipantIDs, payload: deletedPayload, }}} } return Result{Events: []Event{MessageDeletedChannelEvent{ - channelID: msg.ChannelID, + channelID: result.ChannelID, payload: deletedPayload, }}} } + +// serviceErrorToResult converts a service-layer error to a WS Result. +func serviceErrorToResult(err error) Result { + switch { + case errors.Is(err, service.ErrRateLimited): + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: err.Error()}} + case errors.Is(err, service.ErrSlowMode): + return Result{Error: ClientError{Code: ErrCodeSlowMode, Message: err.Error()}} + case errors.Is(err, service.ErrBadRequest): + return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: err.Error()}} + case errors.Is(err, service.ErrNotFound): + return Result{Error: ClientError{Code: ErrCodeNotFound, Message: err.Error()}} + case errors.Is(err, service.ErrForbidden), errors.Is(err, service.ErrBlocked), + errors.Is(err, service.ErrDeletedMessage): + return Result{Error: ClientError{Code: ErrCodeForbidden, Message: err.Error()}} + case errors.Is(err, service.ErrConflict): + return Result{Error: ClientError{Code: ErrCodeConflict, Message: err.Error()}} + default: + return Result{Error: ClientError{Code: ErrCodeInternal, Message: err.Error()}} + } +} diff --git a/Server/ws/handlers_presence.go b/Server/ws/handlers_presence.go index dcb77d93..58569a8d 100644 --- a/Server/ws/handlers_presence.go +++ b/Server/ws/handlers_presence.go @@ -2,17 +2,11 @@ package ws import ( "context" - "fmt" - "log/slog" + "errors" - "github.com/owncord/server/permissions" + "github.com/owncord/server/service" ) -// validPresenceStatuses is the set of accepted status values for presence_update. -var validPresenceStatuses = map[string]bool{ - "online": true, "idle": true, "dnd": true, "offline": true, -} - // registerPresenceHandlers registers presence, typing, and channel focus handlers. // All three are V2 handlers. func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) { @@ -30,39 +24,18 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R channelID := typingCmd.ChannelID() userID := info.UserID - // Rate limit. - ratKey := fmt.Sprintf("typing:%d:%d", userID, channelID) - if d.Limiter != nil && !d.Limiter.Allow(ratKey, typingRateLimit, typingWindow) { - return Result{} // silently drop; no error for typing throttle - } - - // Channel lookup. - ch, err := d.DB.GetChannel(channelID) + ch, err := d.ChannelSvc.HandleTyping(userID, channelID, d.Limiter) if err != nil || ch == nil { - return Result{} // silently drop for unknown channels - } - - // Permission check. - if ch.Type == "dm" { - ok, dmErr := d.DB.IsDMParticipant(userID, channelID) - if dmErr != nil || !ok { - return Result{} // silently drop — not a DM participant - } - } else { - if !hasPerm(d.DB, d.Permissions, userID, channelID, permissions.ReadMessages) { - return Result{} // silently drop — no read permission - } + return Result{} // silently drop } payload := buildTypingMsg(channelID, userID, info.Username) if ch.Type == "dm" { - // For DM channels, get participant IDs and send to each excluding sender. - participantIDs, pErr := d.DB.GetDMParticipantIDs(channelID) + participantIDs, pErr := d.ChannelSvc.GetDMParticipantIDs(channelID) if pErr != nil { - return Result{} // silently drop on error + return Result{} } - // Build one TypingDMEvent per other participant (UserTargetedEvent routing). var events []Event for _, pid := range participantIDs { if pid == userID { @@ -76,7 +49,6 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R return Result{Events: events} } - // Regular channel: ExcludeSenderEvent routing. return Result{ Events: []Event{ TypingChannelEvent{ @@ -94,26 +66,12 @@ func handlePresenceV2(_ context.Context, cmd Command, info ClientInfo, deps any) d := deps.(PresenceDeps) presenceCmd := cmd.(PresenceUpdateCmd) userID := info.UserID - - // Rate limit. - ratKey := fmt.Sprintf("presence:%d", userID) - if d.Limiter != nil && !d.Limiter.Allow(ratKey, presenceRateLimit, presenceWindow) { - return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many presence updates"}} - } - - // Validate status. status := presenceCmd.Status() - if !validPresenceStatuses[status] { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "status must be online|idle|dnd|offline"}} + + if err := d.ChannelSvc.HandlePresenceUpdate(userID, status, d.Limiter); err != nil { + return serviceErrorToResult(err) } - // Update DB. - if err := d.DB.UpdateUserStatus(userID, status); err != nil { - slog.Error("ws handlePresenceV2 UpdateUserStatus", "err", err, "user_id", userID) - return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update status"}} - } - - // Broadcast to all connected clients. return Result{ Events: []Event{ PresenceEvent{payload: buildPresenceMsg(userID, status)}, @@ -128,38 +86,13 @@ func handleChannelFocusV2(_ context.Context, cmd Command, info ClientInfo, deps d := deps.(PresenceDeps) focusCmd := cmd.(ChannelFocusCmd) chID := focusCmd.ChannelID() - userID := info.UserID - if chID <= 0 { - return Result{} // silently drop invalid channel_id - } - - // Channel lookup. - ch, chErr := d.DB.GetChannel(chID) - if chErr != nil || ch == nil { - return Result{} // silently drop — channel not found - } - - // Permission check. - if ch.Type == "dm" { - ok, dmErr := d.DB.IsDMParticipant(userID, chID) - if dmErr != nil || !ok { - return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "not a participant in this DM"}} - } - } else { - if denied := requirePerm(d.DB, d.Permissions, userID, chID, permissions.ReadMessages, "READ_MESSAGES"); denied != nil { - return *denied - } - } - - slog.Debug("channel_focus", "user_id", userID, "channel_id", chID) - - // Mark channel as read by updating read_states to the latest message. - latestID, latestErr := d.DB.GetLatestMessageID(chID) - if latestErr == nil && latestID > 0 { - if rsErr := d.DB.UpdateReadState(userID, chID, latestID); rsErr != nil { - slog.Warn("handleChannelFocusV2 UpdateReadState", "err", rsErr, "user_id", userID, "channel_id", chID) + _, err := d.ChannelSvc.HandleChannelFocus(info.UserID, chID) + if err != nil { + if errors.Is(err, service.ErrForbidden) { + return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "access denied"}} } + return Result{} // silently drop other errors } return Result{SetChannelID: &chID} diff --git a/Server/ws/handlers_reaction.go b/Server/ws/handlers_reaction.go index b8c5d53a..fe5053fa 100644 --- a/Server/ws/handlers_reaction.go +++ b/Server/ws/handlers_reaction.go @@ -2,10 +2,8 @@ package ws import ( "context" - "fmt" - "log/slog" - "github.com/owncord/server/permissions" + "github.com/owncord/server/service" ) // registerReactionHandlers registers reaction_add and reaction_remove V2 handlers. @@ -33,90 +31,27 @@ func reactionV2Handler(add bool) HandlerV2 { emoji = c.Emoji() } - // Rate limit. - ratKey := fmt.Sprintf("reaction:%d", userID) - if d.Limiter != nil && !d.Limiter.Allow(ratKey, reactionRateLimit, reactionWindow) { - return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many reactions"}} - } - - // Validate fields. - if msgID <= 0 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}} - } - if emoji == "" { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji cannot be empty"}} - } - if len(emoji) > 32 { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji too long"}} - } - // Reject control characters (U+0000-U+001F, U+007F) to prevent injection. - for _, r := range emoji { - if r < 0x20 || r == 0x7F { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji contains invalid characters"}} - } - } - // Sanitize HTML to prevent stored XSS via emoji field. - if sanitized := sanitizer.Sanitize(emoji); sanitized != emoji { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "emoji contains invalid characters"}} - } - - // Look up message. - msg, err := d.DB.GetMessage(msgID) - if err != nil || msg == nil { - // Normalize: same error whether message doesn't exist or is in a - // channel the user can't see (prevents IDOR information leak). - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "reaction failed"}} - } - - // BUG-126: Reject reactions on soft-deleted messages. - if msg.Deleted { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "reaction failed"}} - } - - // Check channel type for DM-aware permission handling. - reactCh, chErr := d.DB.GetChannel(msg.ChannelID) - reactIsDM := chErr == nil && reactCh != nil && reactCh.Type == "dm" - - if reactIsDM { - ok, dmErr := d.DB.IsDMParticipant(userID, msg.ChannelID) - if dmErr != nil || !ok { - return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "reaction failed"}} - } - } else { - if denied := requirePerm(d.DB, d.Permissions, userID, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS"); denied != nil { - return *denied - } - } - - // Execute reaction. - action := "add" + var result *service.ReactionResult + var err error if add { - err = d.DB.AddReaction(msgID, userID, emoji) + result, err = d.MessageSvc.AddReaction(userID, msgID, emoji) } else { - action = "remove" - err = d.DB.RemoveReaction(msgID, userID, emoji) + result, err = d.MessageSvc.RemoveReaction(userID, msgID, emoji) } if err != nil { - // Sanitize: never leak raw DB constraint errors to client. - slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", userID, "err", err) - return Result{Error: ClientError{Code: ErrCodeConflict, Message: "reaction failed"}} + return serviceErrorToResult(err) } - reactionPayload := buildReactionUpdate(msgID, msg.ChannelID, userID, emoji, action) - if reactIsDM { - participantIDs, pErr := d.DB.GetDMParticipantIDs(msg.ChannelID) - if pErr != nil { - slog.Error("reactionV2Handler GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) - return Result{} - } + reactionPayload := buildReactionUpdate(result.MessageID, result.ChannelID, result.UserID, result.Emoji, result.Action) + if result.IsDM { return Result{Events: []Event{ReactionDMEvent{ - channelID: msg.ChannelID, - participantIDs: participantIDs, + channelID: result.ChannelID, + participantIDs: result.ParticipantIDs, payload: reactionPayload, }}} } return Result{Events: []Event{ReactionChannelEvent{ - channelID: msg.ChannelID, + channelID: result.ChannelID, payload: reactionPayload, }}} }