mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Make server failures debuggable without leaking secrets: - configurable stdout log level (config.yaml logging.level + OWNCORD_LOGGING_LEVEL) - preserve the DB cause in ErrInternal wraps; log auth-DB failures distinctly from bad tokens; log the previously-silent expired-session cleanup goroutine - route HTTP handler panics through slog (was chi stderr-only, invisible to the admin log stream) - stackutil: argument-free panic stacks so key/token bytes never reach the admin ring buffer / SSE; slog.LogValuer redaction on VoiceConfig/GitHubConfig/ GIFConfig/Config and db.User/db.Session - logctx: req_id/trace_id correlation on ...Context log calls Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
738 lines
25 KiB
Go
738 lines
25 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)
|
|
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: cannot edit this message", ErrForbidden)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.SendMessages) {
|
|
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"
|
|
|
|
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 {
|
|
isMsgOwner := msg.UserID == userID
|
|
canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ManageMessages)
|
|
canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.SendMessages))
|
|
if !canDelete {
|
|
return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden)
|
|
}
|
|
}
|
|
|
|
isMod := !isDM && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ManageMessages)
|
|
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)
|
|
}
|
|
} 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)
|
|
}
|
|
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) {
|
|
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)
|
|
}
|
|
recipient, err := s.st.GetDMRecipient(ctx, channelID, userID)
|
|
if err == nil && recipient != nil {
|
|
blocked, blkErr := s.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: cannot send messages — user is blocked", ErrBlocked)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|