mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020) * fix(db): 1 defect(s) (OC-0096) * fix(admin): 1 defect(s) (OC-0097) * fix(auth): 2 defect(s) (OC-0099, OC-0021) * fix(voice): 1 defect(s) (OC-0018) * fix(admin): 1 defect(s) (OC-0045) * fix(api): 1 defect(s) (OC-0103) * fix(client): 1 defect(s) (OC-0105) * fix(client): 1 defect(s) (OC-0107) * fix(api): 1 defect(s) (OC-0109) * fix(api): 1 defect(s) (OC-0112) * test(admin): compare restore bytes with bytes.Equal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0095, OC-0014) OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext. OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token. * fix(profile): 2 defect(s) (OC-0100, OC-0102) * fix(service): 1 defect(s) (OC-0022) Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate. * fix(api): 1 defect(s) (OC-0048) * chore(workflows): correct stale model labels in bughunt-fix phase details Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(client): 1 defect(s) (OC-0015) * fix(voice): 1 defect(s) (OC-0002) * test: fix two CI-only failures in the batch-4 test suite The delete-account broadcast test now observes member_ban on a second client's socket: the hub broadcasts and then force-disconnects the target, so on a slow runner the close could beat the target's own copy of the frame. The observer is also the party the event exists for. The voice e2e mock now echoes the real joined channel id on voice_leave (it hardcoded channel_id 0, which the dispatcher's channel-matched self-leave teardown correctly ignores), and the rejoin test waits for the mock's delayed echoes to settle before clicking the row again — clicking inside the echo window toggled a leave instead of a join. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
249 lines
8.8 KiB
Go
249 lines
8.8 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)
|
|
}
|
|
// 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
|
|
}
|