mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
requireChannelBroadcastAccess went through RequireChannelAccess, whose DM branch checks only participant membership — a blocked user's plugin broadcast could reach the person who blocked them — and it issued a raw GetRoleByID per broadcast, bypassing the permission cache. The gate now delegates to MessageService.CanPost (extracted over checkSendPermission), so DM blocks, channel permissions, and future posting policy apply from exactly one place; fails closed when no service is wired. First brick of the permission-path unification. MemStore.GetDMRecipient gets an honest implementation so the block path is testable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
735 lines
23 KiB
Go
735 lines
23 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/store"
|
|
"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.Store
|
|
perms *PermissionService
|
|
limiter *auth.RateLimiter
|
|
}
|
|
|
|
// NewMessageService creates a MessageService.
|
|
func NewMessageService(st store.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(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(p.UserID, p.ChannelID, isDM); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Slow mode (non-DM only).
|
|
if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(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(p.UserID, p.ChannelID, permissions.AttachFiles) {
|
|
return nil, fmt.Errorf("%w: missing ATTACH_FILES permission", ErrForbidden)
|
|
}
|
|
}
|
|
|
|
// Persist message.
|
|
msgID, err := s.st.CreateMessage(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(msgID, p.UserID, p.AttachmentIDs)
|
|
if linkErr != nil {
|
|
slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID)
|
|
// Cleanup: soft-delete the message.
|
|
if delErr := s.st.DeleteMessage(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([]int64{msgID})
|
|
if attErr != nil {
|
|
slog.Error("MessageService.SendMessage GetAttachments", "err", attErr)
|
|
} else {
|
|
attachments = attMap[msgID]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch message for timestamp.
|
|
msg, err := s.st.GetMessage(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(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(p.UserID)
|
|
result.SenderUser = sender
|
|
|
|
for _, pid := range participantIDs {
|
|
if pid == p.UserID {
|
|
continue
|
|
}
|
|
if openErr := s.st.OpenDM(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(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(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(msg.ChannelID)
|
|
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
|
|
|
if isDM {
|
|
ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID)
|
|
if dmErr != nil || !ok {
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages) {
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
|
}
|
|
|
|
// EditMessage checks ownership internally.
|
|
if err := s.st.EditMessage(msgID, userID, content); err != nil {
|
|
return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden)
|
|
}
|
|
|
|
// Re-fetch for updated edited_at timestamp.
|
|
msg, err = s.st.GetMessage(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(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(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(msgID)
|
|
if err != nil || msg == nil {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
|
|
ch, chErr := s.st.GetChannel(msg.ChannelID)
|
|
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
|
|
|
if isDM {
|
|
ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID)
|
|
if dmErr != nil || !ok {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
} else {
|
|
isMsgOwner := msg.UserID == userID
|
|
canManage := s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages)
|
|
canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages))
|
|
if !canDelete {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
}
|
|
|
|
isMod := !isDM && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages)
|
|
if err := s.st.DeleteMessage(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)
|
|
_ = s.st.LogAudit(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(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(userID, msgID int64, emoji string) (*ReactionResult, error) {
|
|
return s.handleReaction(userID, msgID, emoji, true)
|
|
}
|
|
|
|
// RemoveReaction removes a reaction from a message.
|
|
func (s *MessageService) RemoveReaction(userID, msgID int64, emoji string) (*ReactionResult, error) {
|
|
return s.handleReaction(userID, msgID, emoji, false)
|
|
}
|
|
|
|
func (s *MessageService) handleReaction(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(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(msg.ChannelID)
|
|
isDM := chErr == nil && ch != nil && ch.Type == "dm"
|
|
|
|
if isDM {
|
|
ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID)
|
|
if dmErr != nil || !ok {
|
|
return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(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(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(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(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(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(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(userID, channelID)
|
|
if err != nil || !ok {
|
|
return nil, false, fmt.Errorf("%w: access denied", ErrNotFound)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(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(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(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(*channelID)
|
|
if err != nil || ch == nil {
|
|
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
if ch.Type == "dm" {
|
|
ok, err := s.st.IsDMParticipant(userID, *channelID)
|
|
if err != nil || !ok {
|
|
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(userID, *channelID, permissions.ReadMessages) {
|
|
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
|
}
|
|
results, err := s.st.SearchMessages(query, channelID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: search failed", ErrInternal)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// Global search: build accessible channel list.
|
|
accessibleIDs, err := s.GetAccessibleChannelIDs(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(accessibleIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
results, err := s.st.SearchMessagesInChannels(query, accessibleIDs, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: search failed", ErrInternal)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// GetPinnedMessages retrieves pinned messages for a channel.
|
|
func (s *MessageService) GetPinnedMessages(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(channelID)
|
|
if err != nil || ch == nil {
|
|
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
if ch.Type == "dm" {
|
|
ok, err := s.st.IsDMParticipant(userID, channelID)
|
|
if err != nil || !ok {
|
|
return nil, fmt.Errorf("%w: access denied", ErrNotFound)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) {
|
|
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
|
|
}
|
|
msgs, err := s.st.GetPinnedMessages(channelID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to fetch pinned messages", ErrInternal)
|
|
}
|
|
return msgs, nil
|
|
}
|
|
|
|
// SetMessagePinned pins or unpins a message.
|
|
func (s *MessageService) SetMessagePinned(userID, channelID, msgID int64, pinned bool) error {
|
|
if channelID <= 0 || msgID <= 0 {
|
|
return fmt.Errorf("%w: invalid IDs", ErrBadRequest)
|
|
}
|
|
ch, err := s.st.GetChannel(channelID)
|
|
if err != nil || ch == nil {
|
|
return fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
if ch.Type == "dm" {
|
|
ok, err := s.st.IsDMParticipant(userID, channelID)
|
|
if err != nil || !ok {
|
|
return fmt.Errorf("%w: access denied", ErrNotFound)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) {
|
|
return fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden)
|
|
}
|
|
// Verify message belongs to this channel.
|
|
msg, err := s.st.GetMessage(msgID)
|
|
if err != nil || msg == nil || msg.ChannelID != channelID {
|
|
return fmt.Errorf("%w: message not found in this channel", ErrNotFound)
|
|
}
|
|
return s.st.SetMessagePinned(msgID, pinned)
|
|
}
|
|
|
|
// GetAccessibleChannelIDs returns all channel IDs the user can read.
|
|
func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) {
|
|
channels, err := s.st.ListChannels()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to list channels", ErrInternal)
|
|
}
|
|
|
|
role, err := s.perms.GetRoleForUser(userID)
|
|
if err != nil || role == nil {
|
|
return nil, fmt.Errorf("%w: failed to get role", ErrInternal)
|
|
}
|
|
|
|
isAdmin := permissions.HasAdmin(role.Permissions)
|
|
var overrides map[int64]db.ChannelOverride
|
|
if !isAdmin {
|
|
var overrideErr error
|
|
overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(role.ID)
|
|
if overrideErr != nil {
|
|
return nil, fmt.Errorf("%w: failed to fetch channel overrides", ErrInternal)
|
|
}
|
|
if overrides == nil {
|
|
overrides = make(map[int64]db.ChannelOverride)
|
|
}
|
|
}
|
|
|
|
var ids []int64
|
|
for i := range channels {
|
|
if channels[i].Type == "dm" {
|
|
continue
|
|
}
|
|
if isAdmin {
|
|
ids = append(ids, channels[i].ID)
|
|
continue
|
|
}
|
|
o := overrides[channels[i].ID]
|
|
effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny)
|
|
if effective&permissions.ReadMessages == permissions.ReadMessages {
|
|
ids = append(ids, channels[i].ID)
|
|
}
|
|
}
|
|
|
|
// Also include DM channels the user participates in.
|
|
dmChannels, err := s.st.GetUserDMChannels(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(userID, channelID int64) error {
|
|
ch, err := s.st.GetChannel(channelID)
|
|
if err != nil || ch == nil {
|
|
return fmt.Errorf("%w: channel not found", ErrNotFound)
|
|
}
|
|
return s.checkSendPermission(userID, channelID, ch.Type == "dm")
|
|
}
|
|
|
|
// checkSendPermission validates send permission for DM and non-DM channels.
|
|
func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error {
|
|
if isDM {
|
|
ok, err := s.st.IsDMParticipant(userID, channelID)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to check DM participation", ErrInternal)
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: not a participant in this DM", ErrForbidden)
|
|
}
|
|
recipient, err := s.st.GetDMRecipient(channelID, userID)
|
|
if err == nil && recipient != nil {
|
|
blocked, blkErr := s.st.IsEitherBlocked(userID, recipient.ID)
|
|
if blkErr != nil {
|
|
return fmt.Errorf("%w: failed to check block status", ErrInternal)
|
|
}
|
|
if blocked {
|
|
return fmt.Errorf("%w: cannot send messages — user is blocked", ErrBlocked)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages|permissions.SendMessages) {
|
|
return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden)
|
|
}
|
|
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
|
|
}
|