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