Files
OwnCord/Server/service/dm.go
T
J3vbandClaude 36be31db43 fix: 11 defects from bughunt sweep across server, db and client (#1382)
* fix(ws): 1 defect(s) (OC-0001)

* fix(db): 1 defect(s) (OC-0002)

sanitizeFTSQuery filtered only characters, so FTS5's bareword boolean
keywords (AND, OR, NOT) reached MATCH as operators; a query in an
invalid operator position raised "fts5: syntax error" instead of
returning results. Drop those bareword tokens after sanitizing.

* fix(dm): 1 defect(s) (OC-0004)

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

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

Count the shared voice_max_video budget in streams rather than rows: a
single user publishing both camera and screenshare consumed one slot while
producing two live streams, letting a channel over-admit up to 2N streams
against an N-stream cap.

* fix(client): 2 defect(s) (OC-0007, OC-0009)

OC-0007: mark the active channel loading before invalidating its message
window on a full-ready resync, so MessageList shows the spinner instead
of the empty-channel state for the duration of the refetch.

OC-0009: fan USER_UPDATE renames out to voiceStore.voiceUsers, which
keeps its own frozen username copy, so the voice roster no longer shows
a stale name for the rest of the call.

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

* fix(identity): 1 defect(s) (OC-0011)

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

The public half of an invisible user's presence (PresenceOthersEvent, and
BroadcastPresence's own mapped payload) went out via broadcastExcludeLow on
the low-priority queue - the ephemeral, unsequenced, drop-on-overflow
transport built for typing indicators - while every other source of the same
user's presence shares the normal-priority queue. That split one user's
presence across two per-client FIFOs with different durability and different
drain order (writePump drains normal strictly before low), so a frame could
land out of order against a later connect/disconnect presence frame, or be
silently dropped with no replay recovery.

Adds Hub.BroadcastToAllExcept, which routes through the same h.broadcast
channel and seqMu-serialized deliverBroadcast as BroadcastToAll, carrying an
excludeUserID that deliverBroadcast applies via pubsub.Publish(TopicGlobal,
msg, excludeUserID).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 17:06:11 +02:00

405 lines
16 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"time"
"unicode/utf8"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/telemetry"
)
// DMService handles direct message channel operations.
type DMService struct {
st Store
}
// NewDMService creates a DMService.
func NewDMService(st Store) *DMService {
return &DMService{st: st}
}
// CreateDMResult holds the result of creating or fetching a DM channel.
type CreateDMResult struct {
Channel *db.Channel
Created bool
Recipient *db.User
}
// CreateDM creates or retrieves a DM channel between two users.
// Validates that neither user has blocked the other.
func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*CreateDMResult, error) {
ctx, span := telemetry.GlobalTracer("service/dm").Start(ctx, "DMService.CreateDM",
telemetry.Int64("user_id", userID),
telemetry.Int64("recipient_id", recipientID),
)
start := time.Now()
defer func() {
telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start,
telemetry.String("method", "CreateDM"))
span.End()
}()
if recipientID <= 0 {
return nil, fmt.Errorf("%w: recipient_id must be positive", ErrBadRequest)
}
if userID == recipientID {
return nil, fmt.Errorf("%w: cannot create DM with yourself", ErrBadRequest)
}
recipient, err := s.st.GetUserByID(ctx, recipientID)
if err != nil || recipient == nil {
return nil, fmt.Errorf("%w: recipient not found", ErrNotFound)
}
// GetUserByID has no banned filter (unlike the lookups that normally
// surface a user to a caller, e.g. ListMembers), and a hand-crafted
// recipient_id naming a deleted/banned account otherwise creates a
// dead-end DM channel plus participant rows for the tombstone user.
// Gated on IsEffectivelyBanned rather than the raw flag so a lapsed
// temporary ban — which login/WS already treat as not-banned — still
// permits the DM once it expires.
if auth.IsEffectivelyBanned(recipient) {
return nil, fmt.Errorf("%w: recipient not found", ErrNotFound)
}
blocked, err := s.st.IsEitherBlocked(ctx, userID, recipientID)
if err != nil {
return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err)
}
if blocked {
return nil, fmt.Errorf("%w: cannot create DM — user is blocked", ErrForbidden)
}
ch, created, err := s.st.GetOrCreateDMChannel(ctx, userID, recipientID)
if err != nil {
slog.Error("DMService.CreateDM", "err", err)
return nil, fmt.Errorf("%w: failed to create DM channel", ErrInternal)
}
return &CreateDMResult{
Channel: ch,
Created: created,
Recipient: recipient,
}, nil
}
// ListDMs returns all open DM channels for a user.
func (s *DMService) ListDMs(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) {
dms, err := s.st.GetUserDMChannels(ctx, userID)
if err != nil {
return nil, fmt.Errorf("%w: failed to list DMs: %v", ErrInternal, err)
}
return dms, nil
}
// CloseDMResult describes what closing a DM actually did, so the caller knows
// which events to send.
type CloseDMResult struct {
// Left is true when the caller left a *group* DM: they are no longer a
// participant, and the remaining members need to be told.
Left bool
// ChannelDeleted is true when the caller was the last participant of a
// group and the channel row went with them.
ChannelDeleted bool
// RemainingParticipantIDs is who is still in the group after a leave. Empty
// for a 1:1 close, which changes nothing for the other party.
RemainingParticipantIDs []int64
}
// CloseDM closes a DM channel for a user.
//
// For a 1:1 DM this hides the conversation from the caller's sidebar and
// nothing more — the other party keeps their copy, and the next message from
// either side re-opens it. For a group DM it is a *leave*: the caller comes
// out of dm_participants, stops receiving the group's messages, and cannot
// re-open it without being re-added. The two meanings share a route because
// they share a gesture ("get this out of my list"), but they are different
// operations and the result says which one ran.
func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) (*CloseDMResult, error) {
if channelID <= 0 {
return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
}
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
if err != nil || !ok {
return nil, fmt.Errorf("%w: not a participant in this DM", ErrNotFound)
}
isGroup, err := s.st.IsGroupDM(ctx, channelID)
if err != nil {
return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err)
}
if !isGroup {
if err := s.st.CloseDM(ctx, userID, channelID); err != nil {
return nil, fmt.Errorf("%w: failed to close DM: %v", ErrInternal, err)
}
slog.Debug("DM closed", "user_id", userID, "channel_id", channelID)
return &CloseDMResult{}, nil
}
// Read the survivors *before* the leave: after it, the caller is gone from
// dm_participants and a post-hoc read could not tell "left" from "was
// never in it" if the delete half-succeeded.
remaining, err := s.st.GetDMParticipantIDs(ctx, channelID)
if err != nil {
return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err)
}
survivors := make([]int64, 0, len(remaining))
for _, pid := range remaining {
if pid != userID {
survivors = append(survivors, pid)
}
}
deleted, err := s.st.LeaveGroupDM(ctx, userID, channelID)
if err != nil {
return nil, fmt.Errorf("%w: failed to leave group DM: %v", ErrInternal, err)
}
slog.Debug("group DM left", "user_id", userID, "channel_id", channelID, "deleted", deleted)
return &CloseDMResult{
Left: true,
ChannelDeleted: deleted,
RemainingParticipantIDs: survivors,
}, nil
}
// ─── Group DMs ──────────────────────────────────────────────────────────────
// MaxGroupDMNameLen bounds the optional group name. It matches the channel
// name cap: a group DM name renders in the same sidebar row a channel name
// does, so a longer one would only ever be shown clipped.
const MaxGroupDMNameLen = 100
// CreateGroupDMResult holds the result of creating a group DM.
type CreateGroupDMResult struct {
Channel *db.Channel
Participants []db.DMUser
// ParticipantIDs is every member including the creator, which is the set
// the caller fans dm_channel_open out to.
ParticipantIDs []int64
}
// CreateGroupDM creates a group DM between the caller and 2..8 other users
// (3..10 total, matching db.MaxGroupDMParticipants).
//
// Blocks are enforced in both directions, per recipient: a user cannot pull
// someone they have blocked into a room with them, and cannot use a group to
// reach someone who blocked them. The check is deliberately *creation-time
// only* — once the group exists, sending into it is not block-checked, because
// a group DM is a shared room and silently dropping one member's messages for
// one other member would make the conversation lie to everybody in it. That is
// also why the composer gate on the client applies to 1:1 DMs alone.
//
// Unlike CreateDM this always creates a new channel: the same set of people may
// want more than one group, so there is no "the group for these users" to find.
func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientIDs []int64, name string) (*CreateGroupDMResult, error) {
ctx, span := telemetry.GlobalTracer("service/dm").Start(ctx, "DMService.CreateGroupDM",
telemetry.Int64("user_id", userID),
)
start := time.Now()
defer func() {
telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start,
telemetry.String("method", "CreateGroupDM"))
span.End()
}()
// De-duplicate and drop the caller: a payload naming the same person twice
// is a client bug, not a reason to refuse, but it must not inflate the
// participant count or double-insert.
seen := map[int64]bool{userID: true}
unique := make([]int64, 0, len(recipientIDs))
for _, rid := range recipientIDs {
if rid <= 0 {
return nil, fmt.Errorf("%w: recipient_ids must be positive", ErrBadRequest)
}
if seen[rid] {
continue
}
seen[rid] = true
unique = append(unique, rid)
}
if len(unique) < 2 {
return nil, fmt.Errorf("%w: a group DM needs at least 2 other users", ErrBadRequest)
}
if len(unique)+1 > db.MaxGroupDMParticipants {
return nil, fmt.Errorf("%w: a group DM holds at most %d users", ErrBadRequest, db.MaxGroupDMParticipants)
}
cleanName := cleanText(name)
if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen {
return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen)
}
for _, rid := range unique {
user, err := s.st.GetUserByID(ctx, rid)
if err != nil || user == nil {
return nil, fmt.Errorf("%w: recipient not found", ErrNotFound)
}
// See the matching check in CreateDM: gate on effective ban status,
// not the raw flag, so a lapsed temporary ban does not wrongly
// refuse the group.
if auth.IsEffectivelyBanned(user) {
return nil, fmt.Errorf("%w: recipient not found", ErrNotFound)
}
}
// Block-check every pair in the room, not just creator-vs-recipient:
// group DMs are exempt from the send-time block gate (requireDMNotBlocked
// skips groups entirely) on the strength of this creation-time check, so
// two mutually-blocked recipients must not both end up in the same group
// even when neither of them blocked the creator. n <= MaxGroupDMParticipants,
// so the O(n^2) scan is trivial.
participantIDs := append([]int64{userID}, unique...)
for i := range participantIDs {
for j := i + 1; j < len(participantIDs); j++ {
blocked, err := s.st.IsEitherBlocked(ctx, participantIDs[i], participantIDs[j])
if err != nil {
return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err)
}
if blocked {
return nil, fmt.Errorf("%w: cannot add a blocked user to a group DM", ErrForbidden)
}
}
}
ch, err := s.st.CreateGroupDMChannel(ctx, cleanName, participantIDs)
if err != nil {
slog.Error("DMService.CreateGroupDM", "err", err)
return nil, fmt.Errorf("%w: failed to create group DM", ErrInternal)
}
// The channel, all dm_participants rows and all dm_open_state rows are
// already committed at this point, so this read must not turn a fully-
// persisted group DM into a reported failure (OC-0004): it runs
// uncancellable (context.WithoutCancel) so a client disconnect landing
// right after the commit can't fail it via ctx.Err(), and any other
// failure is logged rather than propagated so the caller still gets a
// usable result to fan dm_channel_open out from. Group DMs are
// duplicate-by-design — there is no "the group for these users" to find —
// so telling the caller creation failed when it actually committed only
// invites a retry that creates a second, indistinguishable group.
participants, err := s.st.GetDMParticipants(context.WithoutCancel(ctx), ch.ID, userID)
if err != nil {
slog.Error("DMService.CreateGroupDM: failed to read participants after commit", "err", err, "channel_id", ch.ID)
participants = nil
}
return &CreateGroupDMResult{
Channel: ch,
Participants: participants,
ParticipantIDs: participantIDs,
}, nil
}
// RenameGroupDM sets (or, with an empty name, clears) a group DM's name.
//
// Any participant may rename it, which is Discord's rule and the only one that
// works here: a group DM has no owner column and no roles, so "who may rename"
// has exactly one answer that does not require inventing an ownership model.
// A 1:1 DM refuses — its name is who is in it.
func (s *DMService) RenameGroupDM(ctx context.Context, userID, channelID int64, name string) (*db.Channel, error) {
if channelID <= 0 {
return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
}
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
if err != nil || !ok {
return nil, fmt.Errorf("%w: not a participant in this DM", ErrNotFound)
}
isGroup, err := s.st.IsGroupDM(ctx, channelID)
if err != nil {
return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err)
}
if !isGroup {
return nil, fmt.Errorf("%w: only group DMs can be named", ErrBadRequest)
}
cleanName := cleanText(name)
if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen {
return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen)
}
if err := s.st.SetDMChannelName(ctx, channelID, cleanName); err != nil {
return nil, fmt.Errorf("%w: failed to rename group DM: %v", ErrInternal, err)
}
ch, err := s.st.GetChannel(ctx, channelID)
if err != nil || ch == nil {
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
}
return ch, nil
}
// DMSummaryFor returns one DM's payload shape as viewerID sees it: the group
// name, the participants other than them, and whether it is a group.
//
// Every push of a DM's membership — group create, rename, leave — goes through
// it, so the shape a client receives from an event is the same one it receives
// from GET /dms and from `ready`. Membership is checked here rather than by
// each caller.
func (s *DMService) DMSummaryFor(ctx context.Context, viewerID, channelID int64) (db.DMChannelInfo, error) {
ok, err := s.st.IsDMParticipant(ctx, viewerID, channelID)
if err != nil || !ok {
return db.DMChannelInfo{}, fmt.Errorf("%w: not a participant in this DM", ErrNotFound)
}
participants, err := s.st.GetDMParticipants(ctx, channelID, viewerID)
if err != nil {
return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err)
}
ch, err := s.st.GetChannel(ctx, channelID)
if err != nil || ch == nil {
return db.DMChannelInfo{}, fmt.Errorf("%w: channel not found", ErrNotFound)
}
isGroup, err := s.st.IsGroupDM(ctx, channelID)
if err != nil {
return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err)
}
return db.NewDMChannelInfo(channelID, ch.Name, isGroup, participants, viewerID), nil
}
// RingTargets returns the other participants of a DM the caller is in — the
// people a call_ring or call_decline is addressed to.
//
// Ringing carries no state: a "call" in a DM *is* somebody being present in
// that DM's voice channel, and the ring is a nudge to come look. That is why
// this is a permission check and a fan-out list rather than a call record —
// there is nothing to persist that presence does not already say, and a
// persisted call would be one more thing that can be left dangling by a crash.
func (s *DMService) RingTargets(ctx context.Context, userID, channelID int64) ([]int64, error) {
if channelID <= 0 {
return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
}
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
if err != nil {
return nil, fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, err)
}
if !ok {
return nil, fmt.Errorf("%w: not a participant in this DM", ErrForbidden)
}
// A ring is a DM interaction like any other sink: without this check a
// blocked user could still make the blocker's client ring (A-2026-08-03).
// Group DMs are exempt inside requireDMNotBlocked, matching every other
// sink — blocks are enforced at group creation instead.
if err := requireDMNotBlocked(ctx, s.st, userID, channelID); err != nil {
return nil, err
}
ids, err := s.st.GetDMParticipantIDs(ctx, channelID)
if err != nil {
return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err)
}
targets := make([]int64, 0, len(ids))
for _, pid := range ids {
if pid != userID {
targets = append(targets, pid)
}
}
return targets, nil
}