fix: batch of 34 correctness fixes across server and client (#1372)

* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116)

Route the tray Status submenu through saveUserStatus() (mapping the legacy
"offline" to "invisible") so notifications, autoIdle, and reconnect presence
restore all agree with the tray's choice; build the connected overlay from
the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the
TOTP overlay open across a rejected verify (totpPending latch) and retain
the partial token for the retry instead of clearing it in finally.

Hand-applied combined cluster preserved from the previous fix run's
overlap-guard block (both clusters edit main.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 2 defect(s) (OC-0010, OC-0011)

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

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

* fix(client): 1 defect(s) (OC-0054)

* fix(client): 1 defect(s) (OC-0059)

* fix(auth): 1 defect(s) (OC-0061)

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

* fix(client): 1 defect(s) (OC-0064)

* fix(service): 1 defect(s) (OC-0070)

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

* fix(service): 2 defect(s) (OC-0075, OC-0120)

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

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

* fix(client): 2 defect(s) (OC-0085, OC-0094)

Scope collapsed-category persistence to the connected host instead of the
server display name, and stop the DM back button from jumping to the first
text channel when DM mode was entered without recording channelBeforeDm.

* fix(service): 1 defect(s) (OC-0087)

* fix(client): 1 defect(s) (OC-0089)

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

* fix(api): 1 defect(s) (OC-0093)

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

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

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

* fix(api): 1 defect(s) (OC-0137)

* fix(client): 1 defect(s) (OC-0142)

* fix(client): 1 defect(s) (OC-0144)

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

* fix(updater): 1 defect(s) (OC-0146)

* fix(client): 1 defect(s) (OC-0150)

* fix(mentions): 1 defect(s) (OC-0131)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-14 18:48:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 7be9ccd2f9
commit 8787b9066d
73 changed files with 2492 additions and 189 deletions
+47
View File
@@ -2,6 +2,7 @@ package admin
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/auth"
@@ -11,6 +12,48 @@ import (
"github.com/owncord/server/updater"
)
// setupLimiterReapInterval and setupLimiterReapMaxWindow control how often
// the setup endpoint's dedicated rate limiter reaps stale window entries.
// Vars, not consts, so tests can shrink them instead of waiting on the real
// interval (see export_test.go).
var (
setupLimiterReapInterval = 5 * time.Minute
setupLimiterReapMaxWindow = 15 * time.Minute
)
// setupLimiterHook, when non-nil, receives the *auth.RateLimiter NewAdminAPI
// creates for the /setup endpoint. Test-only seam: NewAdminAPI returns only
// an http.Handler, so tests otherwise have no way to reach that limiter to
// verify it gets reaped.
var setupLimiterHook func(*auth.RateLimiter)
// startSetupLimiterReap keeps rl's window map bounded for the life of the
// process. Every distinct source IP that ever hits POST /setup leaves an
// entry that Allow itself only prunes on a repeat call from that same key —
// a one-shot caller's entry sits forever unless something sweeps the whole
// map. api/router.go reaps its own limiter with RateLimiter.StartCleanup, a
// goroutine parked in a ticker select until a stop channel closes — but
// NewAdminAPI has no shutdown hook and is called directly by ~180 tests that
// never capture one, so a parked goroutine here would leak under every
// test's goleak check. time.AfterFunc self-rescheduling avoids that: between
// fires there is no live goroutine, only a runtime timer, so nothing needs
// to stop it.
func startSetupLimiterReap(rl *auth.RateLimiter) {
// Capture the timing once, synchronously, on the caller's goroutine.
// The rescheduled AfterFunc callbacks below must never re-read the
// package vars themselves: those callbacks run on their own goroutine
// indefinitely (nothing stops the chain), so a later test's
// SetSetupLimiterReapTiming restoring the vars on its own goroutine
// would otherwise race an in-flight reap here.
interval, maxWindow := setupLimiterReapInterval, setupLimiterReapMaxWindow
var reap func()
reap = func() {
rl.Cleanup(maxWindow)
time.AfterFunc(interval, reap)
}
time.AfterFunc(interval, reap)
}
// ─── NewAdminAPI ──────────────────────────────────────────────────────────────
// NewAdminAPI returns a chi router with all /admin/api/* routes. All routes
@@ -32,6 +75,10 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
// Setup endpoints — unauthenticated, only functional when no users exist.
setupLimiter := auth.NewRateLimiter()
if setupLimiterHook != nil {
setupLimiterHook(setupLimiter)
}
startSetupLimiterReap(setupLimiter)
r.Get("/setup/status", handleSetupStatus(database, setupOpts))
r.Post("/setup", handleSetup(database, setupLimiter, allowedOrigins, hub, setupOpts))
+43
View File
@@ -1479,6 +1479,49 @@ func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) {
}
}
// TestAdminAPI_CreateAPIToken_NegativeExpiresHours pins OC-0145: a caller that
// asks for a bounded credential (negative expires_hours) must not silently
// receive a permanent one. The `> 0` check in handleCreateAPIToken sends any
// negative value down the nil-expiresAt ("never expires") branch.
func TestAdminAPI_CreateAPIToken_NegativeExpiresHours(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "neg-hours", "expires_hours": -1})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String())
}
tokens, _ := database.ListAPITokens(context.Background())
for _, tok := range tokens {
if tok.Label == "neg-hours" {
t.Fatalf("negative expires_hours must not mint a token, got %+v", tok)
}
}
}
// TestAdminAPI_CreateAPIToken_HugeExpiresHours pins OC-0145's overflow half: a
// huge expires_hours must not silently overflow time.Duration into a past
// timestamp and hand back a token that 401s on first use.
func TestAdminAPI_CreateAPIToken_HugeExpiresHours(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "huge-hours", "expires_hours": 3000000})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String())
}
tokens, _ := database.ListAPITokens(context.Background())
for _, tok := range tokens {
if tok.Label == "huge-hours" {
t.Fatalf("out-of-range expires_hours must not mint a token, got %+v", tok)
}
}
}
func TestAdminAPI_ListAPITokens_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
+29 -1
View File
@@ -1,6 +1,34 @@
package admin
import "sync/atomic"
import (
"sync/atomic"
"time"
"github.com/owncord/server/auth"
)
// CaptureSetupLimiter installs h so the next NewAdminAPI call reports the
// *auth.RateLimiter it creates for the /setup endpoint. NewAdminAPI returns
// only an http.Handler, so this is the only way tests can reach that limiter
// to check whether its stale entries get reaped.
func CaptureSetupLimiter(h func(*auth.RateLimiter)) (restore func()) {
prev := setupLimiterHook
setupLimiterHook = h
return func() { setupLimiterHook = prev }
}
// SetSetupLimiterReapTiming overrides the interval and max-window the setup
// endpoint's rate-limiter reaper uses, so tests don't wait on the real
// 5-minute interval.
func SetSetupLimiterReapTiming(interval, maxWindow time.Duration) (restore func()) {
prevI, prevW := setupLimiterReapInterval, setupLimiterReapMaxWindow
setupLimiterReapInterval = interval
setupLimiterReapMaxWindow = maxWindow
return func() {
setupLimiterReapInterval = prevI
setupLimiterReapMaxWindow = prevW
}
}
// SetBackupBaseDir overrides backupBaseDir so tests can point backup handlers
// at a temp dir. Lives here so it stays out of the production binary.
+8
View File
@@ -63,6 +63,14 @@ func handleCreateAPIToken(database *db.DB) http.HandlerFunc {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "label is required")
return
}
// expires_hours=0 means "never expires" (see createTokenRequest doc).
// Negatives must not fall into that same nil-expiresAt branch, and the
// upper bound keeps time.Duration(hours)*time.Hour from overflowing
// int64 nanoseconds into a past timestamp. 87600h = 10 years.
if req.ExpiresHours < 0 || req.ExpiresHours > 24*365*10 {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "expires_hours must be between 0 and 87600")
return
}
var user *db.User
var err error
+65
View File
@@ -0,0 +1,65 @@
package admin_test
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
)
// TestSetupLimiter_ReapsStaleEntries pins OC-0076: setupLimiter — the
// dedicated auth.RateLimiter behind POST /setup — is never reaped, so a
// distinct one-shot source IP (the common case once the server is already
// configured: every unauthenticated caller 403s but still records a rate
// limit entry before the CreateOwnerIfEmpty check rejects them) leaves a
// windows[] entry that lives forever. Unlike a repeat caller, whose entry
// self-prunes on its next Allow() call, a one-shot caller never revisits its
// key, so only a periodic sweep (RateLimiter.Cleanup) can ever evict it.
func TestSetupLimiter_ReapsStaleEntries(t *testing.T) {
restoreTiming := admin.SetSetupLimiterReapTiming(5*time.Millisecond, 5*time.Millisecond)
defer restoreTiming()
var limiter *auth.RateLimiter
restoreHook := admin.CaptureSetupLimiter(func(rl *auth.RateLimiter) { limiter = rl })
defer restoreHook()
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
if limiter == nil {
t.Fatal("setup limiter was not captured — CaptureSetupLimiter hook not wired into NewAdminAPI")
}
// Simulate 20 distinct source IPs each making one POST /setup request —
// each leaves its own windows[] entry that nothing but a reap can evict.
const n = 20
for i := range n {
req := httptest.NewRequest(http.MethodPost, "/setup", strings.NewReader(`{}`))
req.RemoteAddr = fmt.Sprintf("203.0.113.%d:1234", i)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
}
if wins, _ := limiter.Len(); wins != n {
t.Fatalf("Len().windows = %d immediately after %d one-shot requests, want %d", wins, n, n)
}
// Wait well past the (shrunk) reap interval + max window for the sweep
// to evict every now-stale entry.
deadline := time.Now().Add(2 * time.Second)
for {
wins, _ := limiter.Len()
if wins == 0 {
return
}
if time.Now().After(deadline) {
t.Fatalf("Len().windows = %d after waiting past the reap interval, want 0 — setupLimiter is never reaped (OC-0076)", wins)
}
time.Sleep(5 * time.Millisecond)
}
}
+4 -3
View File
@@ -109,7 +109,7 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t
r.Route("/api/v1/auth", func(r chi.Router) {
r.With(RateLimitMiddleware(registerLimiter, "register:", registerRateLimitPerMinute, time.Minute, trustedProxies)).
Post("/register", handleRegister(database))
Post("/register", handleRegister(database, trustedProxies))
r.With(RateLimitMiddleware(loginLimiter, "login:", loginRateLimitPerMinute, time.Minute, trustedProxies)).
Post("/login", handleLogin(database, limiter, partialStore, trustedProxies))
@@ -142,7 +142,8 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t
}
// handleRegister processes POST /api/v1/auth/register.
func handleRegister(database *db.DB) http.HandlerFunc {
func handleRegister(database *db.DB, trustedProxies []string) http.HandlerFunc {
proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction
return func(w http.ResponseWriter, r *http.Request) {
registrationOpen, err := isRegistrationOpen(r.Context(), database)
if err != nil {
@@ -252,7 +253,7 @@ func handleRegister(database *db.DB) http.HandlerFunc {
return
}
ip := clientIP(r)
ip := clientIPWithProxies(r, proxyNets)
slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip)
db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "user_register", "user", uid,
"new account created via invite")
+43
View File
@@ -1746,3 +1746,46 @@ func TestRegister_ExpiredInvite(t *testing.T) {
t.Errorf("Register expired invite status = %d, want 400", rr.Code)
}
}
// TestRegister_UsesTrustedForwardedIP pins OC-0093: handleRegister must
// resolve the client IP through the same trusted-proxy list handleLogin
// uses, not unconditionally use RemoteAddr. Behind a trusted reverse proxy,
// the sessions.ip row registration creates must record the real client, not
// the proxy's own address — otherwise the same client shows two different
// IPs on the "active sessions" screen depending on whether they registered
// or logged in.
func TestRegister_UsesTrustedForwardedIP(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouterWithProxies(database, limiter, []string{"127.0.0.0/8"})
ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1)
code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte(
`{"username":"newuser","password":"securePass1","invite_code":"`+code+`"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "203.0.113.9")
req.RemoteAddr = "127.0.0.1:9999" // the trusted reverse proxy's own hop
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
token, _ := resp["token"].(string)
if token == "" {
t.Fatal("Register response missing token")
}
sess, err := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token))
if err != nil || sess == nil {
t.Fatalf("GetSessionByTokenHash: %v", err)
}
if sess.IP != "203.0.113.9" {
t.Errorf("session IP = %q, want the trusted-forwarded client IP %q — registration behind a reverse proxy must not record the proxy's own address", sess.IP, "203.0.113.9")
}
}
+16
View File
@@ -184,6 +184,22 @@ func TestCreateGroupDM_BlockedCannotAddBlocker(t *testing.T) {
}
}
func TestCreateGroupDM_RejectsMutuallyBlockedRecipients(t *testing.T) {
database, router, _, tokens := groupFixture(t)
// carol blocks bob; neither blocked alice, so alice (an uninvolved third
// party) must not be able to force them into a shared group DM.
if err := database.BlockUser(context.Background(), 3, 2); err != nil {
t.Fatalf("BlockUser: %v", err)
}
rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{
"recipient_ids": []int64{2, 3},
})
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403 when two recipients have blocked each other, got %d: %s", rr.Code, rr.Body.String())
}
}
// ─── listing ────────────────────────────────────────────────────────────────
func TestListDMs_ReturnsGroupWithParticipants(t *testing.T) {
+22 -2
View File
@@ -3,7 +3,6 @@ package api
import (
"database/sql"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
@@ -103,6 +102,27 @@ func isUnsafeInlineMIME(mimeType string) bool {
return false
}
// safeStorageErrorMessage maps a storage.Save error to a client-safe
// "upload rejected" body. Full detail always goes to slog.Warn at the call
// site — this only decides what crosses the HTTP boundary. storage.Save's
// failure messages are built with fmt.Errorf("... %s", dst) / %w around
// path-bearing OS errors (creating the file, syncing it, or the destination
// resolving outside the storage dir), so echoing them verbatim hands any
// authenticated user the server's absolute storage layout the moment a save
// fails (disk full, permission change, read-only mount). The two validation
// failures below are the only ones that never embed a path, so they're the
// only ones whose detail is forwarded.
func safeStorageErrorMessage(err error) string {
msg := err.Error()
switch {
case strings.HasPrefix(msg, "blocked file type:"),
strings.HasPrefix(msg, "file exceeds maximum size"):
return "upload rejected: " + msg
default:
return "upload rejected"
}
}
// MountUploadRoutes registers upload and file-serving endpoints.
// allowedOrigins controls the Access-Control-Allow-Origin header on served files.
//
@@ -190,7 +210,7 @@ func handleUpload(database *db.DB, store *storage.Storage, limiter *auth.RateLim
slog.Warn("file upload rejected", "error", saveErr)
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST",
Message: fmt.Sprintf("upload rejected: %s", saveErr),
Message: safeStorageErrorMessage(saveErr),
})
return
}
+43
View File
@@ -568,6 +568,49 @@ func TestUpload_OversizedFileRejected(t *testing.T) {
}
}
// OC-0137: storage.Save's error strings embed the resolved absolute
// destination path ("creating file %s", "syncing file %s", "resolved path %q
// escapes storage directory"). handleUpload must not forward that text to the
// client — only log it — or any authenticated user who triggers a storage
// failure (disk full, permission change, read-only mount) learns the
// server's absolute storage directory layout.
func TestUpload_StorageErrorDoesNotLeakPath(t *testing.T) {
database := newUploadTestDB(t)
dir := t.TempDir()
store, err := storage.New(dir, 10)
if err != nil {
t.Fatalf("storage.New: %v", err)
}
router := buildUploadRouter(database, store, nil)
token := uploadCreateToken(t, database, "leakuser", 1)
// Remove the storage directory out from under the already-constructed
// Storage so Save's os.Create fails — this is what a disk-full,
// permission-change, or read-only-mount failure looks like from the
// handler's point of view: a storage-layer error surfaces at Save time.
if err := os.RemoveAll(dir); err != nil {
t.Fatalf("RemoveAll: %v", err)
}
content := []byte("content that will fail to persist because the storage dir is gone")
rr := doUpload(t, router, token, "file", "leaktest.txt", content)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
message, _ := resp["message"].(string)
if strings.Contains(message, dir) {
t.Fatalf("response message leaks the absolute storage path: %q", message)
}
if strings.ContainsAny(message, `/\`) {
t.Fatalf("response message looks like it contains a filesystem path: %q", message)
}
}
func TestUpload_DBCreateAttachmentFailureDeletesStoredFile(t *testing.T) {
database := newUploadTestDB(t)
dir := t.TempDir()
+3
View File
@@ -2,6 +2,7 @@ package auth
import (
"context"
"log/slog"
"strconv"
"time"
@@ -83,6 +84,8 @@ func NewPersistentRateLimiter(store LockoutPersister) *RateLimiter {
for i, key := range keys {
rl.shardFor(key).lockouts[key] = &lockoutEntry{expiresAt: expiresAt[i]}
}
} else {
slog.Warn("ratelimit: failed to load persisted lockouts; starting with none", "err", err)
}
return rl
}
+48
View File
@@ -0,0 +1,48 @@
package auth_test
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
"github.com/owncord/server/auth"
)
// failingLockoutStore reports an error from LoadActiveLockouts, simulating a
// transient DB failure (SQLITE_BUSY, disk I/O error) at startup.
type failingLockoutStore struct{}
func (failingLockoutStore) UpsertLockout(context.Context, string, time.Time) error { return nil }
func (failingLockoutStore) DeleteLockout(context.Context, string) error { return nil }
func (failingLockoutStore) CleanupExpiredLockouts(context.Context) error { return nil }
func (failingLockoutStore) LoadActiveLockouts(context.Context) ([]string, []time.Time, error) {
return nil, nil, errors.New("database is locked")
}
// captureLogs redirects the default slog logger to a buffer for the duration
// of fn and returns everything it wrote. Mirrors db/audit_test.go's helper.
func captureLogs(t *testing.T, fn func()) string {
t.Helper()
var buf strings.Builder
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(prev)
fn()
return buf.String()
}
// TestNewPersistentRateLimiter_LoadErrorIsLogged pins OC-0061: a failed
// LoadActiveLockouts must not be swallowed silently — it has to produce a log
// line so an operator can notice that persisted lockouts were dropped.
func TestNewPersistentRateLimiter_LoadErrorIsLogged(t *testing.T) {
out := captureLogs(t, func() {
auth.NewPersistentRateLimiter(failingLockoutStore{})
})
if !strings.Contains(out, "database is locked") {
t.Errorf("expected log output to mention the load error, got: %q", out)
}
}
+18
View File
@@ -150,6 +150,24 @@ func (d *DB) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, chan
return scanEventRows(rows)
}
// CountEventsInRange returns the UNFILTERED (all channels) count of events
// with afterSeq < seq <= uptoSeq. seq is the events table's primary key, so
// this can only ever come up short of (uptoSeq - afterSeq), never over —
// callers use that to detect an interior gap left by a lost row (a dropped
// EventPersister enqueue, or a failed row in a batch flush) without having to
// enumerate every seq in the range.
func (d *DB) CountEventsInRange(ctx context.Context, afterSeq, uptoSeq int64) (int64, error) {
var count int64
err := d.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM events WHERE seq > ? AND seq <= ?`,
afterSeq, uptoSeq,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("CountEventsInRange: %w", err)
}
return count, nil
}
// GetMaxEventSeq returns the largest seq in the events table, or 0 if empty.
func (d *DB) GetMaxEventSeq(ctx context.Context) (int64, error) {
var maxSeq sql.NullInt64
+25 -4
View File
@@ -255,9 +255,30 @@ func (d *DB) GetMentionCount(ctx context.Context, userID, channelID int64) (int,
return count, nil
}
// GetUserIDsByUsernames resolves usernames to ids, keyed by the lowercased
// username. Matching is case-insensitive because users.username is UNIQUE
// COLLATE NOCASE, which makes the column's comparisons case-insensitive too.
// LowerASCII lowercases only ASCII letters ('A'-'Z'), matching the fold
// SQLite's COLLATE NOCASE applies to users.username (see notBannedClause's
// sibling comment above and the migration that declares the column). Go's
// strings.ToLower is Unicode-aware and would fold a non-ASCII uppercase
// letter (e.g. 'É' -> 'é') that NOCASE does not touch, desyncing a Go-side
// lookup key from a query bound against the same column. Every mention
// lookup that builds a key or a query argument from a username must fold
// through this instead of strings.ToLower, or the two folds silently
// disagree on any non-ASCII-uppercase username.
func LowerASCII(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + ('a' - 'A')
}
}
return string(b)
}
// GetUserIDsByUsernames resolves usernames to ids, keyed by the ASCII-lowered
// username (see LowerASCII). Matching is case-insensitive because
// users.username is UNIQUE COLLATE NOCASE, which makes the column's
// comparisons case-insensitive too -- but ASCII-only, which is why the map
// key folds no harder than that.
func (d *DB) GetUserIDsByUsernames(ctx context.Context, usernames []string) (map[string]int64, error) {
result := make(map[string]int64)
if len(usernames) == 0 {
@@ -288,7 +309,7 @@ func (d *DB) GetUserIDsByUsernames(ctx context.Context, usernames []string) (map
if scanErr := rows.Scan(&id, &name); scanErr != nil {
return nil, fmt.Errorf("GetUserIDsByUsernames scan: %w", scanErr)
}
result[strings.ToLower(name)] = id
result[LowerASCII(name)] = id
}
if rows.Err() != nil {
return nil, fmt.Errorf("GetUserIDsByUsernames rows: %w", rows.Err())
+27
View File
@@ -282,6 +282,33 @@ func TestGetUserIDsByUsernames_CaseInsensitive(t *testing.T) {
}
}
// TestGetUserIDsByUsernames_NonASCIIUppercase locks OC-0131: a username
// holding an uppercase non-ASCII letter (legal per auth.ValidateUsername,
// e.g. "Émile") must resolve through the exact same spelling it was queried
// with. users.username is only COLLATE NOCASE, which folds ASCII A-Z only, so
// the map key this function builds from the returned row must fold no harder
// than that column does -- a Unicode-aware strings.ToLower would fold 'É' to
// 'é' here and desync the key from the caller's (equally ASCII-folded)
// lookup spelling, making the row permanently unreachable by name.
func TestGetUserIDsByUsernames_NonASCIIUppercase(t *testing.T) {
database := newMigratedTestDB(t)
seedMentionFixture(t, database)
ctx := context.Background()
uid, err := database.CreateUser(ctx, "Émile", "hash", 4)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
got, err := database.GetUserIDsByUsernames(ctx, []string{"Émile"})
if err != nil {
t.Fatalf("GetUserIDsByUsernames: %v", err)
}
if got["Émile"] != uid {
t.Errorf(`result["Émile"] = %d, want %d (map key must match the query spelling for a non-ASCII-uppercase username)`, got["Émile"], uid)
}
}
// TestGetUserIDsByUsernames_LapsedTempBan_StillResolves locks the "reconverged
// raw column" fix: nothing clears users.banned when a temp ban's ban_expires
// lapses (that's decided lazily, at login, by auth.IsEffectivelyBanned), so a
+14 -4
View File
@@ -638,8 +638,18 @@ func (d *DB) GetLatestMessageID(ctx context.Context, channelID int64) (int64, er
return id, nil
}
// GetPinnedMessages returns all pinned messages in a channel in the API response shape,
// including user object, reactions (with me flag), and attachments.
// MaxPinnedMessages bounds how many pinned messages a single channel query
// returns. Without a cap, scanAndEnrichMessages feeds every pinned message ID
// into several `IN (?,?,...)` batch lookups (reactions, attachments,
// mentions); past SQLite's ~32766 bound-parameter limit that fails outright
// ("too many SQL variables"), and the pins endpoint then 500s on every call
// for that channel forever. The cap sits far below that ceiling, with room to
// spare across all three batch queries.
const MaxPinnedMessages = 1000
// GetPinnedMessages returns up to MaxPinnedMessages pinned messages in a
// channel, most-recently-pinned first, in the API response shape, including
// user object, reactions (with me flag), and attachments.
func (d *DB) GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]MessageAPIResponse, error) {
rows, err := d.reader.QueryContext(ctx,
`SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar,
@@ -647,8 +657,8 @@ func (d *DB) GetPinnedMessages(ctx context.Context, channelID int64, requestingU
m.mentions_everyone
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ? AND m.pinned = 1 AND m.deleted = 0
ORDER BY m.id DESC`,
channelID,
ORDER BY m.id DESC LIMIT ?`,
channelID, MaxPinnedMessages,
)
if err != nil {
return nil, fmt.Errorf("GetPinnedMessages: %w", err)
+33
View File
@@ -1301,6 +1301,39 @@ func TestGetChannelUnreadCounts_IncludesParticipatingDMs(t *testing.T) {
}
}
// ─── GetPinnedMessages ────────────────────────────────────────────────────────
// TestGetPinnedMessages_Capped pins more messages than db.MaxPinnedMessages and
// verifies the query stays bounded instead of returning every pinned row. An
// uncapped GetPinnedMessages feeds an unbounded message-ID slice into the
// shared IN-list batch fetches (reactions/attachments/mentions); past
// SQLite's ~32766 bound-parameter limit that fails every call permanently
// ("too many SQL variables"). This pins the cap well below that ceiling.
func TestGetPinnedMessages_Capped(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "pinner")
chID := seedChannel(t, database, "pins")
total := db.MaxPinnedMessages + 5
for i := range total {
id, err := database.CreateMessage(context.Background(), chID, userID, "msg", nil)
if err != nil {
t.Fatalf("CreateMessage[%d]: %v", i, err)
}
if err := database.SetMessagePinned(context.Background(), id, true); err != nil {
t.Fatalf("SetMessagePinned[%d]: %v", i, err)
}
}
msgs, err := database.GetPinnedMessages(context.Background(), chID, userID)
if err != nil {
t.Fatalf("GetPinnedMessages: %v", err)
}
if len(msgs) > db.MaxPinnedMessages {
t.Errorf("GetPinnedMessages returned %d pins, want <= MaxPinnedMessages (%d)", len(msgs), db.MaxPinnedMessages)
}
}
func TestGetChannelUnreadCounts_ExcludesForeignDMs(t *testing.T) {
database := openMigratedMemory(t)
alice := seedUser(t, database, "dmforeignalice")
+13 -2
View File
@@ -237,13 +237,24 @@ func (s *ChannelService) HandleChannelFocus(ctx context.Context, userID, channel
return nil, fmt.Errorf("%w: channel not found", ErrNotFound)
}
if ch.Type == "dm" {
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)
}
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) {
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
+59
View File
@@ -41,6 +41,65 @@ func TestListVisibleChannels_OverrideFetchErrorFailsClosed(t *testing.T) {
}
}
// TestHandleChannelFocus_RefusedInArchivedChannel locks OC-0070: archived
// channels are hidden from every other client surface (ListVisibleChannels,
// the ws ready payload, RefreshChannelVisibility, voice join) but
// HandleChannelFocus never consulted ch.Archived, so a socket that still held
// the channel id could re-subscribe to its live event stream — and advance
// its own read state — on a channel reconnect replay (computeAllowedChannels)
// would then filter out. focus and mark_read share this one service call, so
// gating it here closes both.
func TestHandleChannelFocus_RefusedInArchivedChannel(t *testing.T) {
ctx := context.Background()
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages,
Position: 1,
})
seedUserRole(t, database, 1, permissions.MemberRoleID)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
svc := NewChannelService(database, NewPermissionService(database, permissions.NewChecker(database)))
// Precondition: focus succeeds while the channel is not archived.
if _, err := svc.HandleChannelFocus(ctx, 1, 10); err != nil {
t.Fatalf("precondition: focus on a live channel: %v", err)
}
if _, err := database.ExecContext(ctx,
`UPDATE channels SET archived = 1 WHERE id = 10`); err != nil {
t.Fatalf("archive channel: %v", err)
}
_, err := svc.HandleChannelFocus(ctx, 1, 10)
if err == nil {
t.Fatal("HandleChannelFocus on an archived channel succeeded — the socket can still subscribe to its live event stream")
}
if !errors.Is(err, ErrForbidden) {
t.Fatalf("HandleChannelFocus error = %v, want ErrForbidden", err)
}
}
// TestHandleChannelFocus_DMExemptFromArchiveGate makes sure the archive gate
// above is scoped to non-DM channels only — DMs carry no archived concept.
func TestHandleChannelFocus_DMExemptFromArchiveGate(t *testing.T) {
ctx := context.Background()
database := newTestDB(t)
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
seedUser(t, database, &db.User{ID: 2, Username: "bob"})
seedChannel(t, database, &db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"})
seedDMParticipant(t, database, 50, 1)
seedDMParticipant(t, database, 50, 2)
svc := NewChannelService(database, NewPermissionService(database, permissions.NewChecker(database)))
if _, err := svc.HandleChannelFocus(ctx, 1, 50); err != nil {
t.Fatalf("HandleChannelFocus on a DM: %v", err)
}
}
// TestHandleTyping_BlockedInDMEmitsNothing completes the DM-block sweep: a
// blocked user could still drive a repeatable typing indicator at the blocker,
// because HandleTyping authorized on DM participation alone. Typing is
+17 -7
View File
@@ -247,16 +247,26 @@ func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientID
if auth.IsEffectivelyBanned(user) {
return nil, fmt.Errorf("%w: recipient not found", ErrNotFound)
}
blocked, err := s.st.IsEitherBlocked(ctx, userID, rid)
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)
}
}
// 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)
+6 -1
View File
@@ -66,7 +66,12 @@ func parseMentionTokens(content string) (tokens []mentionCandidate, everyone, he
if m[3] == "@" {
continue // address-shaped, e.g. "@bob@example.com"
}
raw := strings.ToLower(m[2])
// db.LowerASCII, not strings.ToLower: usernames.username is only
// COLLATE NOCASE, which folds ASCII A-Z only. A Unicode fold here
// (e.g. 'É' -> 'é') would desync this token from GetUserIDsByUsernames'
// equally ASCII-folded map key, so a username holding an uppercase
// non-ASCII letter could never resolve (OC-0131).
raw := db.LowerASCII(m[2])
switch raw {
case everyoneToken:
everyone = true
+19
View File
@@ -168,6 +168,25 @@ func TestSendMessage_CaseInsensitiveUsername(t *testing.T) {
}
}
// TestSendMessage_NonASCIIUppercaseUsernameResolves locks OC-0131: a username
// holding an uppercase non-ASCII letter is legal (auth.ValidateUsername only
// rejects control/format runes) and must still be @mentionable. Go's
// Unicode-aware strings.ToLower would fold "Émile" to "émile" before the
// lookup ever reaches SQL, but users.username is only COLLATE NOCASE, which
// folds ASCII A-Z only -- so a Unicode-lowered token can never match the
// stored non-ASCII-uppercase row, and the mention silently degrades to plain
// text.
func TestSendMessage_NonASCIIUppercaseUsernameResolves(t *testing.T) {
svc, _, database := newMentionFixture(t)
seedUser(t, database, &db.User{ID: 5, Username: "Émile", Status: "online"})
seedUserRole(t, database, 5, permissions.MemberRoleID)
res := sendAs(t, svc, 1, "hey @Émile")
if len(res.Mentions) != 1 || res.Mentions[0] != 5 {
t.Fatalf("mentions = %v, want [5] (Émile must resolve)", res.Mentions)
}
}
func TestSendMessage_UnknownWordStaysText(t *testing.T) {
svc, _, database := newMentionFixture(t)
+9 -2
View File
@@ -41,10 +41,17 @@ func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int
// Also include DM channels the user participates in. Only the IDs are
// needed here, so skip the full DM query's preview/unread work.
//
// A failed lookup must not silently shrink the accessible set to guild
// channels only — SearchMessages (message_query.go) treats this list as
// authoritative and would otherwise report a successful, DM-stripped
// result instead of failing. Same posture as the ws sibling,
// computeAllowedChannels in ws/serve.go.
dmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID)
if err == nil {
ids = append(ids, dmIDs...)
if err != nil {
return nil, fmt.Errorf("%w: failed to fetch DM channels: %v", ErrInternal, err)
}
ids = append(ids, dmIDs...)
return ids, nil
}
+53
View File
@@ -0,0 +1,53 @@
package service
import (
"context"
"errors"
"testing"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// errDMChannelIDsStore wraps a real *db.DB but always fails
// GetUserDMChannelIDs, so GetAccessibleChannelIDs' fail-closed contract
// (OC-0087) is testable. Embedding *db.DB satisfies the service Store
// interface; only the overridden method diverges.
type errDMChannelIDsStore struct {
*db.DB
}
func (errDMChannelIDsStore) GetUserDMChannelIDs(context.Context, int64) ([]int64, error) {
return nil, errors.New("boom")
}
// TestGetAccessibleChannelIDs_DMLookupErrorFailsClosed locks OC-0087: a
// transient GetUserDMChannelIDs failure must not silently degrade the
// accessible-channel set to guild channels only. Before the fix the error was
// discarded (`if err == nil { ids = append(...) }`), so GetAccessibleChannelIDs
// returned (nil error, truncated set) and SearchMessages read back a
// successful-but-DM-stripped result — exactly the hole the ws sibling
// (computeAllowedChannels in ws/serve.go) was deliberately hardened against.
func TestGetAccessibleChannelIDs_DMLookupErrorFailsClosed(t *testing.T) {
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages,
Position: 1,
})
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
seedUserRole(t, database, 1, permissions.MemberRoleID)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
checker := permissions.NewChecker(database)
svc := NewMessageService(errDMChannelIDsStore{database}, NewPermissionService(database, checker), nil)
ids, err := svc.GetAccessibleChannelIDs(context.Background(), 1)
if err == nil {
t.Fatalf("expected an error when the DM lookup fails, got ids=%v, nil error", ids)
}
if !errors.Is(err, ErrInternal) {
t.Fatalf("error = %v, want ErrInternal", err)
}
}
@@ -155,6 +155,26 @@ func TestGetReactionUsers_ForeignDMIsNotFound(t *testing.T) {
}
}
// A soft-deleted message must not leak its reactor list. Its siblings in the
// same file/package already refuse a deleted message: handleReaction (this
// file) and GetMessagesAround (message_query.go) both check msg.Deleted, but
// GetReactionUsers had no such guard, so a tombstoned message's reactions
// stayed forever fetchable by direct URL even though the client no longer
// renders the message at all.
func TestGetReactionUsers_DeletedMessageIsNotFound(t *testing.T) {
svc, database := newTestMessageService(t)
msgID := seedReactedMessage(t, svc, database, "👍", 1)
if err := database.DeleteMessage(context.Background(), msgID, 1, false); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
_, err := svc.GetReactionUsers(context.Background(), 1, 10, msgID, "👍")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
// A custom emoji is reacted with as its ":shortcode:" literal, so the longest
// shortcode the emoji service will accept has to fit inside the reaction length
// cap. Before the cap was derived from MaxShortcodeLen, a 31- or 32-character
+10 -2
View File
@@ -39,7 +39,7 @@ func (s *MessageService) GetReactionUsers(ctx context.Context, userID, channelID
}
msg, err := s.st.GetMessage(ctx, msgID)
if err != nil || msg == nil || msg.ChannelID != channelID {
if err != nil || msg == nil || msg.ChannelID != channelID || msg.Deleted {
return nil, fmt.Errorf("%w: message not found", ErrNotFound)
}
@@ -102,8 +102,16 @@ func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64
return nil, fmt.Errorf("%w: cannot react to deleted message", ErrBadRequest)
}
// Fail closed, mirroring EditMessage/DeleteMessage (message_crud.go): a
// lookup failure must not fall through to the non-DM permission branch
// below. That branch passes on the base role mask alone
// (READ_MESSAGES|ADD_REACTIONS, no per-channel override exists for a DM),
// skipping both IsDMParticipant and requireDMNotBlocked entirely.
ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)
isDM := chErr == nil && ch != nil && ch.Type == "dm"
if chErr != nil || ch == nil {
return nil, fmt.Errorf("%w: cannot react to this message", ErrForbidden)
}
isDM := ch.Type == "dm"
// Archived channels are read-only. handleReaction bypasses
// checkSendPermission (it runs its own DM/permission branch below), so it
+70
View File
@@ -21,6 +21,76 @@ func (errDMParticipantsStore) GetDMParticipantIDs(context.Context, int64) ([]int
return nil, errors.New("boom")
}
// errGetChannelStore wraps a real *db.DB but fails GetChannel for one
// specific channel id, leaving every other call (including GetMessage) to
// hit the real database. Used to simulate a transient GetChannel error
// mid-request without disturbing the rest of the fixture.
type errGetChannelStore struct {
*db.DB
failChannelID int64
}
func (s errGetChannelStore) GetChannel(ctx context.Context, id int64) (*db.Channel, error) {
if id == s.failChannelID {
return nil, errors.New("boom")
}
return s.DB.GetChannel(ctx, id)
}
// TestHandleReaction_ChannelLookupErrorFailsClosed locks OC-0075: a
// GetChannel error during handleReaction must not be treated as "not a DM".
// Before the fix, isDM := chErr == nil && ch != nil && ch.Type == "dm" quietly
// became false on any lookup error, routing a DM message into the role-based
// permission branch. That branch checks HasChannelPerm against the base role
// mask (no channel-override rows exist for a DM), so any user with the
// ordinary READ_MESSAGES|ADD_REACTIONS member permissions could react inside
// a private DM they are not a participant of, and the reaction would be
// fanned out as a channel event instead of a DM event.
func TestHandleReaction_ChannelLookupErrorFailsClosed(t *testing.T) {
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions,
Position: 1,
})
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
seedUser(t, database, &db.User{ID: 2, Username: "bob"})
seedUser(t, database, &db.User{ID: 3, Username: "mallory"})
seedUserRole(t, database, 1, permissions.MemberRoleID)
seedUserRole(t, database, 2, permissions.MemberRoleID)
seedUserRole(t, database, 3, permissions.MemberRoleID)
permSvc := NewPermissionService(database, permissions.NewChecker(database))
ch, _, err := database.GetOrCreateDMChannel(context.Background(), 1, 2)
if err != nil {
t.Fatalf("GetOrCreateDMChannel: %v", err)
}
msgID, err := database.CreateMessage(context.Background(), ch.ID, 1, "just us", nil)
if err != nil {
t.Fatalf("CreateMessage: %v", err)
}
failingStore := errGetChannelStore{DB: database, failChannelID: ch.ID}
svc := NewMessageService(failingStore, permSvc, nil)
// Mallory is not a DM participant, but carries the ordinary member role's
// READ_MESSAGES|ADD_REACTIONS. With GetChannel failing, a fail-open
// implementation lets this through as a non-DM reaction.
if _, err := svc.AddReaction(context.Background(), 3, msgID, "👍"); err == nil {
t.Fatal("AddReaction must fail when GetChannel errors mid-request, not fall open into the non-DM branch")
}
counts, err := database.GetReactions(context.Background(), msgID)
if err != nil {
t.Fatalf("GetReactions: %v", err)
}
if len(counts) != 0 {
t.Fatalf("reaction must not be committed for a non-participant when the DM channel lookup failed, got %d reaction rows", len(counts))
}
}
// TestHandleReaction_DMParticipantFetchErrorFailsClosed locks OC-0069: a DM
// reaction must not be persisted with no way to notify anyone. Before the
// fix, handleReaction committed AddReaction/RemoveReaction first and only
@@ -0,0 +1,58 @@
package updater
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
)
// A burst of concurrent CheckForUpdate calls against an expired/cold release
// cache must collapse into a single outbound GitHub fetch. Without
// singleflight, every caller that observes the cache as expired issues its
// own outbound request (OC-0146).
func TestCheckForUpdateCoalescesConcurrentMisses(t *testing.T) {
var hits atomic.Int64
release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v2.0.0")
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(release); err != nil {
t.Fatalf("encoding release: %v", err)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
const callers = 25
var wg sync.WaitGroup
errs := make([]error, callers)
start := make(chan struct{})
for i := range callers {
wg.Go(func() {
<-start // release all goroutines together to force a real burst
_, errs[i] = u.CheckForUpdate(context.Background())
})
}
close(start)
wg.Wait()
for i := range callers {
if errs[i] != nil {
t.Fatalf("caller %d: unexpected error: %v", i, errs[i])
}
}
if got := hits.Load(); got != 1 {
t.Fatalf("outbound fetches = %d, want exactly 1 (singleflight should coalesce)", got)
}
}
+57 -29
View File
@@ -90,6 +90,7 @@ type Updater struct {
errCacheExpiry time.Time
textAssetCache map[string]textAssetCacheEntry
textAssetSF singleflight.Group
releaseSF singleflight.Group
mu syncutil.Mutex
httpClient *http.Client
signingKeyText string
@@ -153,39 +154,66 @@ func detachFetch(ctx context.Context) (context.Context, context.CancelFunc) {
// fetch is detached from ctx (see detachFetch), so cancelling ctx does not
// abort it or write a failure into the shared cache.
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) {
now := time.Now()
u.mu.Lock()
if u.cache != nil && now.Before(u.cacheExpiry) {
cached := *u.cache
if info, err, ok := u.lookupRelease(time.Now()); ok {
return info, err
}
// Coalesce concurrent misses: when the cache TTL expires under load, every
// caller would otherwise issue its own outbound GitHub fetch (OC-0146).
// One flight runs and the rest wait on its result, exactly like
// FetchTextAssetCached's textAssetSF.
//
// The flight is detached from the leader's ctx (see detachFetch): callers
// include the unauthenticated client-update endpoint, so a leader that
// aborts its request must not fail its followers or write its own
// context.Canceled into the shared cache.
v, err, _ := u.releaseSF.Do("latest-release", func() (any, error) {
now := time.Now()
// Re-check: another flight may have filled the cache while we queued.
if info, err, ok := u.lookupRelease(now); ok {
return info, err
}
fetchCtx, cancel := detachFetch(ctx)
defer cancel()
info, err := u.fetchLatestRelease(fetchCtx)
if err != nil {
u.mu.Lock()
u.cachedErr = err
u.errCacheExpiry = now.Add(errorCacheTTL)
u.mu.Unlock()
return UpdateInfo{}, err
}
u.mu.Lock()
u.cache = &info
u.cacheExpiry = now.Add(cacheTTL)
u.cachedErr = nil
u.mu.Unlock()
return cached, nil
return info, nil
})
if err != nil {
return UpdateInfo{}, err
}
return v.(UpdateInfo), nil
}
// lookupRelease returns a live cached release or cached error, if either
// exists. The third return value reports whether the cache was live (a
// caller should return the first two values directly); a false miss means
// the caller must fetch.
func (u *Updater) lookupRelease(now time.Time) (UpdateInfo, error, bool) {
u.mu.Lock()
defer u.mu.Unlock()
if u.cache != nil && now.Before(u.cacheExpiry) {
return *u.cache, nil, true
}
if u.cachedErr != nil && now.Before(u.errCacheExpiry) {
err := u.cachedErr
u.mu.Unlock()
return UpdateInfo{}, err
return UpdateInfo{}, u.cachedErr, true
}
u.mu.Unlock()
fetchCtx, cancel := detachFetch(ctx)
defer cancel()
info, err := u.fetchLatestRelease(fetchCtx)
if err != nil {
u.mu.Lock()
u.cachedErr = err
u.errCacheExpiry = now.Add(errorCacheTTL)
u.mu.Unlock()
return UpdateInfo{}, err
}
u.mu.Lock()
u.cache = &info
u.cacheExpiry = now.Add(cacheTTL)
u.cachedErr = nil
u.mu.Unlock()
return info, nil
return UpdateInfo{}, nil, false
}
// fetchLatestRelease queries the GitHub API for the latest release and
+1
View File
@@ -86,6 +86,7 @@ type KeyHolderChecker interface {
type PluginDeps struct {
Registry func() *plugin.Registry
MessageSvc *service.MessageService
Limiter *auth.RateLimiter
}
// VoiceDeps holds dependencies for voice handlers.
+4
View File
@@ -73,6 +73,10 @@ func (*fakeEventStore) GetEventsSinceForChannels(context.Context, int64, []int64
panic("unused")
}
func (*fakeEventStore) CountEventsInRange(context.Context, int64, int64) (int64, error) {
panic("unused")
}
func (*fakeEventStore) GetMaxEventSeq(context.Context) (int64, error) {
panic("unused")
}
+4
View File
@@ -19,6 +19,10 @@ type EventStore interface {
PersistEvents(ctx context.Context, events []db.PersistedEvent) (int, error)
GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error)
GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error)
// CountEventsInRange returns the unfiltered count of events with
// afterSeq < seq <= uptoSeq, used to detect an interior gap left by a
// lost row before a persisted range is trusted as a complete replay.
CountEventsInRange(ctx context.Context, afterSeq, uptoSeq int64) (int64, error)
PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
GetMaxEventSeq(ctx context.Context) (int64, error)
}
+15
View File
@@ -14,7 +14,9 @@ import (
"errors"
"fmt"
"log/slog"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/plugin"
"github.com/owncord/server/service"
)
@@ -24,6 +26,15 @@ import (
// the plugin's allocate/dispatch ABI with thousands of strings.
const maxCommandArgs = 64
// pluginCommandRateLimit and pluginCommandWindow cap chat_command frames per
// user (OC-0091). Every other V2 handler is throttled; this one drove a WASM
// guest invocation once per frame with no cap at all. Tighter than chat send
// (10/s) because DispatchCommand does real work per call.
const (
pluginCommandRateLimit = 5
pluginCommandWindow = time.Second
)
// handleChatCommandV2 dispatches a slash command to the owning plugin via the
// live plugin registry (wired post-construction). It returns:
// - a ClientError when no plugin registry is wired, the command is unknown,
@@ -35,6 +46,10 @@ func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps an
d := deps.(PluginDeps)
cc := cmd.(ChatCommandCmd)
if d.Limiter != nil && !d.Limiter.Allow(auth.Key("plugin_cmd", cc.userID), pluginCommandRateLimit, pluginCommandWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many commands"}}
}
var reg *plugin.Registry
if d.Registry != nil {
reg = d.Registry()
+50
View File
@@ -111,6 +111,56 @@ func TestChatCommand_MalformedPayload_ReturnsBadRequest(t *testing.T) {
}
}
// TestChatCommand_RateLimited_ReturnsError verifies that chat_command is
// throttled per-user, same as every other V2 handler (OC-0091): a burst of
// commands beyond the limit must be rejected with RATE_LIMITED instead of
// running DispatchCommand (and therefore the plugin's WASM invocation) once
// per frame with no cap.
func TestChatCommand_RateLimited_ReturnsError(t *testing.T) {
hub, database := newTestHub(t)
send := make(chan []byte, 32)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub.SetPluginRegistry(reg)
sawRateLimited := false
for i := range 20 {
raw, _ := json.Marshal(map[string]any{
"type": "chat_command",
"payload": map[string]any{
"channel_id": int64(1),
"command": "/notexist",
"args": []string{},
},
})
hub.HandleMessageForTest(c, raw)
select {
case msg := <-send:
var env map[string]any
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
payload, _ := env["payload"].(map[string]any)
if payload != nil && payload["code"] == "RATE_LIMITED" {
sawRateLimited = true
}
default:
t.Fatalf("expected a response for message %d", i)
}
}
if !sawRateLimited {
t.Fatal("expected at least one RATE_LIMITED response within 20 rapid chat_command frames")
}
}
// ─── EventSink.Emit ───────────────────────────────────────────────────────────
// TestEventSink_Emit_DeliversToBroadcaster verifies that Emit calls the wired
+1
View File
@@ -155,6 +155,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
Registry: func() *plugin.Registry { return h.pluginRegistry },
MessageSvc: h.messageSvc,
Limiter: h.limiter,
})
registerVoiceControlsV2(reg, VoiceDeps{
DB: h.db,
+9
View File
@@ -175,6 +175,15 @@ func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64
"channel_id", channelID, "err", err)
return []int64{}
}
// Archived channels are hidden from every client regardless of
// permissions, mirroring RefreshChannelVisibility and VisibleChannelIDs.
// Without this, an admin edit to an archived channel (or a voice
// teardown inside one) fans out straight to every connected user whose
// base role holds READ_MESSAGES, none of whom have the channel in their
// ready payload or sidebar.
if ch != nil && ch.Archived {
return []int64{}
}
if ch != nil && ch.Type == "dm" {
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
if err != nil {
+48
View File
@@ -1,10 +1,12 @@
package ws_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/owncord/server/db"
"github.com/owncord/server/ws"
)
@@ -138,6 +140,52 @@ func TestHub_BroadcastDropCount(t *testing.T) {
}
}
// TestHub_ChannelReadAudience_ExcludesArchivedChannel is OC-0073:
// channelReadAudience (shared by BroadcastChannelCreate/Update and the voice
// event / CleanupVoiceForChannel fan-outs) never checked ch.Archived, unlike
// its sibling RefreshChannelVisibility which treats an archived channel as
// invisible to every role. A Member has base READ_MESSAGES with no override,
// so before archiving they are a legitimate audience member for this channel;
// archiving it must remove them from channelReadAudience even though their
// role's READ_MESSAGES grant never changed.
func TestHub_ChannelReadAudience_ExcludesArchivedChannel(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
t.Cleanup(hub.Stop)
member := seedMemberUser(t, database, "archived-audience-member")
send := make(chan []byte, 8)
hub.RegisterNowForTest(ws.NewTestClient(hub, member.ID, send))
chID := seedTestChannel(t, database, "will-be-archived")
ch, err := database.GetChannel(context.Background(), chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: %v", err)
}
if err := database.AdminUpdateChannel(context.Background(), chID, db.ChannelUpdate{
Name: ch.Name,
Topic: ch.Topic,
Category: ch.Category,
SlowMode: ch.SlowMode,
Position: ch.Position,
Archived: true,
}); err != nil {
t.Fatalf("AdminUpdateChannel: %v", err)
}
archived, err := database.GetChannel(context.Background(), chID)
if err != nil || archived == nil {
t.Fatalf("GetChannel after archive: %v", err)
}
if !archived.Archived {
t.Fatalf("channel not archived after AdminUpdateChannel")
}
hub.BroadcastChannelUpdate(archived)
assertNotReceived(t, send, "member with base READ_MESSAGES on an archived channel")
}
func TestHub_SetEventPersister(t *testing.T) {
hub, database := newTestHub(t)
+24 -3
View File
@@ -285,6 +285,14 @@ func (h *Hub) hasChannelPermChecked(ctx context.Context, userID, channelID int64
return permissions.EffectiveChannelPerms(role.Permissions, o)&perm == perm, nil
}
// cleanupVoiceRaceClearHook, when non-nil, runs immediately before
// CleanupVoiceForChannel clears a still-matching client's voice state.
// Test-only (always nil in production): the window it pins is two separate
// voiceMu acquisitions with no I/O between them, too narrow to land reliably
// by staggering real goroutines, so tests use this hook to reproduce a
// voice_join racing in at exactly that point deterministically.
var cleanupVoiceRaceClearHook func(*Client)
// CleanupVoiceForChannel removes all voice participants from the given channel.
// Called when a channel is deleted.
func (h *Hub) CleanupVoiceForChannel(channelID int64) {
@@ -309,12 +317,25 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) {
slog.Error("CleanupVoiceForChannel LeaveVoiceChannelIfMatch", "err", err, "user_id", vs.UserID, "channel_id", channelID)
}
// Clear client voice state and its voice-topic subscription.
// Clear client voice state and its voice-topic subscription. The
// compare (still in this channel?) and the clear must be one atomic
// operation — a getVoiceChID() read followed by a separate
// unconditional clear leaves a window where a concurrent voice_join
// to another channel commits in between and gets silently wiped
// along with its own voice-topic subscription (OC-0050). Mirrors
// sweepStaleVoiceStates' handleVoiceLeaveIfStillIn /
// clearVoiceStateIfMatch and the LiveKit webhook's inline
// compare-and-clear.
h.mu.RLock()
client, ok := h.clients[vs.UserID]
h.mu.RUnlock()
if ok && client.getVoiceChID() == channelID {
h.clearVoiceAndUnsubscribe(client)
if ok {
if cleanupVoiceRaceClearHook != nil {
cleanupVoiceRaceClearHook(client)
}
if _, cleared := client.clearVoiceStateIfMatch(channelID); cleared {
h.pubsub.Unsubscribe(client, VoiceTopic(channelID))
}
}
// Remove from LiveKit (best-effort).
+52
View File
@@ -198,3 +198,55 @@ func TestSweepStaleVoiceStates_GhostRemovalReelectsKeyHolder(t *testing.T) {
t.Error("ghost voice_states row was not removed by the sweep")
}
}
// TestCleanupVoiceForChannel_ConcurrentJoinNotClobbered pins OC-0050:
// CleanupVoiceForChannel's client-state clear must be conditional on the
// participant still being in the channel being cleaned up at the moment it
// clears, not just at the moment it read (hub_sweep.go's own comment already
// promises this: "the client-state clear [is] conditional on the participant
// still being in THIS channel"). A voice_join to a different channel landing
// between the read and the clear must survive, exactly as
// TestSweepStaleVoiceStates_EvictionIsScopedToCheckedChannel already proves
// for the sibling sweep.
//
// The vulnerable window (read, then a separate unconditional clear) is two
// back-to-back voiceMu acquisitions with no I/O between them, too narrow to
// land reliably by staggering real goroutines. cleanupVoiceRaceClearHook
// (test-only, nil in production) fires at exactly that point so the test
// reproduces the interleaving deterministically instead of by luck.
func TestCleanupVoiceForChannel_ConcurrentJoinNotClobbered(t *testing.T) {
ctx := context.Background()
database := newHarvestVoiceDB(t)
uid := seedHarvestVoiceUser(t, database, "cleanup-race")
chA := mustCreateVoiceChannel(t, database, "voice-cleanup-a")
chB := mustCreateVoiceChannel(t, database, "voice-cleanup-b")
if err := database.JoinVoiceChannel(ctx, uid, chA); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
h := NewHub(database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 8))
h.clients[uid] = c
c.setVoiceState(chA, "tok-a")
h.pubsub.Subscribe(c, VoiceTopic(chA))
// Simulate handleVoiceJoin's state-setting step (voice_join.go's
// c.setVoiceState + pubsub.Subscribe) landing exactly between
// CleanupVoiceForChannel's read of the client's current voice channel and
// its clear of that state.
cleanupVoiceRaceClearHook = func(client *Client) {
client.setVoiceState(chB, "tok-b")
h.pubsub.Subscribe(client, VoiceTopic(chB))
}
defer func() { cleanupVoiceRaceClearHook = nil }()
h.CleanupVoiceForChannel(chA)
if got := c.getVoiceChID(); got != chB {
t.Fatalf("client voiceChID = %d after a voice_join raced CleanupVoiceForChannel's read-then-clear window, want %d — the newer join must survive, not be silently wiped", got, chB)
}
if !h.SubscribedToVoiceTopicForTest(c, chB) {
t.Error("client lost its new channel's voice-topic subscription to a concurrent CleanupVoiceForChannel clear")
}
}
+149
View File
@@ -0,0 +1,149 @@
package ws_test
// reconnect_interior_gap_test.go — regression test for OC-0062: the cold-tier
// replay path checked only for a *prefix* gap (retention pruning ahead of
// last_seq) and a *tail* gap (ring buffer not covering the post-flush tail),
// but never checked for a *hole in the middle* of the persisted range. The
// EventPersister can lose an individual row (a full queue drops silently in
// Enqueue, and a per-row insert failure inside a batch flush is logged but
// never surfaced to the replay path — see event_persister.go), leaving the
// events table with an interior gap. handleReconnect's cold tier must not
// accept that as a complete resume: the client tracks only max(seq), so a
// silently skipped seq can never be requested again.
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/owncord/server/auth"
"github.com/owncord/server/ws"
)
// TestReconnect_InteriorGap_ForcesFullReady locks the guard that must be added
// to handleReconnect's cold-tier branch: when the persisted rows above
// last_seq have a hole somewhere in the middle (not at the very start, which
// the oldest-seq probe already catches), the cold-tier replay must not be
// delivered as a complete "db" resume.
//
// Setup mirrors TestReconnect_BufferMiss_FallsBackToDBTier, but seq 550 is
// never persisted — simulating a single row the EventPersister lost — while
// every other seq in 501..600 is present. The channel-filtered query
// (channelIDs empty, so only channel_id=0 rows are considered — all of ours
// are global) returns a 99-row result that:
// - is not at the maxColdReplay cap (so the truncation guard doesn't fire)
// - starts at seq 501, i.e. oldest[0].Seq(501) == lastSeq+1(501), so the
// prefix-gap probe passes
// - has its newest row (600) fully covered by the ring buffer tail, so the
// tail-coverage guard passes
//
// Only an unfiltered contiguity check over (last_seq, max_persisted_seq]
// catches the missing 550.
func TestReconnect_InteriorGap_ForcesFullReady(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
userID, err := database.CreateUser(context.Background(), "reconnect-gap-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
// Persist 501..600 EXCEPT 550 — simulating one row the EventPersister
// lost (full-queue drop or a per-row insert failure during flush).
eventStore := openEventStoreDB(t)
bgCtx := context.Background()
for seq := int64(501); seq <= 600; seq++ {
if seq == 550 {
continue
}
payload := fmt.Appendf(nil, `{"seq":%d,"type":"broadcast"}`, seq)
if err := eventStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil {
t.Fatalf("PersistEvent seq=%d: %v", seq, err)
}
}
hub := ws.NewHub(database, limiter, nil)
hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
// Ring buffer holds 501..1500, so last_seq=500 misses it and the cold
// tier is consulted; the buffer fully covers everything above the
// newest persisted row (600), so the tail-coverage guard alone would
// wrongly let this replay through.
rb := hub.ReplayBuffer()
dummyPayload := []byte(`{"type":"broadcast"}`)
for seq := uint64(501); seq <= 1500; seq++ {
rb.Push(seq, 0, dummyPayload)
}
if oldest := rb.OldestSeq(); oldest != 501 {
t.Fatalf("pre-condition: expected oldestSeq=501, got %d", oldest)
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(handler)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
dialCtx, cancel := context.WithTimeout(bgCtx, 10*time.Second)
defer cancel()
conn, dialResp, dialErr := websocket.Dial(dialCtx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
_ = dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("websocket.Dial: %v", dialErr)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
authMsg := map[string]any{
"type": "auth",
"payload": map[string]any{
"token": token,
"last_seq": uint64(500),
},
}
raw, _ := json.Marshal(authMsg)
if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil {
t.Fatalf("write auth: %v", err)
}
// The first message back must NOT be auth_ok with replay_source="db" —
// accepting the 99-row result as a complete resume silently skips seq
// 550 forever, since the client only ever tracks max(seq).
_, msg, err := conn.Read(dialCtx)
if err != nil {
t.Fatalf("read handshake response: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(msg, &resp); err != nil {
t.Fatalf("unmarshal response: %v; raw=%s", err, msg)
}
if resp["type"] == "auth_ok" {
if payloadField, _ := resp["payload"].(map[string]any); payloadField["replay_source"] == "db" {
t.Fatalf("reconnect accepted a cold-tier replay with an interior gap (missing seq 550) as a complete db-tier resume: %s", msg)
}
}
_, dbTier, fullTier := hub.ReconnectTierStats()
if dbTier != 0 {
t.Errorf("db tier count = %d, want 0: a persisted range with an interior gap was delivered as a complete resume", dbTier)
}
if fullTier != 1 {
t.Errorf("full tier count = %d, want 1: an interior gap in the persisted range must force a full ready re-sync", fullTier)
}
}
+42 -16
View File
@@ -227,24 +227,50 @@ func (h *Hub) handleReconnect(
for _, p := range persisted {
persistedTail = append(persistedTail, p.Payload)
}
// The EventPersister flushes asynchronously, so cold rows can
// lag the live seq: events broadcast after the last flush sit
// only in the ring buffer. Confirm the buffer can cover
// everything above the newest persisted row — the
// authoritative re-read happens atomically with registerNow
// below, but a hole here must still force a full ready
// rather than a replay with a silent gap at its end.
maxPersistedSeq = uint64(persisted[len(persisted)-1].Seq) //nolint:gosec // seq is a counter bounded well below MaxInt64
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
case tail != nil:
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
// Post-restart empty buffer with the hub seq seeded from
// the store max: nothing was broadcast after the last
// persisted row, so the cold rows alone are complete.
default:
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready",
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
// persisted is channel-filtered, so a hole in a channel
// outside allowedChannelIDs would slip past a contiguity
// check on persisted itself — and EventPersister can lose a
// row outright (a full queue drops silently in Enqueue, a
// per-row insert failure inside a batch flush is logged but
// never surfaced here; see event_persister.go). Count the
// UNFILTERED range (lastSeq, maxPersistedSeq] and require
// every seq in it to be present. seq is the events table's
// primary key, so the count can only come up short, never
// over.
expectedCount := maxPersistedSeq - lastSeq
switch gapCount, gapErr := es.CountEventsInRange(ctx, int64(lastSeq), int64(maxPersistedSeq)); { //nolint:gosec // bounded well below MaxInt64
case gapErr != nil:
slog.Warn("ws handleReconnect: cold-tier contiguity probe failed, forcing full ready",
"user_id", c.userID, "err", gapErr)
persistedTail = nil
case uint64(gapCount) != expectedCount: //nolint:gosec // bounded well below MaxInt64
slog.Warn("ws handleReconnect: cold-tier replay has an interior gap, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq, "max_persisted_seq", maxPersistedSeq,
"expected", expectedCount, "found", gapCount)
persistedTail = nil
}
if persistedTail != nil {
// The EventPersister flushes asynchronously, so cold rows can
// lag the live seq: events broadcast after the last flush sit
// only in the ring buffer. Confirm the buffer can cover
// everything above the newest persisted row — the
// authoritative re-read happens atomically with registerNow
// below, but a hole here must still force a full ready
// rather than a replay with a silent gap at its end.
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
case tail != nil:
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
// Post-restart empty buffer with the hub seq seeded from
// the store max: nothing was broadcast after the last
// persisted row, so the cold rows alone are complete.
default:
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready",
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
persistedTail = nil
}
}
if persistedTail != nil {
events = persistedTail