Files
OwnCord/Server/service/message_query.go
T
J3vb db0275a290 fix: batch of 29 correctness fixes across server and client (#1369)
* fix(ws): 2 defect(s) (OC-0013, OC-0140)

* fix(voice): 1 defect(s) (OC-0044)

* fix(ws): 1 defect(s) (OC-0024)

* fix(server): 1 defect(s) (OC-0027)

* fix(ws): 1 defect(s) (OC-0028)

* fix(server): 7 defect(s) (OC-0033, OC-0066, OC-0067, OC-0068, OC-0074, OC-0077, OC-0106)

* fix(ws): 1 defect(s) (OC-0051)

* fix(client): 1 defect(s) (OC-0053)

* fix(client): 1 defect(s) (OC-0055)

* fix(service): 1 defect(s) (OC-0069)

* fix(voice): 1 defect(s) (OC-0072)

* fix(service): 1 defect(s) (OC-0082)

* fix(client): 1 defect(s) (OC-0083)

* fix(plugin): 1 defect(s) (OC-0088)

* fix(plugin): 4 defect(s) (OC-0104, OC-0126, OC-0127, OC-0133)

* fix(admin): 1 defect(s) (OC-0110)

* fix(client): 1 defect(s) (OC-0114)

* fix(api): 1 defect(s) (OC-0139)

* fix(client): 1 defect(s) (OC-0149)

* test(server): adapt existing tests to updated OpenDM and IncrementMentionCounts signatures

* style(plugin): modernize loops and goroutine spawns in race test

* fix(ws): mirror the focus admission gate in the post-subscribe revalidation

* fix(service): detach DM post-commit side effects from the request ctx, fail delete closed, add empty-fan-out fallback

* fix(plugin): preserve enabled intent when upgrade reactivation hits a runtime-less build

* chore(skills): harden bughunt-fix workflow and fold review lessons into bughunt-run/db-change

* Add comprehensive documentation for task-observer skill

- Introduced environments.md to outline activation setup, compaction behavior, and handoff-doc mode.
- Created skill-authoring.md detailing taxonomy, licensing, confidentiality, and editing rules for skill creation.
- Added weekly-review.md for a structured review process of OPEN observations, including scheduled and in-session fallback modes.

* chore(go): pin toolchain go1.26.6 (stdlib CVE fixes flagged by govulncheck)
2026-08-14 10:05:40 +02:00

242 lines
8.5 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"github.com/owncord/server/db"
"github.com/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)
}
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
}