mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
EditMessage authorized the non-DM path with permissions.SendMessages alone while every sibling message sink requires ReadMessages plus the mutate bit, so a user denied READ_MESSAGES could still rewrite an old post and have the edit broadcast to the channel. The edit gate now calls the existing checkSendPermission helper and collapses its error into the sink's pre-existing opaque ErrForbidden, so the reply stays a non-oracle. Verified by a panel of agents; the added test fails against the unpatched tree, showing the edit succeeded before the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
796 lines
28 KiB
Go
796 lines
28 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"github.com/owncord/server/telemetry"
|
|
)
|
|
|
|
// sanitizer is the shared HTML sanitization policy (strips all tags).
|
|
var sanitizer = bluemonday.StrictPolicy()
|
|
|
|
// maxMessageLen is the maximum message length in runes.
|
|
const maxMessageLen = 4000
|
|
|
|
// Common service-layer errors.
|
|
var (
|
|
ErrRateLimited = errors.New("rate limited")
|
|
ErrBadRequest = errors.New("bad request")
|
|
ErrNotFound = errors.New("not found")
|
|
ErrForbidden = errors.New("forbidden")
|
|
ErrInternal = errors.New("internal error")
|
|
ErrSlowMode = errors.New("slow mode")
|
|
ErrConflict = errors.New("conflict")
|
|
ErrBlocked = errors.New("blocked")
|
|
ErrDeletedMessage = errors.New("message is deleted")
|
|
)
|
|
|
|
// SendMessageParams contains validated input for sending a message.
|
|
type SendMessageParams struct {
|
|
ChannelID int64
|
|
UserID int64
|
|
Username string
|
|
Avatar *string
|
|
RoleName string
|
|
Content string // raw, will be sanitized
|
|
ReplyTo *int64
|
|
AttachmentIDs []string
|
|
}
|
|
|
|
// SendMessageResult contains the output of a successful message send.
|
|
type SendMessageResult struct {
|
|
MessageID int64
|
|
Timestamp string
|
|
Content string // sanitized content
|
|
IsDM bool
|
|
Channel *db.Channel
|
|
|
|
// DM-specific fields populated when IsDM is true.
|
|
ParticipantIDs []int64
|
|
SenderUser *db.User // for dm_channel_open events
|
|
OpenedDMFor []int64 // participant IDs that had their DM opened
|
|
|
|
// Attachment data for broadcast.
|
|
Attachments []db.AttachmentInfo
|
|
}
|
|
|
|
// EditMessageResult contains the output of a successful message edit.
|
|
type EditMessageResult struct {
|
|
MessageID int64
|
|
ChannelID int64
|
|
Content string
|
|
EditedAt string
|
|
IsDM bool
|
|
// DM-specific.
|
|
ParticipantIDs []int64
|
|
}
|
|
|
|
// DeleteMessageResult contains the output of a successful message delete.
|
|
type DeleteMessageResult struct {
|
|
MessageID int64
|
|
ChannelID int64
|
|
IsDM bool
|
|
IsMod bool
|
|
// DM-specific.
|
|
ParticipantIDs []int64
|
|
}
|
|
|
|
// ReactionResult contains the output of a reaction add/remove.
|
|
type ReactionResult struct {
|
|
MessageID int64
|
|
ChannelID int64
|
|
UserID int64
|
|
Emoji string
|
|
Action string // "add" or "remove"
|
|
IsDM bool
|
|
// DM-specific.
|
|
ParticipantIDs []int64
|
|
}
|
|
|
|
// MessageService handles message-related business logic including
|
|
// send, edit, delete, reactions, pins, and search.
|
|
type MessageService struct {
|
|
st Store
|
|
perms *PermissionService
|
|
limiter *auth.RateLimiter
|
|
}
|
|
|
|
// NewMessageService creates a MessageService.
|
|
func NewMessageService(st Store, perms *PermissionService, limiter *auth.RateLimiter) *MessageService {
|
|
return &MessageService{
|
|
st: st,
|
|
perms: perms,
|
|
limiter: limiter,
|
|
}
|
|
}
|
|
|
|
// SendMessage validates, persists, and prepares broadcast data for a new message.
|
|
// Callers are responsible for emitting the appropriate events.
|
|
func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) (*SendMessageResult, error) {
|
|
// Phase B Step 8 — wrap the public service entrypoint in a tracing span
|
|
// and a duration histogram. Both are no-ops in the default build.
|
|
ctx, span := telemetry.GlobalTracer("service/message").Start(ctx, "MessageService.SendMessage",
|
|
telemetry.Int64("user_id", p.UserID),
|
|
telemetry.Int64("channel_id", p.ChannelID),
|
|
)
|
|
start := time.Now()
|
|
defer func() {
|
|
telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start,
|
|
telemetry.String("method", "SendMessage"))
|
|
span.End()
|
|
}()
|
|
|
|
// Rate limit.
|
|
ratKey := fmt.Sprintf("chat:%d", p.UserID)
|
|
if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) {
|
|
return nil, ErrRateLimited
|
|
}
|
|
|
|
if p.ChannelID <= 0 {
|
|
return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest)
|
|
}
|
|
|
|
ch, err := s.st.GetChannel(ctx, p.ChannelID)
|
|
if err != nil || ch == nil {
|
|
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
|
|
isDM := ch.Type == "dm"
|
|
|
|
// Permission check.
|
|
if err := s.checkSendPermission(ctx, p.UserID, p.ChannelID, ch.Type); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Slow mode (non-DM only).
|
|
if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) {
|
|
slowKey := fmt.Sprintf("slow:%d:%d", p.UserID, p.ChannelID)
|
|
if s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {
|
|
return nil, fmt.Errorf("%w: channel has %ds slow mode", ErrSlowMode, ch.SlowMode)
|
|
}
|
|
}
|
|
|
|
// Validate and sanitize content.
|
|
content, err := sanitizeContent(p.Content, len(p.AttachmentIDs) > 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Attachment permission (non-DM).
|
|
if !isDM && len(p.AttachmentIDs) > 0 {
|
|
if !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.AttachFiles) {
|
|
return nil, fmt.Errorf("%w: missing ATTACH_FILES permission", ErrForbidden)
|
|
}
|
|
}
|
|
|
|
// Persist message.
|
|
msgID, err := s.st.CreateMessage(ctx, p.ChannelID, p.UserID, content, p.ReplyTo)
|
|
if err != nil {
|
|
slog.Error("MessageService.SendMessage CreateMessage", "err", err)
|
|
return nil, fmt.Errorf("%w: failed to save message", ErrInternal)
|
|
}
|
|
|
|
// Link attachments. Ownership is enforced atomically inside the link
|
|
// UPDATE itself (uploader match + still unlinked), so another user's
|
|
// upload, an already-linked attachment, or a nonexistent id is skipped by
|
|
// the statement — no check-then-link race and no N+1 pre-verification.
|
|
var attachments []db.AttachmentInfo
|
|
if len(p.AttachmentIDs) > 0 {
|
|
linked, linkErr := s.st.LinkAttachmentsToMessage(ctx, msgID, p.UserID, p.AttachmentIDs)
|
|
if linkErr != nil {
|
|
slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID)
|
|
// Cleanup: soft-delete the message. The compensating delete must run
|
|
// even when the link failed because the request ctx was canceled.
|
|
if delErr := s.st.DeleteMessage(context.WithoutCancel(ctx), msgID, p.UserID, true); delErr != nil {
|
|
slog.Error("MessageService.SendMessage DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID)
|
|
}
|
|
return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal)
|
|
}
|
|
if linked < int64(len(p.AttachmentIDs)) {
|
|
slog.Warn("MessageService.SendMessage: skipped attachments (not owned, already linked, or missing)",
|
|
"msg_id", msgID, "user_id", p.UserID, "requested", len(p.AttachmentIDs), "linked", linked)
|
|
}
|
|
if linked > 0 {
|
|
attMap, attErr := s.st.GetAttachmentsByMessageIDs(ctx, []int64{msgID})
|
|
if attErr != nil {
|
|
slog.Error("MessageService.SendMessage GetAttachments", "err", attErr)
|
|
} else {
|
|
attachments = attMap[msgID]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch message for timestamp. Post-commit: the message exists whether or
|
|
// not the sender is still connected, so the refetch that feeds the fan-out
|
|
// must not die with the sender's ctx.
|
|
msg, err := s.st.GetMessage(context.WithoutCancel(ctx), msgID)
|
|
if err != nil || msg == nil {
|
|
slog.Error("MessageService.SendMessage GetMessage after create", "err", err)
|
|
return nil, fmt.Errorf("%w: failed to retrieve message", ErrInternal)
|
|
}
|
|
|
|
result := &SendMessageResult{
|
|
MessageID: msgID,
|
|
Timestamp: msg.Timestamp,
|
|
Content: content,
|
|
IsDM: isDM,
|
|
Channel: ch,
|
|
Attachments: attachments,
|
|
}
|
|
|
|
// DM path: open DM for recipients.
|
|
if isDM {
|
|
participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, p.ChannelID)
|
|
if pErr != nil {
|
|
slog.Error("MessageService.SendMessage GetDMParticipantIDs", "err", pErr, "channel_id", p.ChannelID)
|
|
return result, nil // Message saved, skip DM side effects.
|
|
}
|
|
result.ParticipantIDs = participantIDs
|
|
|
|
sender, _ := s.st.GetUserByID(ctx, p.UserID)
|
|
result.SenderUser = sender
|
|
|
|
for _, pid := range participantIDs {
|
|
if pid == p.UserID {
|
|
continue
|
|
}
|
|
if openErr := s.st.OpenDM(ctx, pid, p.ChannelID); openErr != nil {
|
|
slog.Error("MessageService.SendMessage OpenDM", "err", openErr, "recipient_id", pid, "channel_id", p.ChannelID)
|
|
continue
|
|
}
|
|
result.OpenedDMFor = append(result.OpenedDMFor, pid)
|
|
}
|
|
}
|
|
|
|
slog.Debug("message sent", "user", p.Username, "channel_id", p.ChannelID, "msg_id", msgID)
|
|
return result, nil
|
|
}
|
|
|
|
// EditMessage validates and persists a message edit.
|
|
func (s *MessageService) EditMessage(ctx context.Context, userID, msgID int64, rawContent string) (*EditMessageResult, error) {
|
|
// Rate limit.
|
|
ratKey := fmt.Sprintf("chat_edit:%d", userID)
|
|
if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) {
|
|
return nil, ErrRateLimited
|
|
}
|
|
|
|
if msgID <= 0 {
|
|
return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest)
|
|
}
|
|
|
|
content, err := sanitizeContent(rawContent, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Fetch message.
|
|
msg, err := s.st.GetMessage(ctx, msgID)
|
|
if err != nil || msg == nil {
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
|
}
|
|
if msg.Deleted {
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrDeletedMessage)
|
|
}
|
|
|
|
// Channel type for DM-aware permissions.
|
|
ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)
|
|
chanType := ""
|
|
if chErr == nil && ch != nil {
|
|
chanType = ch.Type
|
|
}
|
|
isDM := chanType == "dm"
|
|
|
|
if isDM {
|
|
ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)
|
|
if dmErr != nil || !ok {
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
|
}
|
|
if blkErr := requireDMNotBlocked(ctx, s.st, userID, msg.ChannelID); blkErr != nil {
|
|
return nil, blkErr
|
|
}
|
|
} else if permErr := s.checkSendPermission(ctx, userID, msg.ChannelID, chanType); permErr != nil {
|
|
// An edit injects new text into the channel and is fanned out to every
|
|
// reader, so it must clear the same gate as a send rather than
|
|
// SEND_MESSAGES alone: READ_MESSAGES so a role locked out of a private
|
|
// channel (the panel's "Can access" toggle denies
|
|
// READ_MESSAGES|CONNECT_VOICE and leaves SEND_MESSAGES intact) cannot
|
|
// rewrite its old posts, and the announcement rule so a demoted
|
|
// moderator cannot rewrite a trusted broadcast. Mirrors DeleteMessage,
|
|
// SetMessagePinned and handleReaction, which already require
|
|
// READ_MESSAGES. The reason is collapsed into this sink's single opaque
|
|
// error so the reply stays an ownership/permission non-oracle.
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
|
}
|
|
|
|
// EditMessage checks ownership internally.
|
|
if err := s.st.EditMessage(ctx, msgID, userID, content); err != nil {
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
|
}
|
|
|
|
// Re-fetch for updated edited_at timestamp. Post-commit: must not die with
|
|
// the editor's ctx or the committed edit is never broadcast.
|
|
msg, err = s.st.GetMessage(context.WithoutCancel(ctx), msgID)
|
|
if err != nil || msg == nil {
|
|
slog.Error("MessageService.EditMessage GetMessage after edit", "err", err, "msg_id", msgID)
|
|
return nil, fmt.Errorf("%w: edit saved but broadcast failed", ErrInternal)
|
|
}
|
|
|
|
editedAt := ""
|
|
if msg.EditedAt != nil {
|
|
editedAt = *msg.EditedAt
|
|
}
|
|
|
|
result := &EditMessageResult{
|
|
MessageID: msgID,
|
|
ChannelID: msg.ChannelID,
|
|
Content: content,
|
|
EditedAt: editedAt,
|
|
IsDM: isDM,
|
|
}
|
|
|
|
if isDM {
|
|
participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)
|
|
if pErr != nil {
|
|
slog.Error("MessageService.EditMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
|
|
} else {
|
|
result.ParticipantIDs = participantIDs
|
|
}
|
|
}
|
|
|
|
slog.Debug("message edited", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID)
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteMessage validates and soft-deletes a message.
|
|
func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64) (*DeleteMessageResult, error) {
|
|
// Rate limit.
|
|
ratKey := fmt.Sprintf("chat_delete:%d", userID)
|
|
if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) {
|
|
return nil, ErrRateLimited
|
|
}
|
|
|
|
if msgID <= 0 {
|
|
return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest)
|
|
}
|
|
|
|
msg, err := s.st.GetMessage(ctx, msgID)
|
|
if err != nil || msg == nil {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
|
|
ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)
|
|
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
|
|
|
var isMod bool
|
|
if isDM {
|
|
ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)
|
|
if dmErr != nil || !ok {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
} else {
|
|
// Require READ_MESSAGES alongside MANAGE_MESSAGES (and alongside
|
|
// SEND_MESSAGES on the author path) so a role explicitly denied access to
|
|
// a channel cannot delete messages in it. Mirrors handleReaction and
|
|
// checkSendPermission, which both require ReadMessages for non-DM channels.
|
|
isMsgOwner := msg.UserID == userID
|
|
canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.ManageMessages)
|
|
canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.SendMessages))
|
|
if !canDelete {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
// db.DeleteMessage skips the ownership check when ismod is true, so the
|
|
// moderation flag must reuse the decision made above rather than
|
|
// re-checking MANAGE_MESSAGES without READ_MESSAGES.
|
|
isMod = canManage
|
|
}
|
|
|
|
if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
|
|
slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod)
|
|
// Audit rows must survive a request canceled after the delete committed.
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "message_delete", "message", msgID,
|
|
fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod))
|
|
|
|
result := &DeleteMessageResult{
|
|
MessageID: msgID,
|
|
ChannelID: msg.ChannelID,
|
|
IsDM: isDM,
|
|
IsMod: isMod,
|
|
}
|
|
|
|
if isDM {
|
|
participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)
|
|
if pErr != nil {
|
|
slog.Error("MessageService.DeleteMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
|
|
} else {
|
|
result.ParticipantIDs = participantIDs
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// AddReaction adds a reaction to a message.
|
|
func (s *MessageService) AddReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) {
|
|
return s.handleReaction(ctx, userID, msgID, emoji, true)
|
|
}
|
|
|
|
// RemoveReaction removes a reaction from a message.
|
|
func (s *MessageService) RemoveReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) {
|
|
return s.handleReaction(ctx, userID, msgID, emoji, false)
|
|
}
|
|
|
|
func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64, emoji string, add bool) (*ReactionResult, error) {
|
|
// Rate limit.
|
|
ratKey := fmt.Sprintf("reaction:%d", userID)
|
|
if s.limiter != nil && !s.limiter.Allow(ratKey, 5, time.Second) {
|
|
return nil, ErrRateLimited
|
|
}
|
|
|
|
if msgID <= 0 {
|
|
return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest)
|
|
}
|
|
if emoji == "" || len([]rune(emoji)) > 32 {
|
|
return nil, fmt.Errorf("%w: invalid emoji", ErrBadRequest)
|
|
}
|
|
// Reject control characters.
|
|
for _, r := range emoji {
|
|
if r <= 0x1F || r == 0x7F {
|
|
return nil, fmt.Errorf("%w: emoji contains control characters", ErrBadRequest)
|
|
}
|
|
}
|
|
// Sanitize check.
|
|
if sanitizer.Sanitize(emoji) != emoji {
|
|
return nil, fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest)
|
|
}
|
|
|
|
msg, err := s.st.GetMessage(ctx, msgID)
|
|
if err != nil || msg == nil {
|
|
return nil, fmt.Errorf("%w: message not found", ErrBadRequest)
|
|
}
|
|
if msg.Deleted {
|
|
return nil, fmt.Errorf("%w: cannot react to deleted message", ErrBadRequest)
|
|
}
|
|
|
|
ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)
|
|
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
|
|
|
if isDM {
|
|
ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)
|
|
if dmErr != nil || !ok {
|
|
return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest)
|
|
}
|
|
if blkErr := requireDMNotBlocked(ctx, s.st, userID, msg.ChannelID); blkErr != nil {
|
|
return nil, blkErr
|
|
}
|
|
} else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) {
|
|
// Require READ_MESSAGES in addition to ADD_REACTIONS so a user cannot
|
|
// react in a channel they cannot read. Mirrors checkSendPermission,
|
|
// which requires ReadMessages|SendMessages for non-DM sends.
|
|
return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden)
|
|
}
|
|
|
|
action := "add"
|
|
if add {
|
|
if err := s.st.AddReaction(ctx, msgID, userID, emoji); err != nil {
|
|
slog.Warn("MessageService.AddReaction", "err", err, "msg_id", msgID, "user_id", userID)
|
|
return nil, fmt.Errorf("%w: reaction already exists", ErrConflict)
|
|
}
|
|
} else {
|
|
action = "remove"
|
|
if err := s.st.RemoveReaction(ctx, msgID, userID, emoji); err != nil {
|
|
slog.Warn("MessageService.RemoveReaction", "err", err, "msg_id", msgID, "user_id", userID)
|
|
return nil, fmt.Errorf("%w: reaction not found", ErrBadRequest)
|
|
}
|
|
}
|
|
|
|
result := &ReactionResult{
|
|
MessageID: msgID,
|
|
ChannelID: msg.ChannelID,
|
|
UserID: userID,
|
|
Emoji: emoji,
|
|
Action: action,
|
|
IsDM: isDM,
|
|
}
|
|
|
|
if isDM {
|
|
participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)
|
|
if pErr != nil {
|
|
slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
|
|
} else {
|
|
result.ParticipantIDs = participantIDs
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetMessages retrieves paginated messages for a channel with permission checks.
|
|
func (s *MessageService) GetMessages(ctx context.Context, userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) {
|
|
if channelID <= 0 {
|
|
return nil, false, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
|
|
}
|
|
|
|
ch, err := s.st.GetChannel(ctx, channelID)
|
|
if err != nil || ch == nil {
|
|
return nil, false, fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
|
|
// Permission check.
|
|
if ch.Type == "dm" {
|
|
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
|
|
if err != nil || !ok {
|
|
return nil, false, fmt.Errorf("%w: access denied", ErrNotFound)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) {
|
|
return nil, false, fmt.Errorf("%w: access denied", ErrForbidden)
|
|
}
|
|
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
// Fetch one extra to detect has_more.
|
|
msgs, err := s.st.GetMessagesForAPI(ctx, channelID, before, limit+1, userID)
|
|
if err != nil {
|
|
slog.Error("MessageService.GetMessages", "err", err, "channel_id", channelID)
|
|
return nil, false, fmt.Errorf("%w: failed to fetch messages", ErrInternal)
|
|
}
|
|
|
|
hasMore := len(msgs) > limit
|
|
if hasMore {
|
|
msgs = msgs[:limit]
|
|
}
|
|
|
|
return msgs, hasMore, nil
|
|
}
|
|
|
|
// SearchMessages performs full-text search across accessible channels.
|
|
func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) {
|
|
if query == "" {
|
|
return nil, fmt.Errorf("%w: query cannot be empty", ErrBadRequest)
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
// Single-channel search.
|
|
if channelID != nil && *channelID > 0 {
|
|
ch, err := s.st.GetChannel(ctx, *channelID)
|
|
if err != nil || ch == nil {
|
|
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
if ch.Type == "dm" {
|
|
ok, err := s.st.IsDMParticipant(ctx, userID, *channelID)
|
|
if err != nil || !ok {
|
|
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(ctx, userID, *channelID, permissions.ReadMessages) {
|
|
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
|
}
|
|
results, err := s.st.SearchMessages(ctx, query, channelID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// Global search: build accessible channel list.
|
|
accessibleIDs, err := s.GetAccessibleChannelIDs(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(accessibleIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
results, err := s.st.SearchMessagesInChannels(ctx, query, accessibleIDs, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// GetPinnedMessages retrieves pinned messages for a channel.
|
|
func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelID int64) ([]db.MessageAPIResponse, error) {
|
|
if channelID <= 0 {
|
|
return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
|
|
}
|
|
ch, err := s.st.GetChannel(ctx, channelID)
|
|
if err != nil || ch == nil {
|
|
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
if ch.Type == "dm" {
|
|
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
|
|
if err != nil || !ok {
|
|
return nil, fmt.Errorf("%w: access denied", ErrNotFound)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) {
|
|
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
|
}
|
|
msgs, err := s.st.GetPinnedMessages(ctx, channelID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to fetch pinned messages: %v", ErrInternal, err)
|
|
}
|
|
return msgs, nil
|
|
}
|
|
|
|
// SetMessagePinned pins or unpins a message.
|
|
func (s *MessageService) SetMessagePinned(ctx context.Context, userID, channelID, msgID int64, pinned bool) error {
|
|
if channelID <= 0 || msgID <= 0 {
|
|
return fmt.Errorf("%w: invalid IDs", ErrBadRequest)
|
|
}
|
|
ch, err := s.st.GetChannel(ctx, channelID)
|
|
if err != nil || ch == nil {
|
|
return fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
if ch.Type == "dm" {
|
|
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
|
|
if err != nil || !ok {
|
|
return fmt.Errorf("%w: access denied", ErrNotFound)
|
|
}
|
|
if blkErr := requireDMNotBlocked(ctx, s.st, userID, channelID); blkErr != nil {
|
|
return blkErr
|
|
}
|
|
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.ManageMessages) {
|
|
// Require READ_MESSAGES alongside MANAGE_MESSAGES so a role locked out
|
|
// of a private channel cannot mutate its pins — the admin panel's
|
|
// "Can access" toggle denies READ_MESSAGES|CONNECT_VOICE and leaves
|
|
// MANAGE_MESSAGES intact. Mirrors handleReaction and checkSendPermission.
|
|
return fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden)
|
|
}
|
|
// Verify message belongs to this channel.
|
|
msg, err := s.st.GetMessage(ctx, msgID)
|
|
if err != nil || msg == nil || msg.ChannelID != channelID {
|
|
return fmt.Errorf("%w: message not found in this channel", ErrNotFound)
|
|
}
|
|
return s.st.SetMessagePinned(ctx, msgID, pinned)
|
|
}
|
|
|
|
// GetAccessibleChannelIDs returns all channel IDs the user can read.
|
|
func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int64) ([]int64, error) {
|
|
channels, err := s.st.ListChannels(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to list channels: %v", ErrInternal, err)
|
|
}
|
|
|
|
role, err := s.perms.GetRoleForUser(ctx, userID)
|
|
if err != nil || role == nil {
|
|
return nil, fmt.Errorf("%w: failed to get role: %v", ErrInternal, err)
|
|
}
|
|
|
|
var overrides map[int64]db.ChannelOverride
|
|
if !permissions.HasAdmin(role.Permissions) {
|
|
var overrideErr error
|
|
overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(ctx, role.ID)
|
|
if overrideErr != nil {
|
|
return nil, fmt.Errorf("%w: failed to fetch channel overrides: %v", ErrInternal, overrideErr)
|
|
}
|
|
}
|
|
|
|
// Single visibility predicate shared with REST ListVisibleChannels and the
|
|
// ws ready payload, so no site can drift.
|
|
visibleIDs := s.perms.Checker().VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
|
|
var ids []int64
|
|
for i := range channels {
|
|
if visibleIDs[channels[i].ID] {
|
|
ids = append(ids, channels[i].ID)
|
|
}
|
|
}
|
|
|
|
// Also include DM channels the user participates in.
|
|
dmChannels, err := s.st.GetUserDMChannels(ctx, userID)
|
|
if err == nil {
|
|
for _, dmc := range dmChannels {
|
|
ids = append(ids, dmc.ChannelID)
|
|
}
|
|
}
|
|
|
|
return ids, nil
|
|
}
|
|
|
|
// CanPost reports whether userID may post into channelID, applying the same
|
|
// checks as a real message send: channel permissions via the cached checker
|
|
// for regular channels; participant membership AND block status for DMs.
|
|
// Exists so gates outside the send flow (the plugin broadcast path) share
|
|
// exactly this policy instead of hand-rolling a weaker copy.
|
|
func (s *MessageService) CanPost(ctx context.Context, userID, channelID int64) error {
|
|
ch, err := s.st.GetChannel(ctx, channelID)
|
|
if err != nil || ch == nil {
|
|
return fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
return s.checkSendPermission(ctx, userID, channelID, ch.Type)
|
|
}
|
|
|
|
// checkSendPermission validates send permission for a channel of the given
|
|
// type. Announcement channels are readable by anyone with READ_MESSAGES but
|
|
// only postable by users with MANAGE_MESSAGES (posting is restricted to
|
|
// moderators/admins); all other non-DM channels require SEND_MESSAGES.
|
|
func (s *MessageService) checkSendPermission(ctx context.Context, userID, channelID int64, chanType string) error {
|
|
isDM := chanType == "dm"
|
|
if isDM {
|
|
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, err)
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: not a participant in this DM", ErrForbidden)
|
|
}
|
|
return requireDMNotBlocked(ctx, s.st, userID, channelID)
|
|
}
|
|
if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.SendMessages) {
|
|
return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden)
|
|
}
|
|
// Announcement channels: posting is restricted to users who can manage
|
|
// messages, even though everyone with READ_MESSAGES can view them.
|
|
if chanType == "announcement" && !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) {
|
|
return fmt.Errorf("%w: announcement channels require MANAGE_MESSAGES to post", ErrForbidden)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// requireDMNotBlocked reports ErrBlocked when userID and the other participant
|
|
// of DM channelID have blocked each other in either direction.
|
|
//
|
|
// It is the single block-check implementation, called from every DM
|
|
// interaction sink — send, edit, react, pin and typing. Enforcing it on the
|
|
// send path alone left a blocked user an open channel to the blocker: editing
|
|
// an already-sent message fans MessageEditedDMEvent out to every participant,
|
|
// so arbitrary new text still reached the person who blocked them, and
|
|
// reactions and typing indicators did the same.
|
|
//
|
|
// Callers keep their own IsDMParticipant check. Its failure mode is
|
|
// deliberately different per sink (ErrForbidden for edit, ErrBadRequest for
|
|
// reactions, ErrNotFound for pins so a foreign DM's existence stays hidden)
|
|
// and flattening them here would change client-visible status codes.
|
|
//
|
|
// A GetDMRecipient lookup failure or a DM with no other participant is treated
|
|
// as "not blocked", carrying over the posture the send path has always had
|
|
// rather than newly failing closed on all five sinks at once.
|
|
func requireDMNotBlocked(ctx context.Context, st Store, userID, channelID int64) error {
|
|
recipient, err := st.GetDMRecipient(ctx, channelID, userID)
|
|
if err != nil || recipient == nil {
|
|
return nil //nolint:nilerr // carries over checkSendPermission's posture: a lookup failure or a DM with no other participant is not a block
|
|
}
|
|
blocked, blkErr := st.IsEitherBlocked(ctx, userID, recipient.ID)
|
|
if blkErr != nil {
|
|
return fmt.Errorf("%w: failed to check block status: %v", ErrInternal, blkErr)
|
|
}
|
|
if blocked {
|
|
return fmt.Errorf("%w: user is blocked", ErrBlocked)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sanitizeContent validates and sanitizes message content.
|
|
func sanitizeContent(raw string, allowEmpty bool) (string, error) {
|
|
if len(raw) > maxMessageLen*4 {
|
|
return "", fmt.Errorf("%w: message content exceeds maximum length", ErrBadRequest)
|
|
}
|
|
content := sanitizer.Sanitize(raw)
|
|
if content == "" && !allowEmpty {
|
|
return "", fmt.Errorf("%w: message content cannot be empty", ErrBadRequest)
|
|
}
|
|
if utf8.RuneCountInString(content) > maxMessageLen {
|
|
return "", fmt.Errorf("%w: message content exceeds maximum length of %d characters", ErrBadRequest, maxMessageLen)
|
|
}
|
|
return content, nil
|
|
}
|