mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
+74
-288
@@ -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()}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user