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