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)
314 lines
12 KiB
Go
314 lines
12 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// buildAuthOK constructs the auth_ok server→client message.
|
|
// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status).
|
|
//
|
|
// replaySource records which reconnection tier served this client:
|
|
// - "none" — fresh connection or full re-sync (no resume)
|
|
// - "buffer" — resume served from the in-memory ring buffer
|
|
// - "db" — resume served from the persistent EventStore (Phase B Step 7)
|
|
func (h *Hub) buildAuthOK(ctx context.Context, user *db.User, roleName string, replaySource string) []byte {
|
|
var avatarVal any
|
|
if user.Avatar != nil {
|
|
avatarVal = *user.Avatar
|
|
}
|
|
|
|
serverName, motd := h.getCachedSettings(ctx)
|
|
|
|
return buildJSON(map[string]any{
|
|
"type": MsgTypeAuthOK,
|
|
"payload": map[string]any{
|
|
"user": map[string]any{
|
|
"id": user.ID,
|
|
"username": user.Username,
|
|
"avatar": avatarVal,
|
|
"role": roleName,
|
|
// The signed-in user's own profile fields. Null when unset;
|
|
// display_name falls back to username client-side, and about
|
|
// is what the "edit my profile" form pre-fills from.
|
|
"display_name": user.DisplayName,
|
|
"about": user.About,
|
|
"custom_status": user.CustomStatus,
|
|
// The user's own true status — invisible included. Only their
|
|
// own auth_ok ever carries it, which is the whole point: the
|
|
// picker has to render what they chose, while every other
|
|
// client is told offline.
|
|
"status": user.Status,
|
|
},
|
|
"server_name": serverName,
|
|
"motd": motd,
|
|
"replay_source": replaySource,
|
|
},
|
|
})
|
|
}
|
|
|
|
// presentableMembers rewrites each member's status into what viewerID may see.
|
|
//
|
|
// Two rules, both applied here so no payload builder can implement only one:
|
|
//
|
|
// 1. A member with no live connection is offline, whatever the row says.
|
|
// users.status keeps a *chosen* idle/dnd/invisible across a disconnect so
|
|
// the next connect can honour it, which would otherwise leave a signed-out
|
|
// user showing as "Do Not Disturb" indefinitely.
|
|
// 2. An invisible member is offline to everyone but themselves
|
|
// (db.StatusForViewer). The owner keeps their true state so their own
|
|
// picker renders the status they actually chose.
|
|
func (h *Hub) presentableMembers(members []db.MemberSummary, viewerID int64) []db.MemberSummary {
|
|
connected := h.connectedUserIDs()
|
|
out := make([]db.MemberSummary, 0, len(members))
|
|
for _, m := range members {
|
|
if !connected[m.ID] {
|
|
m.Status = db.StatusOffline
|
|
m.CustomStatus = nil
|
|
}
|
|
out = append(out, m.ForViewer(viewerID))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// connectedUserIDs snapshots the ids with a live WebSocket connection.
|
|
func (h *Hub) connectedUserIDs() map[int64]bool {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
set := make(map[int64]bool, len(h.clients))
|
|
for uid := range h.clients {
|
|
set[uid] = true
|
|
}
|
|
return set
|
|
}
|
|
|
|
// channelRefs maps db channels to the checker's db-agnostic ChannelRef so
|
|
// buildReady and computeAllowedChannels can share permissions.VisibleChannelIDs.
|
|
func channelRefs(channels []db.Channel) []permissions.ChannelRef {
|
|
refs := make([]permissions.ChannelRef, len(channels))
|
|
for i := range channels {
|
|
refs[i] = permissions.ChannelRef{ID: channels[i].ID, Type: channels[i].Type, Archived: channels[i].Archived}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
// permOverrides maps a db override map to the checker's override map, carrying
|
|
// BOTH layers — the role override and the per-user override — so the checker
|
|
// resolves the full order (base -> role -> user) rather than half of it.
|
|
func permOverrides(overrides map[int64]db.ChannelOverride) map[int64]permissions.ChannelOverride {
|
|
out := make(map[int64]permissions.ChannelOverride, len(overrides))
|
|
for id, o := range overrides {
|
|
out[id] = permissions.ChannelOverride{
|
|
Allow: o.Allow,
|
|
Deny: o.Deny,
|
|
UserAllow: o.UserAllow,
|
|
UserDeny: o.UserDeny,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// channelCanSend reports whether a user with the given role and per-channel
|
|
// override may post in a channel of chanType. It mirrors the non-DM branch of
|
|
// MessageService.checkSendPermission so the client can pre-disable the composer
|
|
// without a round-trip; the server still enforces the rule authoritatively.
|
|
func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool {
|
|
if role == nil {
|
|
return false
|
|
}
|
|
if permissions.HasAdmin(role.Permissions) {
|
|
return true
|
|
}
|
|
eff := permissions.EffectiveChannelPerms(role.Permissions, permissions.ChannelOverride{
|
|
Allow: o.Allow, Deny: o.Deny, UserAllow: o.UserAllow, UserDeny: o.UserDeny,
|
|
})
|
|
need := permissions.ReadMessages | permissions.SendMessages
|
|
if eff&need != need {
|
|
return false
|
|
}
|
|
if chanType == "announcement" {
|
|
return eff&permissions.ManageMessages == permissions.ManageMessages
|
|
}
|
|
return true
|
|
}
|
|
|
|
// buildReady constructs the ready server→client message.
|
|
// Per docs/protocol.md, channels include unread_count and last_message_id per
|
|
// user, and only protocol-specified fields (no slow_mode, archived, voice_*
|
|
// extras).
|
|
func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, role *db.Role) ([]byte, error) {
|
|
channels, err := database.ListChannels(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("buildReady ListChannels: %w", err)
|
|
}
|
|
roles, err := database.ListRoles(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("buildReady ListRoles: %w", err)
|
|
}
|
|
|
|
members, err := database.ListMembers(ctx)
|
|
if err != nil {
|
|
slog.Warn("buildReady ListMembers", "err", err)
|
|
members = []db.MemberSummary{}
|
|
}
|
|
members = h.presentableMembers(members, userID)
|
|
|
|
// Filter channels by READ_MESSAGES through the single permissions.Checker
|
|
// predicate shared with REST ListVisibleChannels and reconnect replay
|
|
// filtering (computeAllowedChannels). The overrides map is fetched once and
|
|
// reused below for the per-channel can_send affordance. DM channels are
|
|
// excluded by the checker — they are delivered via the dm_channels field.
|
|
overrides := map[int64]db.ChannelOverride{}
|
|
if role != nil && !permissions.HasAdmin(role.Permissions) {
|
|
var oErr error
|
|
overrides, oErr = database.GetChannelOverridesFor(ctx, role.ID, userID)
|
|
if oErr != nil {
|
|
return nil, fmt.Errorf("buildReady GetChannelOverridesFor: %w", oErr)
|
|
}
|
|
}
|
|
var visibleChannels []db.Channel
|
|
if role != nil {
|
|
// Nil role = zero access (fail closed), handled by skipping the filter.
|
|
visibleIDs := h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
|
|
for i := range channels {
|
|
if visibleIDs[channels[i].ID] {
|
|
visibleChannels = append(visibleChannels, channels[i])
|
|
}
|
|
}
|
|
}
|
|
if visibleChannels == nil {
|
|
visibleChannels = []db.Channel{}
|
|
}
|
|
|
|
// Per-user unread counts.
|
|
unreadMap, err := database.GetChannelUnreadCounts(ctx, userID)
|
|
if err != nil {
|
|
slog.Warn("buildReady GetChannelUnreadCounts", "err", err)
|
|
unreadMap = map[int64]db.ChannelUnread{}
|
|
}
|
|
|
|
// Build protocol-compliant channel objects (strip extra fields).
|
|
channelPayloads := make([]map[string]any, 0, len(visibleChannels))
|
|
for i := range visibleChannels {
|
|
entry := map[string]any{
|
|
"id": visibleChannels[i].ID,
|
|
"name": visibleChannels[i].Name,
|
|
"type": visibleChannels[i].Type,
|
|
"category": visibleChannels[i].Category,
|
|
"topic": visibleChannels[i].Topic,
|
|
"position": visibleChannels[i].Position,
|
|
// can_send drives the client's composer affordance. It mirrors
|
|
// MessageService.checkSendPermission for non-DM channels: base role
|
|
// ± channel overrides must grant READ|SEND, and announcement
|
|
// channels additionally require MANAGE_MESSAGES; admins bypass. The
|
|
// server remains the authority — this only pre-disables the UI.
|
|
"can_send": channelCanSend(role, overrides[visibleChannels[i].ID], visibleChannels[i].Type),
|
|
// Cooldown in seconds (0 = off). Lets the composer disable itself
|
|
// for the window instead of accepting a send the server refuses
|
|
// with SLOW_MODE. The server still enforces.
|
|
"slow_mode": visibleChannels[i].SlowMode,
|
|
// Age-gate flag. Shipped so a client can label or gate the
|
|
// channel; the server applies no content behaviour of its own to
|
|
// a flagged channel (migration 025).
|
|
"nsfw": visibleChannels[i].NSFW,
|
|
// Voice capacity limits (0 = unlimited) — the same values the
|
|
// voice-join path enforces with CHANNEL_FULL / VIDEO_LIMIT.
|
|
"voice_max_users": visibleChannels[i].VoiceMaxUsers,
|
|
"voice_max_video": visibleChannels[i].VoiceMaxVideo,
|
|
}
|
|
if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" {
|
|
if u, ok := unreadMap[visibleChannels[i].ID]; ok {
|
|
entry["unread_count"] = u.UnreadCount
|
|
entry["last_message_id"] = u.LastMessageID
|
|
entry["mention_count"] = u.MentionCount
|
|
} else {
|
|
entry["unread_count"] = 0
|
|
entry["last_message_id"] = 0
|
|
entry["mention_count"] = 0
|
|
}
|
|
}
|
|
channelPayloads = append(channelPayloads, entry)
|
|
}
|
|
|
|
// Load open DM channels for this user. Hoisted above the voice-state
|
|
// filter below so DM channel IDs can seed visibleSet — permissions.Checker
|
|
// (and therefore visibleChannels) deliberately skips DM channels, since
|
|
// their visibility is membership-based rather than role-based, so without
|
|
// this a DM voice call's voice_state rows would never make it into ready.
|
|
dmChannels, err := database.GetUserDMChannels(ctx, userID)
|
|
if err != nil {
|
|
slog.Warn("buildReady GetUserDMChannels", "err", err)
|
|
dmChannels = []db.DMChannelInfo{}
|
|
}
|
|
// GetUserDMChannels computes unread from read_states but carries no mention
|
|
// count, so a DM mention badge used to vanish on every reconnect. The
|
|
// unread map now includes the user's DM rows — pull mention_count from it.
|
|
for i := range dmChannels {
|
|
if u, ok := unreadMap[dmChannels[i].ChannelID]; ok {
|
|
dmChannels[i].MentionCount = u.MentionCount
|
|
}
|
|
}
|
|
|
|
// Collect voice states, filtered to visible channels (BUG-095) plus the
|
|
// user's own open DM channels — mirroring computeAllowedChannels, which
|
|
// layers DM IDs onto the same checker result for reconnect replay
|
|
// filtering. Without this, a DM voice call's voice_state rows are
|
|
// structurally unreachable: VisibleChannelIDs skips ch.Type == "dm", and
|
|
// nothing else re-adds them for this filter.
|
|
allVoiceStates, err := collectAllVoiceStates(ctx, database, channels)
|
|
if err != nil {
|
|
// Non-fatal: send empty list rather than failing the whole ready payload.
|
|
slog.Warn("buildReady collectAllVoiceStates", "err", err)
|
|
allVoiceStates = []db.VoiceState{}
|
|
}
|
|
visibleSet := make(map[int64]struct{}, len(visibleChannels)+len(dmChannels)+1)
|
|
for i := range visibleChannels {
|
|
visibleSet[visibleChannels[i].ID] = struct{}{}
|
|
}
|
|
for i := range dmChannels {
|
|
visibleSet[dmChannels[i].ChannelID] = struct{}{}
|
|
}
|
|
// The caller's own live voice room can never leak by definition -- seed it
|
|
// even if it fell outside both sets above (e.g. CONNECT_VOICE granted
|
|
// without READ_MESSAGES, or a DM voice call after the DM was closed:
|
|
// CloseDM removes dm_open_state but performs no voice eviction). This
|
|
// mirrors liveVoiceEventsSince's rationale on the reconnect-replay tier
|
|
// (serve.go), which this full-ready tier had no equivalent for (OC-0028).
|
|
for i := range allVoiceStates {
|
|
if allVoiceStates[i].UserID == userID {
|
|
visibleSet[allVoiceStates[i].ChannelID] = struct{}{}
|
|
}
|
|
}
|
|
voiceStates := make([]db.VoiceState, 0, len(allVoiceStates))
|
|
for i := range allVoiceStates {
|
|
if _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok {
|
|
voiceStates = append(voiceStates, allVoiceStates[i])
|
|
}
|
|
}
|
|
|
|
serverName, motd := h.getCachedSettings(ctx)
|
|
|
|
return buildJSON(map[string]any{
|
|
"type": MsgTypeReady,
|
|
"payload": map[string]any{
|
|
"channels": channelPayloads,
|
|
"members": members,
|
|
"voice_states": voiceStates,
|
|
"roles": roles,
|
|
"dm_channels": dmChannels,
|
|
"server_name": serverName,
|
|
"motd": motd,
|
|
},
|
|
}), nil
|
|
}
|
|
|
|
// collectAllVoiceStates gathers voice states across all channels in a single
|
|
// query, replacing the previous N+1 per-channel pattern.
|
|
func collectAllVoiceStates(ctx context.Context, database *db.DB, _ []db.Channel) ([]db.VoiceState, error) {
|
|
return database.GetAllVoiceStates(ctx)
|
|
}
|