Files
OwnCord/Server/service/message_reactions.go
T
J3vbandClaude Fable 5 8579cb5d91 fix: batch of 25 correctness fixes across server and client (#1370)
* 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>
2026-08-14 14:49:27 +02:00

173 lines
6.2 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// 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)
}
// GetReactionUsers returns the users who reacted to msgID with emoji, capped at
// db.MaxReactionUsers. Gated by the same read check as fetching the channel's
// history, so a reaction pill never leaks membership of a channel the caller
// cannot read. The message must live in channelID — the URL's channel is what
// the permission check ran against, so a mismatch is a not-found, not a
// silently-broader lookup.
func (s *MessageService) GetReactionUsers(ctx context.Context, userID, channelID, msgID int64, emoji string) ([]db.ReactionUser, error) {
if msgID <= 0 {
return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest)
}
if err := validateEmoji(emoji); err != nil {
return nil, err
}
if err := s.requireChannelRead(ctx, userID, channelID); err != nil {
return nil, err
}
msg, err := s.st.GetMessage(ctx, msgID)
if err != nil || msg == nil || msg.ChannelID != channelID {
return nil, fmt.Errorf("%w: message not found", ErrNotFound)
}
users, err := s.st.GetReactionUsers(ctx, msgID, emoji, db.MaxReactionUsers)
if err != nil {
slog.Error("MessageService.GetReactionUsers", "err", err, "msg_id", msgID)
return nil, fmt.Errorf("%w: failed to fetch reaction users", ErrInternal)
}
if users == nil {
users = []db.ReactionUser{}
}
return users, nil
}
// maxReactionRunes bounds a reaction string. Reactions are free-form text, so
// the ceiling has to clear the longest thing a client can legitimately react
// with: a custom emoji is stored as its ":shortcode:" literal, which is
// MaxShortcodeLen plus the two colons. Deriving it keeps the two from drifting
// into an emoji that renders in a message but is silently refused as a
// reaction. Unicode emoji, even long ZWJ sequences, sit far below this.
const maxReactionRunes = MaxShortcodeLen + 2
// validateEmoji applies the shared shape rules for a reaction emoji: non-empty,
// at most maxReactionRunes runes, no control characters, and unchanged by the
// sanitizer.
func validateEmoji(emoji string) error {
if emoji == "" || len([]rune(emoji)) > maxReactionRunes {
return fmt.Errorf("%w: invalid emoji", ErrBadRequest)
}
for _, r := range emoji {
if r <= 0x1F || r == 0x7F {
return fmt.Errorf("%w: emoji contains control characters", ErrBadRequest)
}
}
if sanitizer.Sanitize(emoji) != emoji {
return fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest)
}
return nil
}
func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64, emoji string, add bool) (*ReactionResult, error) {
// Rate limit.
ratKey := auth.Key("reaction", 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 err := validateEmoji(emoji); err != nil {
return nil, err
}
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"
// Archived channels are read-only. handleReaction 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 nil, err
}
var participantIDs []int64
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)
}
if blkErr := requireDMNotBlocked(ctx, s.st, userID, msg.ChannelID); blkErr != nil {
return nil, blkErr
}
// Resolve the fan-out audience before mutating anything. Participants
// are unaffected by the reaction itself, so failing here is cheap;
// fetching this after AddReaction/RemoveReaction commits (as this
// used to) risked a reaction persisted with no participant list to
// broadcast it to, which reactionV2Handler would then fan out to
// nobody while reporting success to the caller.
ids, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)
if pErr != nil {
slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID)
return nil, fmt.Errorf("%w: failed to resolve DM participants", ErrInternal)
}
participantIDs = ids
} 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 {
result.ParticipantIDs = participantIDs
}
return result, nil
}