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 ( import (
"context" "context"
"fmt" "errors"
"log/slog"
"time"
"github.com/owncord/server/permissions" "github.com/owncord/server/service"
) )
// registerChatHandlers registers all chat-related V2 message handlers. // registerChatHandlers registers all chat-related V2 message handlers.
@@ -16,346 +14,134 @@ func registerChatHandlers(r *HandlerRegistry, deps ChatDeps) {
r.RegisterV2(MsgTypeChatDelete, handleChatDeleteV2, deps) 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 { func handleChatSendV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(ChatDeps) d := deps.(ChatDeps)
sendCmd := cmd.(ChatSendCmd) sendCmd := cmd.(ChatSendCmd)
userID := info.UserID
channelID := sendCmd.ChannelID()
// Rate limit. result, err := d.MessageSvc.SendMessage(service.SendMessageParams{
ratKey := fmt.Sprintf("chat:%d", userID) ChannelID: sendCmd.ChannelID(),
if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) { UserID: info.UserID,
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many messages"}} Username: info.Username,
} Avatar: info.Avatar,
RoleName: info.RoleName,
if channelID <= 0 { Content: sendCmd.Content(),
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "channel_id must be a positive integer"}} ReplyTo: sendCmd.ReplyTo(),
} AttachmentIDs: sendCmd.Attachments(),
})
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())
if err != nil { if err != nil {
slog.Error("ws handleChatSendV2 CreateMessage", "err", err) return serviceErrorToResult(err)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to save message"}}
} }
// Link attachments. // Build attachment data for broadcast.
var attData []map[string]any var attData []map[string]any
if len(attachmentIDs) > 0 { for _, ai := range result.Attachments {
linked, linkErr := d.DB.LinkAttachmentsToMessage(msgID, attachmentIDs) attData = append(attData, map[string]any{
if linkErr != nil { "id": ai.ID,
slog.Error("ws handleChatSendV2 LinkAttachments", "err", linkErr, "msg_id", msgID) "filename": ai.Filename,
if delErr := d.DB.DeleteMessage(msgID, userID, true); delErr != nil { "size": ai.Size,
slog.Error("ws handleChatSendV2 DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID) "mime": ai.Mime,
} "url": ai.URL,
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,
})
}
}
}
} }
// Fetch message for timestamp. reply := buildChatSendOK(info.ReqID, result.MessageID, result.Timestamp)
msg, err := d.DB.GetMessage(msgID) broadcast := buildChatMessage(result.MessageID, sendCmd.ChannelID(), info.UserID,
if err != nil || msg == nil { info.Username, info.Avatar, info.RoleName, result.Content, result.Timestamp,
slog.Error("ws handleChatSendV2 GetMessage after create", "err", err) sendCmd.ReplyTo(), attData)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to retrieve message"}}
}
slog.Debug("message sent", "user", info.Username, "channel_id", channelID, "msg_id", msgID) if !result.IsDM {
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 {
return Result{ return Result{
Reply: reply, 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. // DM path: build dm_channel_open events + 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}
}
var events []Event var events []Event
sender, _ := d.DB.GetUserByID(userID) if result.SenderUser != nil {
for _, pid := range participantIDs { for _, pid := range result.OpenedDMFor {
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 {
events = append(events, DMChannelOpenEvent{ events = append(events, DMChannelOpenEvent{
targetUserID: pid, targetUserID: pid,
payload: buildDMChannelOpen(channelID, sender), payload: buildDMChannelOpen(sendCmd.ChannelID(), result.SenderUser),
}) })
} }
} }
events = append(events, MessageSentDMEvent{ events = append(events, MessageSentDMEvent{
channelID: channelID, channelID: sendCmd.ChannelID(),
participantIDs: participantIDs, participantIDs: result.ParticipantIDs,
payload: broadcast, payload: broadcast,
}) })
return Result{Reply: reply, Events: events} return Result{Reply: reply, Events: events}
} }
// chatSendPermCheck validates send permission for DM and non-DM channels. // handleChatEditV2 processes a chat_edit command via the MessageService.
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.
func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(ChatDeps) d := deps.(ChatDeps)
editCmd := cmd.(ChatEditCmd) editCmd := cmd.(ChatEditCmd)
userID := info.UserID
msgID := editCmd.MessageID()
// Rate limit. result, err := d.MessageSvc.EditMessage(info.UserID, editCmd.MessageID(), editCmd.Content())
ratKey := fmt.Sprintf("chat_edit:%d", userID) if err != nil {
if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) { return serviceErrorToResult(err)
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many edits"}}
} }
if msgID <= 0 { editedPayload := buildChatEdited(result.MessageID, result.ChannelID, result.Content, result.EditedAt)
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}} if result.IsDM {
}
// 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{}
}
return Result{Events: []Event{MessageEditedDMEvent{ return Result{Events: []Event{MessageEditedDMEvent{
channelID: msg.ChannelID, channelID: result.ChannelID,
participantIDs: participantIDs, participantIDs: result.ParticipantIDs,
payload: editedPayload, payload: editedPayload,
}}} }}}
} }
return Result{Events: []Event{MessageEditedChannelEvent{ return Result{Events: []Event{MessageEditedChannelEvent{
channelID: msg.ChannelID, channelID: result.ChannelID,
payload: editedPayload, 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 { func handleChatDeleteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
d := deps.(ChatDeps) d := deps.(ChatDeps)
deleteCmd := cmd.(ChatDeleteCmd) deleteCmd := cmd.(ChatDeleteCmd)
userID := info.UserID
msgID := deleteCmd.MessageID()
// Rate limit. result, err := d.MessageSvc.DeleteMessage(info.UserID, deleteCmd.MessageID())
ratKey := fmt.Sprintf("chat_delete:%d", userID) if err != nil {
if d.Limiter != nil && !d.Limiter.Allow(ratKey, chatRateLimit, chatWindow) { return serviceErrorToResult(err)
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many deletes"}}
} }
if msgID <= 0 { deletedPayload := buildChatDeleted(result.MessageID, result.ChannelID)
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "message_id must be positive integer"}} if result.IsDM {
}
// 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{}
}
return Result{Events: []Event{MessageDeletedDMEvent{ return Result{Events: []Event{MessageDeletedDMEvent{
channelID: msg.ChannelID, channelID: result.ChannelID,
participantIDs: participantIDs, participantIDs: result.ParticipantIDs,
payload: deletedPayload, payload: deletedPayload,
}}} }}}
} }
return Result{Events: []Event{MessageDeletedChannelEvent{ return Result{Events: []Event{MessageDeletedChannelEvent{
channelID: msg.ChannelID, channelID: result.ChannelID,
payload: deletedPayload, 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 ( import (
"context" "context"
"fmt" "errors"
"log/slog"
"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. // registerPresenceHandlers registers presence, typing, and channel focus handlers.
// All three are V2 handlers. // All three are V2 handlers.
func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) { func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) {
@@ -30,39 +24,18 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R
channelID := typingCmd.ChannelID() channelID := typingCmd.ChannelID()
userID := info.UserID userID := info.UserID
// Rate limit. ch, err := d.ChannelSvc.HandleTyping(userID, channelID, d.Limiter)
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)
if err != nil || ch == nil { if err != nil || ch == nil {
return Result{} // silently drop for unknown channels return Result{} // silently drop
}
// 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
}
} }
payload := buildTypingMsg(channelID, userID, info.Username) payload := buildTypingMsg(channelID, userID, info.Username)
if ch.Type == "dm" { if ch.Type == "dm" {
// For DM channels, get participant IDs and send to each excluding sender. participantIDs, pErr := d.ChannelSvc.GetDMParticipantIDs(channelID)
participantIDs, pErr := d.DB.GetDMParticipantIDs(channelID)
if pErr != nil { if pErr != nil {
return Result{} // silently drop on error return Result{}
} }
// Build one TypingDMEvent per other participant (UserTargetedEvent routing).
var events []Event var events []Event
for _, pid := range participantIDs { for _, pid := range participantIDs {
if pid == userID { if pid == userID {
@@ -76,7 +49,6 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R
return Result{Events: events} return Result{Events: events}
} }
// Regular channel: ExcludeSenderEvent routing.
return Result{ return Result{
Events: []Event{ Events: []Event{
TypingChannelEvent{ TypingChannelEvent{
@@ -94,26 +66,12 @@ func handlePresenceV2(_ context.Context, cmd Command, info ClientInfo, deps any)
d := deps.(PresenceDeps) d := deps.(PresenceDeps)
presenceCmd := cmd.(PresenceUpdateCmd) presenceCmd := cmd.(PresenceUpdateCmd)
userID := info.UserID 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() 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{ return Result{
Events: []Event{ Events: []Event{
PresenceEvent{payload: buildPresenceMsg(userID, status)}, PresenceEvent{payload: buildPresenceMsg(userID, status)},
@@ -128,38 +86,13 @@ func handleChannelFocusV2(_ context.Context, cmd Command, info ClientInfo, deps
d := deps.(PresenceDeps) d := deps.(PresenceDeps)
focusCmd := cmd.(ChannelFocusCmd) focusCmd := cmd.(ChannelFocusCmd)
chID := focusCmd.ChannelID() chID := focusCmd.ChannelID()
userID := info.UserID
if chID <= 0 { _, err := d.ChannelSvc.HandleChannelFocus(info.UserID, chID)
return Result{} // silently drop invalid channel_id if err != nil {
} if errors.Is(err, service.ErrForbidden) {
return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "access denied"}}
// 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)
} }
return Result{} // silently drop other errors
} }
return Result{SetChannelID: &chID} return Result{SetChannelID: &chID}
+11 -76
View File
@@ -2,10 +2,8 @@ package ws
import ( import (
"context" "context"
"fmt"
"log/slog"
"github.com/owncord/server/permissions" "github.com/owncord/server/service"
) )
// registerReactionHandlers registers reaction_add and reaction_remove V2 handlers. // registerReactionHandlers registers reaction_add and reaction_remove V2 handlers.
@@ -33,90 +31,27 @@ func reactionV2Handler(add bool) HandlerV2 {
emoji = c.Emoji() emoji = c.Emoji()
} }
// Rate limit. var result *service.ReactionResult
ratKey := fmt.Sprintf("reaction:%d", userID) var err error
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"
if add { if add {
err = d.DB.AddReaction(msgID, userID, emoji) result, err = d.MessageSvc.AddReaction(userID, msgID, emoji)
} else { } else {
action = "remove" result, err = d.MessageSvc.RemoveReaction(userID, msgID, emoji)
err = d.DB.RemoveReaction(msgID, userID, emoji)
} }
if err != nil { if err != nil {
// Sanitize: never leak raw DB constraint errors to client. return serviceErrorToResult(err)
slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", userID, "err", err)
return Result{Error: ClientError{Code: ErrCodeConflict, Message: "reaction failed"}}
} }
reactionPayload := buildReactionUpdate(msgID, msg.ChannelID, userID, emoji, action) reactionPayload := buildReactionUpdate(result.MessageID, result.ChannelID, result.UserID, result.Emoji, result.Action)
if reactIsDM { if result.IsDM {
participantIDs, pErr := d.DB.GetDMParticipantIDs(msg.ChannelID)
if pErr != nil {
slog.Error("reactionV2Handler GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
return Result{}
}
return Result{Events: []Event{ReactionDMEvent{ return Result{Events: []Event{ReactionDMEvent{
channelID: msg.ChannelID, channelID: result.ChannelID,
participantIDs: participantIDs, participantIDs: result.ParticipantIDs,
payload: reactionPayload, payload: reactionPayload,
}}} }}}
} }
return Result{Events: []Event{ReactionChannelEvent{ return Result{Events: []Event{ReactionChannelEvent{
channelID: msg.ChannelID, channelID: result.ChannelID,
payload: reactionPayload, payload: reactionPayload,
}}} }}}
} }