mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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)
242 lines
8.5 KiB
Go
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
|
|
}
|