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
This commit is contained in:
Claude
2026-04-05 20:47:47 +00:00
parent 1c5fbbc246
commit e54ad46079
3 changed files with 99 additions and 445 deletions
+74 -288
View File
@@ -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()}}
}
}
+14 -81
View File
@@ -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}
+11 -76
View File
@@ -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,
}}}
}