package service import ( "context" "errors" "fmt" "log/slog" "slices" "github.com/J3vb/OwnCord/Server/db" "github.com/J3vb/OwnCord/Server/permissions" ) // requireChannelRead resolves a channel and asserts the user may read it: DM // membership for a DM, READ_MESSAGES otherwise. A DM the user is not in is // reported as ErrNotFound rather than ErrForbidden — its existence is not // something an outsider gets to learn. func (s *MessageService) requireChannelRead(ctx context.Context, userID, channelID int64) error { if channelID <= 0 { return fmt.Errorf("%w: channel_id must be positive", 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, dmErr := s.st.IsDMParticipant(ctx, userID, channelID) if dmErr != nil || !ok { return fmt.Errorf("%w: access denied", ErrNotFound) } return nil } if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return fmt.Errorf("%w: access denied", ErrForbidden) } return 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 err := s.requireChannelRead(ctx, userID, channelID); err != nil { return nil, false, err } 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 } // MessageWindow is a slice of channel history centred on one message, as // returned by GetMessagesAround. Messages are ordered oldest-first; the // HasMore flags report whether the channel holds further history on each side // of the window. type MessageWindow struct { Messages []db.MessageAPIResponse `json:"messages"` HasMoreBefore bool `json:"has_more_before"` HasMoreAfter bool `json:"has_more_after"` } // GetMessagesAround retrieves the window of `limit` messages centred on // messageID, ordered oldest-first, with the same read gate as GetMessages. // // The centre message must be a live message in this channel: a message from // another channel, one that never existed, or a soft-deleted one (which // history omits, so there is no row to centre on) is ErrNotFound. func (s *MessageService) GetMessagesAround(ctx context.Context, userID, channelID, messageID int64, limit int) (*MessageWindow, error) { if messageID <= 0 { return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest) } if err := s.requireChannelRead(ctx, userID, channelID); err != nil { return nil, err } if limit <= 0 { limit = 50 } if limit > 100 { limit = 100 } msg, err := s.st.GetMessage(ctx, messageID) if err != nil { slog.Error("MessageService.GetMessagesAround", "err", err, "message_id", messageID) return nil, fmt.Errorf("%w: failed to fetch message", ErrInternal) } if msg == nil || msg.ChannelID != channelID || msg.Deleted { return nil, fmt.Errorf("%w: message not found in this channel", ErrNotFound) } // Half the window sits before the centre, the rest after it; the centre // occupies one slot. Ask for one extra on each side so the has-more flags // come out of the same query instead of two follow-up counts. beforeCount := limit / 2 afterCount := limit - beforeCount - 1 msgs, err := s.st.GetMessagesAroundForAPI(ctx, channelID, messageID, beforeCount+1, afterCount+1, userID) if err != nil { slog.Error("MessageService.GetMessagesAround", "err", err, "channel_id", channelID) return nil, fmt.Errorf("%w: failed to fetch messages", ErrInternal) } centreIdx := slices.IndexFunc(msgs, func(m db.MessageAPIResponse) bool { return m.ID == messageID }) if centreIdx < 0 { // The centre vanished between the lookup and the window query. return nil, fmt.Errorf("%w: message not found in this channel", ErrNotFound) } window := &MessageWindow{Messages: msgs} if centreIdx > beforeCount { window.HasMoreBefore = true window.Messages = window.Messages[centreIdx-beforeCount:] centreIdx = beforeCount } if len(window.Messages)-centreIdx-1 > afterCount { window.HasMoreAfter = true window.Messages = window.Messages[:centreIdx+afterCount+1] } return window, nil } // GetPinnedMessages retrieves pinned messages for a channel. func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelID int64) ([]db.MessageAPIResponse, error) { if err := s.requireChannelRead(ctx, userID, channelID); err != nil { return nil, err } 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) } // Archived channels are read-only. SetMessagePinned bypasses // checkSendPermission (it runs its own DM/permission branch below), so it // needs the shared gate directly — see requireChannelWritable in // message_perms.go. if err := requireChannelWritable(ch); err != nil { return err } 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) } if err := s.st.SetMessagePinned(ctx, msgID, pinned); err != nil { // The pin SQL excludes soft-deleted rows, so a message deleted between // the GetMessage check above and this UPDATE (or one whose Deleted flag // we didn't re-check) surfaces here as db.ErrNotFound. Map it to the // service taxonomy so writeServiceError answers 404, not a 500 — same // class of guard as EditMessage's ErrDeletedMessage and handleReaction's // ErrBadRequest on their own deleted-message paths. if errors.Is(err, db.ErrNotFound) { return fmt.Errorf("%w: message not found in this channel", ErrNotFound) } return fmt.Errorf("%w: %v", ErrInternal, err) } return nil }