Files
OwnCord/Server/service/channel.go
T
J3vbandClaude Fable 5 03fcb7d518 fix: execute the 2026-08-19 audit fix order (docs refresh + five FRAGILE fixes) (#1396)
* docs(plans): phased remediation plan for the 2026-08-19 audit

Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per
finding group, statuses updated in place as phases land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* test(client): give the renderWindow-breaker test its own timeout (audit F-5)

30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a
loaded runner; the test timed out once under CI-like load and passes in
isolation, so it now carries an explicit 20s budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10)

schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL
(matching 030's rebuild), index inventory rewritten from cumulative migration
state, writer/reader pool split described, default-roles table made a
consistent post-migration snapshot, dbgen preamble updated.

protocol.md: DM chat events documented as sequenced/ring-buffered/replayable
(they are), plugin_broadcast seq flipped to Yes, retry_after claim removed
(no WS error carries it), the five enforced-but-documented-as-None rate
limits added (channel_focus, mark_read, call_decline, chat_command, ping),
E2EE announce/offer budgets corrected incl. the per-target inner cap,
BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/
roles field lists completed, member_join top-level status documented.

api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP
limiter and host:port livekit_url, error-code table now matches emitted codes
(INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap
exemptions listed, identity_public_key documented on PATCH /users/me, plugin
endpoints' plain-text errors + X-Plugin-Runtime header documented, /health
503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply
restart-conflict 409s added.

Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes
(buildReady comment, e2e spec-count comments, logctx stray word, three plan
status headers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16)

Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6
session-cap eviction failure in CreateSession (db/auth_queries.go), and the
channel_focus read-state write failure (service/channel.go) all discarded
their errors with no trace — a brute-force lockout could silently fail to
survive a restart. In-memory behavior is unchanged (warn-and-continue); the
lockout write paths are pinned by tests mirroring OC-0061's load-path test.
The session-cap and read-state sites are log-only additions on seams the
existing suites already exercise on the success path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1)

The block gate ran only at voice_join and voluntary voice_token_refresh, so
a blocked user already in the shared 1:1 DM call kept their session
indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser
now severs the call through the dmVoiceEvictor capability handleCloseDM
already exercises, using a new find-only FindDMChannelIDBetween lookup
(sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group
DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler
tests: shared-DM eviction, no-DM no-op, group-only no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(ws): close the role-reassignment/handshake race (audit F-2)

A role reassignment landing mid-handshake was invisible for the socket's
whole life: both handshake paths resolved permissions from the auth-time
c.user snapshot, revokeUnreadableChannels early-returns for a user not yet
in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a
reconnect replaced the client.

Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and
role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the
resume-fallback path re-reads the role once more after registerNow and runs
the revocation pass when it moved, so the reassignment-vs-registration
orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the
live client immediately before acting, mirroring RefreshChannelVisibility.
Pinned by four tests driving real WS handshakes through the existing race
hooks plus a new pre-register/pre-act hook pair; ws suite green under the
default and deadlock builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* refactor(client): remove the inert replay-dedup machinery (audit F-6)

The server writes auth_ok before the replay burst, so replayDedup — created
on socket-open and cleared when auth_ok is processed — could never be active
for a real replayed frame, and the dispatcher's isReplaying() unread gates
never fired. Their no-op behavior is the correct behavior (a buffer/db
resume has no ready payload, so replayed frames must count as unread), so
the machinery, the gates, and the misleading comments are removed rather
than repaired. The pinning tests injected replay frames in an order a
spec-compliant server never produces; they are replaced by a test pinning
the real contract (frames after auth_ok are dispatched verbatim; duplicate
handling belongs to the stores). Client suite green: 5036/5036.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* docs(plans): mark remediation phases 1-6 done

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(ws): nolint the context-less revoke call golangci-lint flags

revokeUnreadableChannels takes no context by design (admin HubBroadcaster
interface); annotate the one call site inside a ctx-taking function, matching
the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 03:49:13 +02:00

296 lines
12 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/permissions"
"github.com/owncord/server/telemetry"
)
// ChannelService handles channel-related business logic including
// listing, permission-filtered access, typing, presence, and read state.
type ChannelService struct {
st Store
perms *PermissionService
}
// NewChannelService creates a ChannelService.
func NewChannelService(st Store, perms *PermissionService) *ChannelService {
return &ChannelService{
st: st,
perms: perms,
}
}
// ListVisibleChannels returns channels the user has ReadMessages permission for.
// DM channels are excluded (they are accessed via DMService).
func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) ([]db.Channel, error) {
// Phase B Step 8 — span the public service entrypoint.
ctx, span := telemetry.GlobalTracer("service/channel").Start(ctx,
"ChannelService.ListVisibleChannels",
telemetry.Int64("user_id", userID),
)
start := time.Now()
defer func() {
telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start,
telemetry.String("method", "ListVisibleChannels"))
span.End()
}()
all, err := s.st.ListChannels(ctx)
if err != nil {
slog.Error("ChannelService.ListVisibleChannels", "err", err)
return nil, fmt.Errorf("%w: failed to list channels", ErrInternal)
}
role, err := s.perms.GetRoleForUser(ctx, userID)
if err != nil || role == nil {
slog.Error("ChannelService.ListVisibleChannels GetRoleForUser", "err", err, "user_id", userID)
return nil, fmt.Errorf("%w: failed to get role", ErrInternal)
}
// Admins skip the override fetch (they bypass all channel checks anyway).
// Non-admins get both layers — role and per-user — in one batched fetch.
var overrides map[int64]db.ChannelOverride
if !permissions.HasAdmin(role.Permissions) {
overrides, err = s.st.GetChannelOverridesFor(ctx, role.ID, userID)
if err != nil {
// Fail closed — an empty map would return every denied channel.
slog.Error("ChannelService.ListVisibleChannels GetChannelOverridesFor", "err", err, "user_id", userID, "role_id", role.ID)
return nil, fmt.Errorf("%w: failed to fetch channel overrides", ErrInternal)
}
}
// Single visibility predicate shared with the ws ready payload and reconnect
// replay filtering, so REST and WS can never disagree on what a role sees.
visibleIDs := s.perms.Checker().VisibleChannelIDs(role.Permissions, channelRefs(all), permOverrides(overrides))
visible := make([]db.Channel, 0, len(visibleIDs))
for i := range all {
if visibleIDs[all[i].ID] {
visible = append(visible, all[i])
}
}
return visible, nil
}
// channelRefs maps db channels to the checker's db-agnostic ChannelRef.
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
}
// HandleTyping processes a typing start event for a channel.
// Returns the channel so callers can build broadcast events.
// Silent errors are returned as nil (typing indicators are best-effort).
func (s *ChannelService) HandleTyping(ctx context.Context, userID, channelID int64, limiter interface {
Allow(key string, limit int, window time.Duration) bool
},
) (*db.Channel, error) {
if channelID <= 0 {
return nil, nil
}
ch, err := s.st.GetChannel(ctx, channelID)
if err != nil || ch == nil {
return nil, nil //nolint:nilerr // typing indicators are best-effort; errors silently dropped
}
if ch.Type == "dm" {
ok, dmErr := s.st.IsDMParticipant(ctx, userID, channelID)
if dmErr != nil || !ok {
return nil, nil //nolint:nilerr // typing indicators are best-effort; errors silently dropped
}
// A blocked user must not be able to keep poking the blocker with
// typing indicators. Same gate as the other DM sinks; silently dropped
// here because typing is best-effort.
if blkErr := requireDMNotBlocked(ctx, s.st, userID, channelID); blkErr != nil {
return nil, nil //nolint:nilerr // best-effort: a blocked or unreadable DM emits nothing
}
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) {
return nil, nil // silent drop
}
// Per-user-per-channel rate limit. Built only now that the channel is
// known to exist and the caller is authorized to read it (OC-0202): doing
// this before resolution let any caller-supplied channel id — including
// ids that don't exist or aren't readable — pin a new entry in the
// shared, process-wide RateLimiter. RateLimiter.Cleanup only evicts a key
// once every timestamp on it is stale, so a stream of forged channel ids
// could retain an unbounded number of dead map entries for hours.
ratKey := auth.Key(auth.Key("typing", userID), channelID)
if limiter != nil && !limiter.Allow(ratKey, 1, 3*time.Second) {
return nil, nil
}
return ch, nil
}
// GetDMParticipantIDs returns the participant IDs for a DM channel.
// Convenience method for handlers building DM events.
func (s *ChannelService) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) {
return s.st.GetDMParticipantIDs(ctx, channelID)
}
// HandlePresenceUpdate validates and persists a presence status change, and
// (when customStatus is non-nil) the custom status line that came with it.
//
// The status is stored as chosen, invisible included; collapsing invisible to
// offline is a broadcast-time concern (db.BroadcastStatus), not a storage one —
// the server has to be able to tell "chose to look offline" from "is gone" on
// the next connect. Returns the sanitized custom status the caller should put
// on the wire, so the broadcast and the row can never disagree.
func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, status string, customStatus *string, limiter interface {
Allow(key string, limit int, window time.Duration) bool
},
) (*string, error) {
// Rate limit.
ratKey := auth.Key("presence", userID)
if limiter != nil && !limiter.Allow(ratKey, 1, 10*time.Second) {
return nil, ErrRateLimited
}
if !db.ValidStatuses[status] {
return nil, fmt.Errorf("%w: invalid status", ErrBadRequest)
}
var cleaned *string
if customStatus != nil {
text := cleanText(*customStatus)
if utf8.RuneCountInString(text) > MaxCustomStatusLen {
return nil, fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen)
}
cleaned = nullable(text)
}
// Read the stored custom status BEFORE either write, unconditionally.
// custom_status is *string with no omitempty on the wire (see
// presencePayload), so a nil on the broadcast is wire-identical to "the
// user cleared it" — returning nil for a value we merely failed to read
// wipes the text on every connected client while the row still holds it.
// The stored value is needed twice:
// - when the command carries no custom_status field, it is what rides
// along on the broadcast (a plain online -> idle flip must not blank
// everyone else's copy of the text);
// - when it does carry one and the second write below fails after the
// status write has already committed, it is the true DB state the
// broadcast has to report.
// Doing it first means a read failure aborts before anything commits,
// instead of leaving a committed status with nothing truthful to say.
current, readErr := s.st.GetUserByID(ctx, userID)
if readErr != nil || current == nil {
slog.Error("ChannelService.HandlePresenceUpdate: could not read stored custom status",
"err", readErr, "user_id", userID)
return nil, fmt.Errorf("%w: failed to read current custom status", ErrInternal)
}
storedCustomStatus := current.CustomStatus
if err := s.st.UpdateUserStatus(ctx, userID, status); err != nil {
slog.Error("ChannelService.HandlePresenceUpdate", "err", err, "user_id", userID)
return nil, fmt.Errorf("%w: failed to update status", ErrInternal)
}
if customStatus == nil {
return storedCustomStatus, nil
}
if err := s.st.UpdateUserCustomStatus(ctx, userID, cleaned); err != nil {
// The status row is already committed at this point (two independent
// writes, no transaction), so failing the whole update here would
// report total failure — and broadcast nothing — for a presence
// change that in fact partly succeeded, leaving every client
// (sender included) stuck on the old status while the DB has the
// new one. Swallow the write failure and broadcast the value that is
// actually stored, not the unpersisted "cleaned" text.
slog.Error("ChannelService.HandlePresenceUpdate custom status", "err", err, "user_id", userID)
return storedCustomStatus, nil //nolint:nilerr // status committed; broadcast the true stored custom status
}
return cleaned, nil
}
// HandleChannelFocus processes a channel focus event and updates read state.
// Returns the channel for callers to set client state.
func (s *ChannelService) HandleChannelFocus(ctx context.Context, userID, channelID int64) (*db.Channel, error) {
if channelID <= 0 {
return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest)
}
ch, err := s.st.GetChannel(ctx, channelID)
if err != nil || ch == nil {
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
}
switch {
case ch.Type == "dm":
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
if err != nil || !ok {
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
}
case !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages):
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
case ch.Archived:
// Archived channels are hidden from every other client surface
// (ListVisibleChannels, ready payload, reconnect replay, voice join —
// see permissions.Checker.VisibleChannelIDs and ws/voice_join.go).
// HasChannelPerm alone doesn't know about the archive flag, so without
// this a socket that still held the id could resubscribe to the live
// topic and advance its own read state on a channel reconnect replay
// then filters back out. channel_focus and mark_read share this one
// service call, so the guard closes both at once (OC-0070).
return nil, fmt.Errorf("%w: channel is archived", ErrForbidden)
}
// Mark channel as read. latestID == 0 (no undeleted messages) still
// writes: the upsert is what zeroes mention_count, and a last_read of 0 is
// correct then — any future message id is larger, so unread counts hold.
//
// Skip the UPSERT when the stored row already says exactly this (same
// last_message_id, no mentions to clear): channel_focus/mark_read fire on
// every refocus at up to 10/s/user, and even a no-op write occupies the
// single writer connection and opens a transaction. The extra read runs on
// the reader pool, which doesn't serialize. Same problem-shape as the
// session-touch throttle (api/middleware.go). A read failure falls through
// to the write — the write is the load-bearing half.
latestID, err := s.st.GetLatestMessageID(ctx, channelID)
if err == nil {
lastRead, mentions, found, rsErr := s.st.GetReadState(ctx, userID, channelID)
if rsErr == nil && found && lastRead == latestID && mentions == 0 {
slog.Debug("channel_focus: read state already current, skipping write",
"user_id", userID, "channel_id", channelID)
return ch, nil
}
if wErr := s.st.UpdateReadState(ctx, userID, channelID, latestID); wErr != nil {
// Self-heals on the next focus, but a persistently failing write
// means unread badges never clear — it must not be invisible.
slog.Warn("channel_focus: read-state write failed",
"user_id", userID, "channel_id", channelID, "err", wErr)
}
}
slog.Debug("channel_focus", "user_id", userID, "channel_id", channelID)
return ch, nil
}