From 6bc5938ddf19375c0c47557db9a2cbb47f04ba62 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:59:13 +0200 Subject: [PATCH 01/15] fix(auth): canonicalize username for per-user login lockout keys The per-username brute-force lockout keyed on the raw request username while GetUserByUsername matches COLLATE NOCASE, so case variants (admin/Admin/ADMIN) each got an independent 9-attempt bucket, multiplying allowed guesses per account. Lowercase the username before building the login_user_fail/login_user_lock keys so all casings share one bucket. (Security scan F1) Co-Authored-By: Claude Opus 4.8 --- Server/api/auth_handler.go | 8 ++++++-- Server/api/auth_handler_test.go | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 9e9ca880..b6e83fed 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -287,7 +287,11 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } // BUG-110: Also check per-username lockout to prevent distributed brute force. - userLockKey := "login_user_lock:" + req.Username + // F1: canonicalize the username the same way GetUserByUsername does (COLLATE + // NOCASE) before keying the lockout, so case variants of one account + // (admin/Admin/ADMIN) share a single bucket instead of each getting its own. + unameKey := strings.ToLower(req.Username) + userLockKey := "login_user_lock:" + unameKey if limiter.IsLockedOut(userLockKey) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", @@ -316,7 +320,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } failKey := "login_fail:" + ip - userFailKey := "login_user_fail:" + req.Username + userFailKey := "login_user_fail:" + unameKey // Always run the password check — with an empty hash when the user does // not exist. auth.CheckPassword performs a dummy bcrypt comparison for an // empty hash, so bcrypt executes on every path and response time stays diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index bc8305ed..6f1eeb67 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -409,6 +409,40 @@ func TestLogin_UsernameLockoutBlocksCorrectPasswordFromFreshIP(t *testing.T) { } } +// TestLogin_UsernameLockoutIgnoresCasing locks F1: the per-username lockout key +// must be case-folded so it matches the DB's COLLATE NOCASE username lookup. +// Otherwise an attacker splits the 9-attempt lockout budget across case variants +// of one account (admin, Admin, ADMIN, …), all of which authenticate the same row. +func TestLogin_UsernameLockoutIgnoresCasing(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("casehunt", hash, 4) + + // Trip the per-username lockout using the lowercase spelling, from many IPs + // so the per-IP limiter is never the binding cap. + for i := 0; i < 10; i++ { + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "casehunt", + "password": "wrongpassword", + }, fmt.Sprintf("198.51.100.%d", i+1)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String()) + } + } + + // A different casing of the SAME account must land in the same lockout bucket. + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "CASEHUNT", + "password": "wrongpassword", + }, "198.51.100.250") + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("case-variant username bypassed the per-username lockout: status = %d, want 429; body = %s", rr.Code, rr.Body.String()) + } +} + func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) { database := newAuthTestDB(t) limiter := auth.NewRateLimiter() From 420eec227c0d87d2aa2c3613f7972e7276059c10 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:59:13 +0200 Subject: [PATCH 02/15] fix(plugin): serialize wazero guest calls with a per-Instance mutex invokeCommand drove a shared wazero module (allocate/mem.Write/command_dispatch/mem.Read) with no per-instance lock, so concurrent invocations of the same plugin command raced the module's linear-memory buffer. Add a per-Instance mutex around the guest-call sequence. Confirmed under -race. (Security scan F2) Co-Authored-By: Claude Opus 4.8 --- Server/plugin/registry.go | 6 ++++ Server/plugin/sandbox_wazero.go | 10 +++++++ Server/plugin/sandbox_wazero_test.go | 45 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index 5c798e8e..6cf708df 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -62,6 +62,12 @@ type Instance struct { WASMPath string Enabled bool + // invokeMu serializes guest calls for this instance. wazero's Function.Call + // is not goroutine-safe, and concurrent invocations race the module's shared + // linear-memory buffer (F2). Held by the wazero-tagged invokeCommand around + // the whole allocate/write/dispatch/read sequence. + invokeMu sync.Mutex //nolint:unused // used only by the wazero-tagged build + // module is the wazero compiled module in the wazero-tagged build, or // nil in the default build. module any //nolint:unused // assigned by wazero-tagged build diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index bf11ee5d..fc73637e 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -185,6 +185,16 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch if inst == nil { return nil, false } + // F2: serialize all guest interaction for this instance. wazero's + // Function.Call is not goroutine-safe, and concurrent allocate/mem.Write/ + // command_dispatch/mem.Read on the shared module tear its linear-memory + // slice header. A per-Instance lock (not r.mu — that would serialize every + // plugin in the registry and be held across a full CPU budget) confines + // contention to concurrent invocations of the SAME plugin, and also makes the + // lazy re-activation below atomic so two overruns can't double-instantiate. + inst.invokeMu.Lock() + defer inst.invokeMu.Unlock() + r.mu.RLock() moduleAny := inst.module enabled := inst.Enabled diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go index 2f96e126..05bd60ec 100644 --- a/Server/plugin/sandbox_wazero_test.go +++ b/Server/plugin/sandbox_wazero_test.go @@ -24,6 +24,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" ) @@ -322,6 +323,50 @@ func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) { } } +// TestWazeroConcurrentDispatchRace locks F2: concurrent invocations of the same +// plugin command must be serialized per Instance. Without a per-Instance lock, +// two goroutines drive one shared wazero module (allocate / mem.Write / +// command_dispatch / mem.Read) with no synchronization, racing on its linear +// memory. Run under -race: without the fix the detector reports a data race; +// with it the run is clean. +func TestWazeroConcurrentDispatchRace(t *testing.T) { + dir := t.TempDir() + manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"spin"}]}` + writeTestPlugin(t, dir, "spinner", manifest, spinWASM) + + reg, mem := newWazeroTestRegistry(t, dir) + ctx := context.Background() + if err := reg.LoadAll(ctx); err != nil { + t.Fatal(err) + } + rows, _ := mem.ListPlugins(ctx) + if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil { + t.Fatalf("EnablePlugin: %v", err) + } + reg.mu.RLock() + inst := reg.plugins[rows[0].ID] + reg.mu.RUnlock() + if err := reg.RegisterCommand("spin", inst); err != nil { + t.Fatalf("RegisterCommand: %v", err) + } + + const goroutines = 8 + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + for j := 0; j < 20; j++ { + reg.DispatchCommand(ctx, 1, 2, "spin", nil) + } + }() + } + close(start) + wg.Wait() +} + func TestWazeroInvalidWASMFailsActivation(t *testing.T) { dir := t.TempDir() manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` From e98c1d7cbc4cfc06d38d96d61a3bfa7fe40a9252 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:59:13 +0200 Subject: [PATCH 03/15] fix(ws): resolve live role in hasChannelPerm to honor mid-session demotions hasChannelPerm resolved permissions from the connect-time role snapshot (c.user.RoleID), so a user reassigned to a lower role kept the old role's voice privileges (CONNECT_VOICE and the SPEAK/VIDEO grants in the LiveKit token) until reconnect. Resolve the current role via GetRoleForUser(c.userID), matching the V2 handlers. (Security scan F5) Co-Authored-By: Claude Opus 4.8 --- Server/ws/export_test.go | 5 ++++ Server/ws/handlers.go | 12 ++++++--- Server/ws/voice_perm_stale_test.go | 43 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 Server/ws/voice_perm_stale_test.go diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 33aefb35..18f459e0 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -228,3 +228,8 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool { return h.mustFullResync(lastSeq) } + +// HasChannelPermForTest exposes Hub.hasChannelPerm for external tests. +func (h *Hub) HasChannelPermForTest(c *Client, channelID, perm int64) bool { + return h.hasChannelPerm(c, channelID, perm) +} diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 38e50c7b..92e621b0 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -193,11 +193,15 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { // hasChannelPerm reports whether the client's role has all the given permission bits. // Delegates to the unified permissions.Checker. +// +// F5: resolve the user's CURRENT role via GetRoleForUser(c.userID) rather than the +// role snapshotted onto c.user at connect time. A mid-session role reassignment +// (e.g. stripping CONNECT_VOICE) must take effect immediately for the live +// connection — including the SPEAK/VIDEO grants baked into a freshly minted +// LiveKit token — instead of persisting until the user reconnects. This mirrors +// the V2 handlers, which already resolve the live role (deps.go). func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool { - if c.user == nil { - return false - } - role, err := h.db.GetRoleByID(c.user.RoleID) + role, err := h.db.GetRoleForUser(c.userID) if err != nil || role == nil { return false } diff --git a/Server/ws/voice_perm_stale_test.go b/Server/ws/voice_perm_stale_test.go new file mode 100644 index 00000000..3114b517 --- /dev/null +++ b/Server/ws/voice_perm_stale_test.go @@ -0,0 +1,43 @@ +package ws_test + +import ( + "testing" + + "github.com/owncord/server/permissions" + "github.com/owncord/server/ws" +) + +// TestHasChannelPerm_UsesLiveRoleNotConnectSnapshot locks F5: hasChannelPerm must +// resolve the user's CURRENT role, not the role snapshotted onto the Client at +// connect time. Otherwise a user reassigned to a lower role mid-session keeps the +// old role's voice privileges (CONNECT_VOICE / the SPEAK/VIDEO grants baked into +// the LiveKit token) until they reconnect. +func TestHasChannelPerm_UsesLiveRoleNotConnectSnapshot(t *testing.T) { + hub, database := newHandlerHub(t) + + // Connect-time role: Member (id 4), which carries CONNECT_VOICE. + user := seedMemberUser(t, database, "demoted") + chID := seedTestChannel(t, database, "vc-stale") + + // The client's cached user snapshot still points at the Member role — this + // is exactly the stale state the connection holds after a role reassignment. + send := make(chan []byte, 4) + c := ws.NewTestClientWithUser(hub, user, chID, send) + + // Admin reassigns the user to a role WITHOUT CONNECT_VOICE. The live WS + // connection is not refreshed, so c.user.RoleID is now stale. + if _, err := database.Exec( + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (100, 'novoice', NULL, ?, 5, 0)`, + permissions.ReadMessages, + ); err != nil { + t.Fatalf("seed novoice role: %v", err) + } + if _, err := database.Exec(`UPDATE users SET role_id = 100 WHERE id = ?`, user.ID); err != nil { + t.Fatalf("reassign user role: %v", err) + } + + if hub.HasChannelPermForTest(c, chID, permissions.ConnectVoice) { + t.Fatal("hasChannelPerm granted CONNECT_VOICE from the stale connect-time role; it must use the live role") + } +} From 92dae342e080c56ced0b4ea32923b903473372fc Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:59:13 +0200 Subject: [PATCH 04/15] fix(client): parse OG tags with DOMParser to remove ReDoS vector parseOgTags matched untrusted link-preview HTML (up to 50KB) against regexes with two sequential [^>]* quantifiers around a required literal, which backtrack polynomially and froze the UI thread on crafted input. Parse with DOMParser (a linear tokenizer) instead; it also correctly ignores meta-like strings inside comments/scripts. (Security scan F7) Co-Authored-By: Claude Opus 4.8 --- .../src/components/message-list/embeds.ts | 62 ++++++++----------- Client/tauri-client/tests/unit/embeds.test.ts | 13 ++++ 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/Client/tauri-client/src/components/message-list/embeds.ts b/Client/tauri-client/src/components/message-list/embeds.ts index 1257befd..ca7f50fe 100644 --- a/Client/tauri-client/src/components/message-list/embeds.ts +++ b/Client/tauri-client/src/components/message-list/embeds.ts @@ -37,48 +37,38 @@ export function clearEmbedCaches(): void { // -- OG tag parsing ----------------------------------------------------------- -/** Escape special regex characters in a string for safe use in `new RegExp()`. */ -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** Extract Open Graph meta tags from raw HTML using regex (no DOM parser needed). */ +/** + * Extract Open Graph meta tags from raw HTML. + * + * F7: parse with the platform HTML tokenizer (DOMParser) instead of backtracking + * regexes. Untrusted preview HTML previously ran through patterns with two + * `[^>]*` quantifiers around a required literal, which backtrack polynomially and + * froze the UI thread on crafted input (ReDoS). A real tokenizer is linear and + * additionally ignores meta-like strings inside comments/scripts. + * + * The 50 KB slice at the call site (fetchOgMeta) is kept as a plain memory bound. + */ export function parseOgTags(html: string): OgMeta { - function getMetaContent(property: string): string | null { - // Match both property="og:X" and name="og:X" patterns - const escaped = escapeRegex(property); - const regex = new RegExp( - `]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']` + - `|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`, - "i", - ); - const match = html.match(regex); - if (match !== null) { - return match[1] ?? match[2] ?? null; + const doc = new DOMParser().parseFromString(html, "text/html"); + + // First matching element wins (document order), mirroring the old first-match + // behaviour. Returns "" for an empty content attribute but null when the + // attribute (or element) is absent, so the title→host fallback still fires. + function metaContent(...ogNames: readonly string[]): string | null { + for (const name of ogNames) { + const el = + doc.querySelector(`meta[property="${name}"]`) ?? doc.querySelector(`meta[name="${name}"]`); + const content = el?.getAttribute("content"); + if (content != null) return content; } return null; } - // Fallback: extract tag if no og:title - function getTitle(): string | null { - const og = getMetaContent("og:title"); - if (og !== null) return og; - const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i); - return titleMatch?.[1]?.trim() ?? null; - } - - // Fallback: extract meta description if no og:description - function getDescription(): string | null { - const og = getMetaContent("og:description"); - if (og !== null) return og; - return getMetaContent("description"); - } - return { - title: getTitle(), - description: getDescription(), - image: getMetaContent("og:image"), - siteName: getMetaContent("og:site_name"), + title: metaContent("og:title") ?? doc.querySelector("title")?.textContent?.trim() ?? null, + description: metaContent("og:description", "description"), + image: metaContent("og:image"), + siteName: metaContent("og:site_name"), }; } diff --git a/Client/tauri-client/tests/unit/embeds.test.ts b/Client/tauri-client/tests/unit/embeds.test.ts index bfefbc16..cf872565 100644 --- a/Client/tauri-client/tests/unit/embeds.test.ts +++ b/Client/tauri-client/tests/unit/embeds.test.ts @@ -509,6 +509,19 @@ describe("parseOgTags", () => { const meta = parseOgTags(html); expect(meta.title).toBe("Spaced Title"); }); + + it("ignores meta-like strings inside comments and scripts (F7: real HTML parsing, not backtracking regex)", () => { + // A real HTML tokenizer treats these as a comment node and script text, not + // <meta> elements — so attacker-controlled preview HTML can neither smuggle a + // fake OG tag nor drive a polynomial-backtracking regex. A string-matching + // regex would (wrongly) pick up "Commented Out". + const html = `<html><head> + <!-- <meta property="og:title" content="Commented Out"> --> + <script>var s = '<meta property="og:title" content="In Script">';</script> + </head></html>`; + const meta = parseOgTags(html); + expect(meta.title).toBeNull(); + }); }); describe("applyOgMeta", () => { From 1485e13f9abc24ff1fdc18cf1d2c9cdad22dcf6b Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:47:30 +0200 Subject: [PATCH 05/15] fix(security): gate TLS trust-on-first-use behind explicit confirmation (F4/F8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The http and ws proxies accepted ANY certificate on first use and silently pinned it, forwarding login credentials and the bearer token before the user ever saw the fingerprint — an on-path attacker at first contact captured them. The three proxies also duplicated the TLS verifier and TOFU logic verbatim. - Extract the shared verifier, cert-store helpers, and a pure `decide` function into src-tauri/src/tofu.rs (used by the http/ws/livekit proxies). - Split the trust decision from persistence: a first-use cert is no longer pinned or forwarded to. The proxy rejects (ws: Err; http: 502) and emits a cert-tofu "first_use" event; the only writer of a pin is the explicit accept_cert_fingerprint command. - Frontend: a global cert-tofu listener (active during the connect page's health checks, before any WS connect) surfaces an SSH-style first-use confirmation modal. On accept the fingerprint is pinned and the server re-checked; nothing is sent to an unconfirmed host. Closes security-scan F4 (http proxy) and F8 (ws proxy). Verified: client typecheck/lint/format clean, full unit suite 3311/3311 green (incl. new ws first-use routing + modal tests). Rust compiles in CI (cargo clippy) per the client CLAUDE.md; pure tofu logic covered by #[cfg(test)] unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../tauri-client/src-tauri/src/http_proxy.rs | 183 +++------- Client/tauri-client/src-tauri/src/lib.rs | 1 + .../src-tauri/src/livekit_proxy.rs | 151 +-------- Client/tauri-client/src-tauri/src/tofu.rs | 314 ++++++++++++++++++ Client/tauri-client/src-tauri/src/ws_proxy.rs | 246 +++----------- .../src/components/CertMismatchModal.ts | 93 ++++++ Client/tauri-client/src/lib/ws.ts | 114 ++++--- Client/tauri-client/src/main.ts | 76 +++-- Client/tauri-client/tests/helpers/mock-ws.ts | 6 +- .../tests/integration/stores.test.ts | 4 +- .../tests/unit/cert-first-use-modal.test.ts | 37 +++ .../tests/unit/dispatcher.test.ts | 3 +- .../tests/unit/status-picker-userbar.test.ts | 3 +- Client/tauri-client/tests/unit/ws.test.ts | 36 ++ 14 files changed, 701 insertions(+), 566 deletions(-) create mode 100644 Client/tauri-client/src-tauri/src/tofu.rs create mode 100644 Client/tauri-client/tests/unit/cert-first-use-modal.test.ts diff --git a/Client/tauri-client/src-tauri/src/http_proxy.rs b/Client/tauri-client/src-tauri/src/http_proxy.rs index 4f297b79..e9a0b22d 100644 --- a/Client/tauri-client/src-tauri/src/http_proxy.rs +++ b/Client/tauri-client/src-tauri/src/http_proxy.rs @@ -9,10 +9,12 @@ // host. The webview fetches http://127.0.0.1:{port}/api/v1/... and the proxy // opens a TLS connection to the real server, enforcing the same TOFU // (Trust On First Use) fingerprint pinning as ws_proxy: -// - Unknown host → accept, persist the fingerprint, emit `cert-tofu` -// (status "trusted_first_use") so the UI can show the banner. HTTP is the -// FIRST TLS contact with a server (login precedes the WS connect), so this -// proxy — not ws_proxy — usually establishes the pin. +// - Unknown host → REJECT (502) and emit `cert-tofu` (status "first_use") so +// the UI prompts the user to confirm the fingerprint. Nothing is pinned or +// forwarded until the user explicitly accepts (accept_cert_fingerprint), so +// no credential is ever sent to an unconfirmed host. HTTP is the FIRST TLS +// contact with a server (login precedes the WS connect), so this proxy +// usually surfaces the first-use prompt. (F4/F8) // - Pinned host → fingerprint must match or the connection is refused and a // `cert-tofu` mismatch event fires (CertMismatchModal flow). // @@ -27,21 +29,17 @@ // - The accept loop exits after 5 consecutive errors to prevent CPU spin. use log::{debug, error, info, warn}; -use ring::digest::{digest, SHA256}; use std::collections::HashMap; use std::net::IpAddr; use std::sync::Arc; use rustls::pki_types::ServerName; -use serde_json::Value; use tauri::{AppHandle, Emitter, Runtime}; -use tauri_plugin_store::StoreExt; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; use tokio::time::{timeout, Duration}; -use crate::constants::CERTS_STORE; -use crate::livekit_proxy::cert_store_key; +use crate::tofu::{self, TofuOutcome}; /// Tauri-managed state: one running tunnel per remote host. pub struct HttpProxyState { @@ -139,132 +137,10 @@ pub async fn stop_http_proxy( } // --------------------------------------------------------------------------- -// TOFU verification (mirrors ws_proxy semantics; shared cert store) +// TOFU verification lives in the shared `tofu` module (crate::tofu): +// CaptureVerifier, cert_store_key, evaluate/decide, and the mismatch message. // --------------------------------------------------------------------------- -/// Fingerprint captured during the TLS handshake. -type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>; - -/// Accepts the handshake while recording the leaf certificate's SHA-256 -/// fingerprint; the TOFU decision happens immediately after the handshake, -/// before any request bytes are forwarded. -#[derive(Debug)] -struct CaptureVerifier { - captured: CapturedFingerprint, -} - -impl CaptureVerifier { - fn new() -> (Self, CapturedFingerprint) { - let fp = Arc::new(std::sync::Mutex::new(None)); - (Self { captured: fp.clone() }, fp) - } -} - -impl rustls::client::danger::ServerCertVerifier for CaptureVerifier { - fn verify_server_cert( - &self, - end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { - let hash = digest(&SHA256, end_entity.as_ref()); - let hex = hash - .as_ref() - .iter() - .map(|b| format!("{b:02x}")) - .collect::<Vec<_>>() - .join(":"); - if let Ok(mut guard) = self.captured.lock() { - *guard = Some(hex); - } - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - rustls::crypto::verify_tls12_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { - rustls::crypto::ring::default_provider() - .signature_verification_algorithms - .supported_schemes() - } -} - -/// TOFU decision for `host` (cert-store key, i.e. without a default :443): -/// first use stores the pin, match passes, mismatch fails. Same store, same -/// save-rollback behavior, and same event payloads as ws_proxy::tofu_check. -fn tofu_check<R: Runtime>( - app: &AppHandle<R>, - host: &str, - fingerprint: &str, -) -> Result<String, String> { - let store = app - .store(CERTS_STORE) - .map_err(|e| format!("failed to open certs store: {e}"))?; - - let stored = store.get(host).and_then(|v| { - if let Value::String(s) = v { - Some(s) - } else { - None - } - }); - - match stored { - None => { - let old_value = store.get(host); - store.set(host, Value::String(fingerprint.to_string())); - if let Err(e) = store.save() { - match old_value { - Some(v) => { - store.set(host, v); - } - None => { - let _ = store.delete(host); - } - } - return Err(format!("failed to persist cert fingerprint: {e}")); - } - Ok("trusted_first_use".to_string()) - } - Some(ref stored_fp) if stored_fp == fingerprint => Ok("trusted".to_string()), - Some(stored_fp) => Err(format!( - "Certificate fingerprint changed for {host}.\n\ - Stored: {stored_fp}\n\ - Current: {fingerprint}\n\ - This may indicate a man-in-the-middle attack or a server certificate rotation.\n\ - Use accept_cert_fingerprint to trust the new certificate." - )), - } -} - // --------------------------------------------------------------------------- // Proxy internals // --------------------------------------------------------------------------- @@ -400,7 +276,7 @@ async fn handle_connection<R: Runtime>( let modified = rewrite_request_headers(&buf, remote_host); // ── 2. TLS connect + TOFU check ────────────────────────────────────── - let (verifier, captured_fp) = CaptureVerifier::new(); + let (verifier, captured_fp) = tofu::CaptureVerifier::new(); let tls_config = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(verifier)) @@ -437,22 +313,44 @@ async fn handle_connection<R: Runtime>( return Err("TLS handshake completed but no certificate fingerprint was captured".into()); } - let store_key = cert_store_key(remote_host); - match tofu_check(&app, &store_key, &fingerprint) { - Ok(status) => { - if status == "trusted_first_use" { - info!("[http_proxy] TOFU first-use pin for {}", store_key); - } + let store_key = tofu::cert_store_key(remote_host); + match tofu::evaluate(&app, &store_key, &fingerprint)? { + TofuOutcome::Trusted => { let _ = app.emit( "cert-tofu", serde_json::json!({ "host": store_key, "fingerprint": fingerprint, - "status": status, + "status": "trusted", }), ); } - Err(mismatch_msg) => { + // F4/F8: a first-use cert is NOT silently pinned or forwarded to. Reject + // the request (502) and surface the fingerprint so the user can confirm + // it (accept_cert_fingerprint) before any credential-bearing request is + // sent. The connect page's health check triggers this before login. + TofuOutcome::FirstUse => { + info!("[http_proxy] first-use cert for {} — awaiting user confirmation", store_key); + let _ = app.emit( + "cert-tofu", + serde_json::json!({ + "host": store_key, + "fingerprint": fingerprint, + "status": "first_use", + }), + ); + let _ = local + .write_all( + b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + ) + .await; + return Err(format!( + "certificate for {store_key} is not yet trusted; confirm the fingerprint to continue" + ) + .into()); + } + TofuOutcome::Mismatch { stored } => { + let mismatch_msg = tofu::mismatch_message(&store_key, &stored, &fingerprint); warn!( "[http_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", store_key @@ -464,6 +362,7 @@ async fn handle_connection<R: Runtime>( "fingerprint": fingerprint, "status": "mismatch", "message": mismatch_msg, + "storedFingerprint": stored, }), ); // Give the local fetch a clean HTTP failure instead of a reset. diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 2b1230f4..2e91b665 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod credentials; mod http_proxy; mod livekit_proxy; mod ptt; +mod tofu; mod tray; mod update_commands; mod ws_proxy; diff --git a/Client/tauri-client/src-tauri/src/livekit_proxy.rs b/Client/tauri-client/src-tauri/src/livekit_proxy.rs index f06d5f1d..dde9f4d8 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -26,13 +26,10 @@ // - The accept loop exits after 5 consecutive errors to prevent CPU spin. use log::{debug, error, info, warn}; -use ring::digest::{digest, SHA256}; use std::net::IpAddr; use std::sync::Arc; use rustls::pki_types::ServerName; -use serde_json::Value; use tauri::Runtime; -use tauri_plugin_store::StoreExt; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; @@ -65,118 +62,16 @@ impl LiveKitProxyState { } // --------------------------------------------------------------------------- -// TLS certificate verifier — pinned fingerprint check +// TLS verification & cert-store helpers live in the shared `tofu` module +// (crate::tofu): PinnedVerifier, cert_store_key, load_stored_fingerprint. // --------------------------------------------------------------------------- -use crate::constants::CERTS_STORE; - -/// Verifies the server certificate against a known SHA-256 fingerprint. -/// Reuses the fingerprint stored by ws_proxy's TOFU handshake for the same -/// host, so LiveKit connections are pinned to the same certificate the user -/// already trusted during WebSocket setup. -#[derive(Debug)] -pub(crate) struct PinnedVerifier { - /// Expected SHA-256 colon-hex fingerprint (e.g. "aa:bb:cc:..."). - expected_fingerprint: String, -} - -impl PinnedVerifier { - pub(crate) fn new(expected_fingerprint: String) -> Self { - Self { expected_fingerprint } - } -} - -impl rustls::client::danger::ServerCertVerifier for PinnedVerifier { - fn verify_server_cert( - &self, - end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { - let hash = digest(&SHA256, end_entity.as_ref()); - let hex = hash - .as_ref() - .iter() - .map(|b| format!("{b:02x}")) - .collect::<Vec<_>>() - .join(":"); - - if hex == self.expected_fingerprint { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } else { - Err(rustls::Error::General(format!( - "certificate fingerprint mismatch: expected {}, got {}", - self.expected_fingerprint, hex - ))) - } - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - rustls::crypto::verify_tls12_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { - rustls::crypto::ring::default_provider() - .signature_verification_algorithms - .supported_schemes() - } -} +use crate::tofu; // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- -/// Produce the cert store key matching ws_proxy's format. -/// ws_proxy extracts the host from "wss://host/path" which omits port 443. -/// We normalise by stripping the default ":443" suffix so the keys match. -pub(crate) fn cert_store_key(remote_host: &str) -> String { - remote_host.strip_suffix(":443").unwrap_or(remote_host).to_string() -} - -/// Load the stored certificate fingerprint for a host from the Tauri cert store. -pub(crate) fn load_stored_fingerprint<R: Runtime>( - app: &tauri::AppHandle<R>, - host: &str, -) -> Result<Option<String>, String> { - let store = app - .store(CERTS_STORE) - .map_err(|e| format!("failed to open certs store: {e}"))?; - - Ok(store.get(host).and_then(|v| { - if let Value::String(s) = v { - Some(s) - } else { - None - } - })) -} - /// Start a local TCP proxy that tunnels LiveKit signal connections to the /// remote OwnCord server over TLS, pinning the certificate to the fingerprint /// already trusted via ws_proxy's TOFU handshake. @@ -221,8 +116,8 @@ pub async fn start_livekit_proxy<R: Runtime>( // have connected first (establishing the TOFU trust), so the fingerprint // should already be stored. If not, reject — we refuse to connect without // a pinned cert. - let store_key = cert_store_key(&remote_host); - let fingerprint = load_stored_fingerprint(&app, &store_key)? + let store_key = tofu::cert_store_key(&remote_host); + let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)? .ok_or_else(|| format!( "no trusted certificate fingerprint for {remote_host}. \ Connect via WebSocket first to establish TOFU trust." @@ -384,7 +279,7 @@ async fn handle_connection( let tls_config = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new( - PinnedVerifier::new(pinned_fingerprint.to_string()), + tofu::PinnedVerifier::new(pinned_fingerprint.to_string()), )) .with_no_client_auth(); @@ -426,36 +321,4 @@ async fn handle_connection( Ok(()) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cert_store_key_strips_default_port() { - assert_eq!(cert_store_key("example.com:443"), "example.com"); - } - - #[test] - fn cert_store_key_keeps_non_default_port() { - assert_eq!(cert_store_key("example.com:8443"), "example.com:8443"); - } - - #[test] - fn cert_store_key_no_port() { - assert_eq!(cert_store_key("example.com"), "example.com"); - } - - #[test] - fn cert_store_key_ipv4_default_port() { - assert_eq!(cert_store_key("192.168.1.1:443"), "192.168.1.1"); - } - - #[test] - fn cert_store_key_ipv4_custom_port() { - assert_eq!(cert_store_key("192.168.1.1:7880"), "192.168.1.1:7880"); - } -} +// cert_store_key is covered by unit tests in the shared `tofu` module. diff --git a/Client/tauri-client/src-tauri/src/tofu.rs b/Client/tauri-client/src-tauri/src/tofu.rs new file mode 100644 index 00000000..f78bbf25 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/tofu.rs @@ -0,0 +1,314 @@ +// Shared TLS Trust-On-First-Use (TOFU) machinery for the http / ws / livekit +// proxies. Self-hosted servers use self-signed certs, so we pin the leaf cert's +// SHA-256 fingerprint on first use — like SSH's known_hosts. +// +// F4/F8: pinning is now EXPLICIT. A first-use certificate is never silently +// trusted or forwarded to. The proxies capture the fingerprint during the +// handshake, then reject the connection and surface the fingerprint so the user +// can confirm it (via `accept_cert_fingerprint`) before any credential-bearing +// request is sent. `decide` is a pure function with no persistence side effects; +// the only writer of a pin is the explicit `accept_cert_fingerprint` command. + +use ring::digest::{digest, SHA256}; +use serde_json::Value; +use std::sync::Arc; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_store::StoreExt; + +use crate::constants::CERTS_STORE; + +/// Shared fingerprint captured during the TLS handshake. +pub(crate) type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>; + +/// Format a DER-encoded certificate's SHA-256 as lowercase colon-hex +/// ("aa:bb:cc:..."), the canonical pin format used across the cert store. +pub(crate) fn fingerprint_hex(cert_der: &[u8]) -> String { + digest(&SHA256, cert_der) + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect::<Vec<_>>() + .join(":") +} + +// ── shared rustls signature-verification boilerplate ──────────────────────── +// Identical across every verifier; single-homed here so the three proxies don't +// each re-implement it. + +pub(crate) fn verify_tls12( + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, +) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) +} + +pub(crate) fn verify_tls13( + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, +) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) +} + +pub(crate) fn default_verify_schemes() -> Vec<rustls::SignatureScheme> { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() +} + +// ── verifiers ─────────────────────────────────────────────────────────────── + +/// A rustls verifier that ACCEPTS any leaf cert but records its fingerprint for +/// the post-handshake TOFU decision. Used by the http and ws proxies. Accepting +/// here is safe only because `evaluate` + the caller gate on the pin afterward. +#[derive(Debug)] +pub(crate) struct CaptureVerifier { + captured: CapturedFingerprint, +} + +impl CaptureVerifier { + pub(crate) fn new() -> (Self, CapturedFingerprint) { + let fp = Arc::new(std::sync::Mutex::new(None)); + (Self { captured: fp.clone() }, fp) + } +} + +impl rustls::client::danger::ServerCertVerifier for CaptureVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { + if let Ok(mut guard) = self.captured.lock() { + *guard = Some(fingerprint_hex(end_entity.as_ref())); + } + // Accept — the TOFU decision happens after the handshake, before any + // request bytes are forwarded. + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + verify_tls12(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + verify_tls13(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { + default_verify_schemes() + } +} + +/// A rustls verifier that requires the leaf cert to match a pinned fingerprint, +/// failing the handshake itself on mismatch. Used by the livekit proxy, which +/// refuses to start unless a pin already exists (no TOFU establishment). +#[derive(Debug)] +pub(crate) struct PinnedVerifier { + expected_fingerprint: String, +} + +impl PinnedVerifier { + pub(crate) fn new(expected_fingerprint: String) -> Self { + Self { expected_fingerprint } + } +} + +impl rustls::client::danger::ServerCertVerifier for PinnedVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { + let hex = fingerprint_hex(end_entity.as_ref()); + if hex == self.expected_fingerprint { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } else { + Err(rustls::Error::General(format!( + "certificate fingerprint mismatch: expected {}, got {}", + self.expected_fingerprint, hex + ))) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + verify_tls12(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { + verify_tls13(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { + default_verify_schemes() + } +} + +// ── store keys ────────────────────────────────────────────────────────────── + +/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which +/// keys off `wss://host` with no explicit 443) and the http/livekit proxies +/// (which see `host:443`) resolve the SAME pin. Non-default ports are kept. +pub(crate) fn cert_store_key(host: &str) -> String { + host.strip_suffix(":443").unwrap_or(host).to_string() +} + +/// Extract the host (with any non-default port) from a `wss://` URL. +pub(crate) fn extract_host(url: &str) -> String { + cert_store_key( + url.strip_prefix("wss://") + .unwrap_or(url) + .split('/') + .next() + .unwrap_or(url), + ) +} + +/// Load the stored pin for `host` from the Tauri cert store. +pub(crate) fn load_stored_fingerprint<R: Runtime>( + app: &AppHandle<R>, + host: &str, +) -> Result<Option<String>, String> { + let store = app + .store(CERTS_STORE) + .map_err(|e| format!("failed to open certs store: {e}"))?; + Ok(store.get(host).and_then(|v| match v { + Value::String(s) => Some(s), + _ => None, + })) +} + +// ── the TOFU decision (pure) ──────────────────────────────────────────────── + +/// The trust decision for an observed fingerprint given the stored pin. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum TofuOutcome { + /// A pin exists and matches — proceed. + Trusted, + /// No pin exists — do NOT trust or forward; ask the user to confirm. + FirstUse, + /// A pin exists but differs — reject; possible MITM or cert rotation. + Mismatch { stored: String }, +} + +/// Pure trust decision. No I/O, no persistence — this is the whole point of the +/// F4/F8 fix: deciding never writes a pin. +pub(crate) fn decide(stored: Option<String>, current: &str) -> TofuOutcome { + match stored { + None => TofuOutcome::FirstUse, + Some(s) if s == current => TofuOutcome::Trusted, + Some(s) => TofuOutcome::Mismatch { stored: s }, + } +} + +/// Load the stored pin and decide. Never persists. +pub(crate) fn evaluate<R: Runtime>( + app: &AppHandle<R>, + host: &str, + fingerprint: &str, +) -> Result<TofuOutcome, String> { + let stored = load_stored_fingerprint(app, host)?; + Ok(decide(stored, fingerprint)) +} + +/// The human-readable mismatch message. The frontend parses `Stored:` out of it, +/// so keep this exact shape stable. +pub(crate) fn mismatch_message(host: &str, stored: &str, current: &str) -> String { + format!( + "Certificate fingerprint changed for {host}.\n\ + Stored: {stored}\n\ + Current: {current}\n\ + This may indicate a man-in-the-middle attack or a server certificate rotation.\n\ + Use accept_cert_fingerprint to trust the new certificate." + ) +} + +// --------------------------------------------------------------------------- +// Tests (pure logic only — no Tauri runtime required) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decide_first_use_when_no_pin() { + assert_eq!(decide(None, "aa:bb"), TofuOutcome::FirstUse); + } + + #[test] + fn decide_trusted_when_pin_matches() { + assert_eq!(decide(Some("aa:bb".into()), "aa:bb"), TofuOutcome::Trusted); + } + + #[test] + fn decide_mismatch_when_pin_differs() { + assert_eq!( + decide(Some("aa:bb".into()), "cc:dd"), + TofuOutcome::Mismatch { stored: "aa:bb".into() } + ); + } + + #[test] + fn cert_store_key_strips_default_443_only() { + assert_eq!(cert_store_key("example.com:443"), "example.com"); + assert_eq!(cert_store_key("example.com"), "example.com"); + assert_eq!(cert_store_key("example.com:8443"), "example.com:8443"); + } + + #[test] + fn extract_host_variants() { + assert_eq!(extract_host("wss://example.com/chat"), "example.com"); + assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443"); + assert_eq!(extract_host("wss://example.com:443/chat"), "example.com"); + assert_eq!(extract_host("wss://example.com"), "example.com"); + assert_eq!(extract_host("example.com/path"), "example.com"); + assert_eq!(extract_host(""), ""); + } + + #[test] + fn fingerprint_hex_of_empty_is_known_sha256() { + // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + assert_eq!( + fingerprint_hex(b""), + "e3:b0:c4:42:98:fc:1c:14:9a:fb:f4:c8:99:6f:b9:24:27:ae:41:e4:64:9b:93:4c:a4:95:99:1b:78:52:b8:55" + ); + } +} diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs index fec2f8fb..963e1523 100644 --- a/Client/tauri-client/src-tauri/src/ws_proxy.rs +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -1,14 +1,17 @@ // WebSocket proxy — routes WSS through Rust to bypass self-signed cert rejection. // JS sends/receives messages via Tauri events instead of native WebSocket. // -// Implements TOFU (Trust On First Use) certificate pinning: -// - On first connect to a host, the cert SHA-256 fingerprint is stored. -// - On subsequent connects, the fingerprint is compared with the stored value. -// - If the fingerprint changes, the connection is rejected (potential MitM). +// Implements TOFU (Trust On First Use) certificate pinning via the shared +// `tofu` module: +// - The cert SHA-256 fingerprint is captured during the handshake. +// - On a known host it must match the stored pin, or the connection is rejected. +// - On first use (no pin yet) the connection is rejected and a `cert-tofu` +// "first_use" event is emitted so the user can confirm the fingerprint. F4/F8: +// the proxy never silently pins or forwards to an unconfirmed host — the only +// writer of a pin is the explicit `accept_cert_fingerprint` command. use futures_util::{SinkExt, StreamExt}; use log::{debug, error, info, warn}; -use ring::digest::{digest, SHA256}; use serde_json::Value; use std::sync::Arc; use std::time::Duration; @@ -22,6 +25,7 @@ use tokio_tungstenite::tungstenite::Message; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); use crate::constants::CERTS_STORE; +use crate::tofu::{self, TofuOutcome}; /// Sender half kept in Tauri state so `ws_send` can push messages. /// `tx` is wrapped in `Arc` so the monitoring task can clone a reference @@ -38,157 +42,6 @@ impl WsState { } } -/// Shared fingerprint captured during TLS handshake. -type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>; - -/// TOFU certificate verifier that captures the server cert fingerprint -/// during the TLS handshake. Still accepts self-signed certs (required -/// for self-hosted servers), but records the fingerprint for comparison -/// with the stored value after the connection is established. -#[derive(Debug)] -struct TofuVerifier { - captured: CapturedFingerprint, -} - -impl TofuVerifier { - fn new() -> (Self, CapturedFingerprint) { - let fp = Arc::new(std::sync::Mutex::new(None)); - (Self { captured: fp.clone() }, fp) - } -} - -impl rustls::client::danger::ServerCertVerifier for TofuVerifier { - fn verify_server_cert( - &self, - end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { - // Compute SHA-256 fingerprint of the DER-encoded leaf certificate. - let hash = digest(&SHA256, end_entity.as_ref()); - let hex = hash - .as_ref() - .iter() - .map(|b| format!("{b:02x}")) - .collect::<Vec<_>>() - .join(":"); - - if let Ok(mut guard) = self.captured.lock() { - *guard = Some(hex); - } - - // Accept the cert — TOFU check happens after the handshake completes. - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - rustls::crypto::verify_tls12_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> { - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &rustls::crypto::ring::default_provider().signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> { - vec![ - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::RSA_PKCS1_SHA384, - rustls::SignatureScheme::RSA_PKCS1_SHA512, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - rustls::SignatureScheme::ECDSA_NISTP521_SHA512, - rustls::SignatureScheme::RSA_PSS_SHA256, - rustls::SignatureScheme::RSA_PSS_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA512, - rustls::SignatureScheme::ED25519, - rustls::SignatureScheme::ED448, - ] - } -} - -/// Extract the host (with port) from a wss:// URL. -fn extract_host(url: &str) -> String { - url.strip_prefix("wss://") - .unwrap_or(url) - .split('/') - .next() - .unwrap_or(url) - .to_string() -} - -/// Perform TOFU fingerprint check against the Tauri cert store. -/// Returns Ok(()) if trusted, Err(message) if fingerprint mismatch. -fn tofu_check<R: Runtime>( - app: &AppHandle<R>, - host: &str, - fingerprint: &str, -) -> Result<String, String> { - let store = app - .store(CERTS_STORE) - .map_err(|e| format!("failed to open certs store: {e}"))?; - - let stored = store.get(host).and_then(|v| { - if let Value::String(s) = v { - Some(s) - } else { - None - } - }); - - match stored { - None => { - // First use — store the fingerprint. - // Capture old value before mutating (None here, but consistent pattern). - let old_value = store.get(host); - store.set(host, Value::String(fingerprint.to_string())); - if let Err(e) = store.save() { - // Restore previous in-memory state: put back old value or delete - // if there was none, keeping in-memory consistent with on-disk. - match old_value { - Some(v) => { store.set(host, v); } - None => { let _ = store.delete(host); } - } - return Err(format!("failed to persist cert fingerprint: {e}")); - } - Ok("trusted_first_use".to_string()) - } - Some(ref stored_fp) if stored_fp == fingerprint => { - Ok("trusted".to_string()) - } - Some(stored_fp) => { - Err(format!( - "Certificate fingerprint changed for {host}.\n\ - Stored: {stored_fp}\n\ - Current: {fingerprint}\n\ - This may indicate a man-in-the-middle attack or a server certificate rotation.\n\ - Use accept_cert_fingerprint to trust the new certificate." - )) - } - } -} - /// Single call site for ws-state events — keeps tauri-typegen from generating duplicates. fn emit_ws_state<R: Runtime>(app: &AppHandle<R>, state: &str) { let _ = app.emit("ws-state", state); @@ -229,8 +82,9 @@ pub async fn ws_connect<R: Runtime>( emit_ws_state(&app, "connecting"); - // Create TOFU verifier that captures the cert fingerprint during handshake. - let (verifier, captured_fp) = TofuVerifier::new(); + // Capture the cert fingerprint during the handshake; the TOFU decision runs + // afterward, before the socket is used. + let (verifier, captured_fp) = tofu::CaptureVerifier::new(); let tls_config = rustls::ClientConfig::builder() .dangerous() @@ -261,7 +115,7 @@ pub async fn ws_connect<R: Runtime>( debug!("[ws_proxy] WebSocket handshake complete"); // ── TOFU check ─────────────────────────────────────────────────────── - let host = extract_host(&url); + let host = tofu::extract_host(&url); let fingerprint = captured_fp .lock() .map_err(|e| format!("failed to read captured fingerprint: {e}"))? @@ -272,26 +126,41 @@ pub async fn ws_connect<R: Runtime>( return Err("TLS handshake completed but no certificate fingerprint was captured".into()); } - match tofu_check(&app, &host, &fingerprint) { - Ok(status) => { - info!("[ws_proxy] TOFU check passed for {}: {}", host, status); + match tofu::evaluate(&app, &host, &fingerprint)? { + TofuOutcome::Trusted => { + info!("[ws_proxy] TOFU check passed for {}", host); emit_cert_tofu(&app, serde_json::json!({ "host": host, "fingerprint": fingerprint, - "status": status, + "status": "trusted", })); } - Err(mismatch_msg) => { + TofuOutcome::FirstUse => { + info!("[ws_proxy] first-use cert for {} — awaiting user confirmation", host); + emit_cert_tofu(&app, serde_json::json!({ + "host": host, + "fingerprint": fingerprint, + "status": "first_use", + })); + // Do not open the socket: the user must confirm the fingerprint + // (accept_cert_fingerprint) before anything is sent over it. + return Err(format!( + "certificate for {host} is not yet trusted; confirm the fingerprint to continue" + )); + } + TofuOutcome::Mismatch { stored } => { + let msg = tofu::mismatch_message(&host, &stored, &fingerprint); warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host); - debug!("[ws_proxy] TOFU detail: {}", mismatch_msg); + debug!("[ws_proxy] TOFU detail: {}", msg); emit_cert_tofu(&app, serde_json::json!({ "host": host, "fingerprint": fingerprint, "status": "mismatch", - "message": mismatch_msg, + "message": msg, + "storedFingerprint": stored, })); // Reject the connection — do not proceed. - return Err(mismatch_msg); + return Err(msg); } } // ── End TOFU check ─────────────────────────────────────────────────── @@ -413,8 +282,8 @@ pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), Strin Ok(()) } -/// Accept a changed certificate fingerprint for a host. -/// Call this after the user acknowledges a cert-mismatch warning. +/// Accept a certificate fingerprint for a host — the ONLY path that writes a pin. +/// Called after the user acknowledges a first-use or cert-mismatch prompt. #[tauri::command] pub fn accept_cert_fingerprint<R: Runtime>( app: AppHandle<R>, @@ -458,42 +327,3 @@ pub fn accept_cert_fingerprint<R: Runtime>( } Ok(()) } - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_host_basic_wss_url() { - assert_eq!(extract_host("wss://example.com/chat"), "example.com"); - } - - #[test] - fn extract_host_with_port() { - assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443"); - } - - #[test] - fn extract_host_no_path() { - assert_eq!(extract_host("wss://example.com"), "example.com"); - } - - #[test] - fn extract_host_no_scheme() { - assert_eq!(extract_host("example.com/path"), "example.com"); - } - - #[test] - fn extract_host_empty() { - assert_eq!(extract_host(""), ""); - } - - #[test] - fn extract_host_with_port_and_deep_path() { - assert_eq!(extract_host("wss://myhost:9443/api/v1/ws"), "myhost:9443"); - } -} diff --git a/Client/tauri-client/src/components/CertMismatchModal.ts b/Client/tauri-client/src/components/CertMismatchModal.ts index 73638c87..d2b62617 100644 --- a/Client/tauri-client/src/components/CertMismatchModal.ts +++ b/Client/tauri-client/src/components/CertMismatchModal.ts @@ -108,6 +108,99 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun return { mount, destroy }; } +export interface CertFirstUseModalOptions { + readonly host: string; + readonly fingerprint: string; + readonly onAccept: () => void; + readonly onReject: () => void; +} + +/** + * createCertFirstUseModal — shown on the FIRST connection to a server, when no + * certificate is pinned yet (F4/F8). The proxy refuses to send anything until + * the user confirms this fingerprint, so an on-path attacker at first contact + * cannot silently capture credentials. Mirrors an SSH known-hosts prompt. + */ +export function createCertFirstUseModal(options: CertFirstUseModalOptions): MountableComponent { + const { host, fingerprint, onAccept, onReject } = options; + let overlay: HTMLDivElement | null = null; + const ac = new AbortController(); + + function mount(container: Element): void { + overlay = createElement("div", { class: "modal-overlay visible" }); + const modal = createElement("div", { class: "modal" }); + + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "New Server Certificate"); + const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + closeBtn.textContent = ""; + closeBtn.appendChild(createIcon("x", 14)); + closeBtn.addEventListener("click", onReject, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + const body = createElement("div", { class: "modal-body" }); + + const warning = createElement("div", { class: "cert-warning" }); + warning.appendChild(createIcon("triangle-alert", 24)); + + const certTitle = createElement("div", { class: "cert-title" }); + setText(certTitle, "Confirm the certificate fingerprint"); + + const desc = createElement("div", { class: "cert-desc" }); + setText( + desc, + "This is the first connection to this server, so its certificate is not " + + "yet trusted. Verify the fingerprint below out-of-band (e.g. with the " + + "server operator) before trusting it — on an untrusted network an " + + "attacker could present a fake certificate.", + ); + + const details = createElement("div", { class: "cert-details" }); + appendChildren( + details, + buildRow("Host", host, false), + buildRow("Fingerprint", fingerprint, true), + ); + + appendChildren(body, warning, certTitle, desc, details); + + const footer = createElement("div", { class: "modal-footer" }); + + const rejectBtn = createElement("button", { class: "btn-ghost", type: "button" }); + setText(rejectBtn, "Cancel"); + rejectBtn.addEventListener("click", onReject, { signal: ac.signal }); + + const acceptBtn = createElement("button", { class: "btn-danger", type: "button" }); + setText(acceptBtn, "Trust This Certificate"); + acceptBtn.addEventListener("click", onAccept, { signal: ac.signal }); + + appendChildren(footer, rejectBtn, acceptBtn); + + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) onReject(); + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} + function buildRow(label: string, value: string, isFingerprint: boolean): HTMLDivElement { const row = createElement("div", { class: "cert-row" }); const labelEl = createElement("span", { class: "cert-label" }); diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index 5e6dcb8b..35cd4396 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -62,11 +62,15 @@ export type WsListener<T extends ServerMessage["type"]> = ( id?: string, ) => void; -/** TOFU certificate event emitted by the Rust WS proxy. */ +/** TOFU certificate event emitted by the Rust proxies (http / ws). + * - "first_use": no pin yet — the proxy REJECTED the connection; the user must + * confirm this fingerprint (acceptCertFingerprint) before anything is sent. + * - "trusted": pin matches — proceed. + * - "mismatch": pin differs — reject (possible MITM or cert rotation). */ export interface CertTofuEvent { readonly host: string; readonly fingerprint: string; - readonly status: "trusted_first_use" | "trusted" | "mismatch"; + readonly status: "first_use" | "trusted" | "mismatch"; readonly message?: string; readonly storedFingerprint?: string; } @@ -79,7 +83,7 @@ export function parseStoredFingerprint(message?: string): string | undefined { } export type CertMismatchListener = (event: CertTofuEvent) => void; -export type CertFirstTrustListener = (event: CertTofuEvent) => void; +export type CertFirstUseListener = (event: CertTofuEvent) => void; export interface WsClientConfig { readonly host: string; @@ -129,8 +133,13 @@ export function createWsClient() { // TOFU cert mismatch listeners const certMismatchListeners = new Set<CertMismatchListener>(); - // TOFU first-trust listeners (BUG-133) - const certFirstTrustListeners = new Set<CertFirstTrustListener>(); + // TOFU first-use confirmation listeners (F4/F8) + const certFirstUseListeners = new Set<CertFirstUseListener>(); + + // Global cert-tofu Tauri listener unsub (registered once via startCertListener, + // active for the whole app lifetime so first-use/mismatch events are received + // during the connect page's health checks — before any WS connect). + let certListenerUnsub: (() => void) | null = null; function setState(newState: ConnectionState): void { if (state !== newState) { @@ -299,6 +308,38 @@ export function createWsClient() { } } + // Route a cert-tofu event (from the http or ws proxy) to the right listeners. + // Registered globally via startCertListener so first-use/mismatch events are + // received during the connect page's health checks, before any WS connect. + function handleCertTofu(raw: CertTofuEvent): void { + log.info("TOFU cert event", { host: raw.host, status: raw.status }); + if (raw.status === "first_use") { + log.warn("TOFU: first-use certificate — awaiting user confirmation", { + host: raw.host, + fingerprint: raw.fingerprint, + }); + for (const listener of certFirstUseListeners) { + listener(raw); + } + } else if (raw.status === "mismatch") { + const evt: CertTofuEvent = { + ...raw, + storedFingerprint: raw.storedFingerprint ?? parseStoredFingerprint(raw.message), + }; + log.error("Certificate fingerprint mismatch!", { + host: evt.host, + fingerprint: evt.fingerprint, + storedFingerprint: evt.storedFingerprint, + }); + certMismatchBlock = true; + setState("disconnected"); + for (const listener of certMismatchListeners) { + listener(evt); + } + } + // "trusted" → no action + } + async function setupEventListeners(): Promise<void> { if (tauriListen === null) return; @@ -356,38 +397,15 @@ export function createWsClient() { }); eventUnsubs.push(unsubErr); - // TOFU certificate events - const unsubCert = await tauriListen("cert-tofu", (e) => { - if (gen !== wsGeneration) return; - const raw = e.payload as CertTofuEvent; - log.info("TOFU cert event", { host: raw.host, status: raw.status }); - - if (raw.status === "trusted_first_use") { - log.warn("TOFU: first-use certificate trust", { - host: raw.host, - fingerprint: raw.fingerprint, - }); - for (const listener of certFirstTrustListeners) { - listener(raw); - } - } else if (raw.status === "mismatch") { - const evt: CertTofuEvent = { - ...raw, - storedFingerprint: parseStoredFingerprint(raw.message), - }; - log.error("Certificate fingerprint mismatch!", { - host: evt.host, - fingerprint: evt.fingerprint, - storedFingerprint: evt.storedFingerprint, - }); - certMismatchBlock = true; - setState("disconnected"); - for (const listener of certMismatchListeners) { - listener(evt); - } - } - }); - eventUnsubs.push(unsubCert); + // Register the global cert-tofu listener on first connect (idempotent). + // startCertListener() registers the same listener at app bootstrap so + // first-use/mismatch events are also caught during the connect page's health + // checks, before any WS connection exists. + if (certListenerUnsub === null) { + certListenerUnsub = await tauriListen("cert-tofu", (e) => { + handleCertTofu(e.payload as CertTofuEvent); + }); + } } function cleanupEventListeners(): void { @@ -556,10 +574,24 @@ export function createWsClient() { return () => sendFailureListeners.delete(listener); }, - /** Register a listener for TOFU first-trust events (BUG-133). */ - onCertFirstTrust(listener: CertFirstTrustListener): () => void { - certFirstTrustListeners.add(listener); - return () => certFirstTrustListeners.delete(listener); + /** + * Register the global cert-tofu event listener. Idempotent. Call once at app + * bootstrap (before the connect page's health checks) so first-use and + * mismatch events are received even before a WS connection exists. + */ + async startCertListener(): Promise<void> { + if (certListenerUnsub !== null) return; + await ensureTauriApis(); + if (tauriListen === null) return; + certListenerUnsub = await tauriListen("cert-tofu", (e) => { + handleCertTofu(e.payload as CertTofuEvent); + }); + }, + + /** Register a listener for TOFU first-use confirmation events (F4/F8). */ + onCertFirstUse(listener: CertFirstUseListener): () => void { + certFirstUseListeners.add(listener); + return () => certFirstUseListeners.delete(listener); }, /** Register a listener for TOFU certificate mismatch events. */ diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 4e387f35..b908e3d8 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -26,7 +26,7 @@ import { createLogger } from "@lib/logger"; import { initLogPersistence, flushLogs } from "@lib/logPersistence"; import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials"; import { initWindowState } from "@lib/window-state"; -import { createCertMismatchModal } from "@components/CertMismatchModal"; +import { createCertMismatchModal, createCertFirstUseModal } from "@components/CertMismatchModal"; import { createProfileManager, createTauriBackend } from "@lib/profiles"; import type { CertTofuEvent } from "@lib/ws"; @@ -105,37 +105,49 @@ let dispatcherCleanup: (() => void) | null = null; let connectedOverlay: ConnectedOverlayControl | null = null; let lastConnectHost = ""; let lastConnectToken = ""; +// Re-run the connect page's health checks (set while the connect page is +// mounted, cleared otherwise) — refreshes a server's status after its +// certificate is trusted for the first time. +let rerunConnectHealth: (() => void) | null = null; -// Certificate first-trust notification (BUG-133). -// Show a brief banner so the user is aware a new server cert was pinned. -ws.onCertFirstTrust((evt: CertTofuEvent) => { - log.warn("TOFU: first-use certificate pinned", { +// Shared guard so the first-use and mismatch cert modals never stack. +let certModalActive = false; + +// First-use certificate confirmation (F4/F8). The Rust proxy REJECTS the first +// connection to a server until the user confirms its fingerprint, so no +// credential is ever sent to an unconfirmed host. This fires during the connect +// page's health check (the first TLS contact), before login. +ws.onCertFirstUse((evt: CertTofuEvent) => { + if (certModalActive) return; + certModalActive = true; + + const modal = createCertFirstUseModal({ host: evt.host, fingerprint: evt.fingerprint, + onAccept: () => { + modal.destroy?.(); + certModalActive = false; + void (async () => { + try { + await ws.acceptCertFingerprint(evt.host, evt.fingerprint); + // Refresh server health so the now-trusted host becomes reachable, + // and resume a pending connect if one was in flight. + rerunConnectHealth?.(); + if (lastConnectHost && lastConnectToken) { + ws.connect({ host: lastConnectHost, token: lastConnectToken }); + } + } catch (err) { + log.error("Failed to trust first-use certificate", err); + } + })(); + }, + onReject: () => { + modal.destroy?.(); + certModalActive = false; + }, }); - const banner = document.createElement("div"); - Object.assign(banner.style, { - position: "fixed", - top: "12px", - left: "50%", - transform: "translateX(-50%)", - background: "#2d5a27", - color: "#e0e0e0", - padding: "10px 20px", - borderRadius: "8px", - fontSize: "13px", - zIndex: "10000", - boxShadow: "0 4px 12px rgba(0,0,0,0.5)", - cursor: "default", - }); - banner.textContent = `New server certificate trusted for ${evt.host}`; - banner.title = `SHA-256: ${evt.fingerprint}`; - document.body.appendChild(banner); - setTimeout(() => banner.remove(), 8000); + modal.mount(document.body); }); - -// Certificate mismatch modal handler -let certModalActive = false; ws.onCertMismatch((evt: CertTofuEvent) => { if (certModalActive) return; certModalActive = true; @@ -169,6 +181,10 @@ ws.onCertMismatch((evt: CertTofuEvent) => { modal.mount(document.body); }); +// Register the global cert-tofu listener now so first-use / mismatch prompts +// are received during the connect page's health checks, before any WS connect. +void ws.startCertListener(); + // Current page component reference for cleanup let currentPage: { destroy?(): void } | null = null; @@ -224,6 +240,8 @@ function renderPage(pageId: "connect" | "main"): void { currentPage?.destroy?.(); currentPage = null; appEl!.textContent = ""; + // Only valid while the connect page is mounted (re-set in its render branch). + rerunConnectHealth = null; // Shared helper for post-auth WS connect + overlay flow function wirePostAuth( @@ -433,6 +451,10 @@ function renderPage(pageId: "connect" | "main"): void { }, }; + // Expose a health-refresh hook so trusting a first-use certificate can + // re-check the now-reachable server without a full page navigation. + rerunConnectHealth = () => runHealthChecks(connectPage, getProfileList()); + // Load saved profiles and kick off health checks void (async () => { try { diff --git a/Client/tauri-client/tests/helpers/mock-ws.ts b/Client/tauri-client/tests/helpers/mock-ws.ts index 070d5d70..abb7ce0f 100644 --- a/Client/tauri-client/tests/helpers/mock-ws.ts +++ b/Client/tauri-client/tests/helpers/mock-ws.ts @@ -78,7 +78,11 @@ export function createMockWsClient() { return () => sendFailureListeners.delete(listener); }, - onCertFirstTrust(): () => void { + async startCertListener(): Promise<void> { + // no-op in mock + }, + + onCertFirstUse(): () => void { return () => {}; }, diff --git a/Client/tauri-client/tests/integration/stores.test.ts b/Client/tauri-client/tests/integration/stores.test.ts index cdb9444f..9bae2ce8 100644 --- a/Client/tauri-client/tests/integration/stores.test.ts +++ b/Client/tauri-client/tests/integration/stores.test.ts @@ -67,7 +67,9 @@ function createMockWsClient(): MockWsClient { return () => {}; }, - onCertFirstTrust(): () => void { + async startCertListener(): Promise<void> {}, + + onCertFirstUse(): () => void { return () => {}; }, diff --git a/Client/tauri-client/tests/unit/cert-first-use-modal.test.ts b/Client/tauri-client/tests/unit/cert-first-use-modal.test.ts new file mode 100644 index 00000000..9e431019 --- /dev/null +++ b/Client/tauri-client/tests/unit/cert-first-use-modal.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from "vitest"; +import { createCertFirstUseModal } from "../../src/components/CertMismatchModal"; + +describe("createCertFirstUseModal (F4/F8)", () => { + it("renders the host + fingerprint and wires accept/reject", () => { + const onAccept = vi.fn(); + const onReject = vi.fn(); + const modal = createCertFirstUseModal({ + host: "example.com:8443", + fingerprint: "aa:bb:cc:dd:ee:ff", + onAccept, + onReject, + }); + + const container = document.createElement("div"); + modal.mount(container); + + const text = container.textContent ?? ""; + expect(text).toContain("example.com:8443"); + expect(text).toContain("aa:bb:cc:dd:ee:ff"); + + const buttons = Array.from(container.querySelectorAll("button")); + const trustBtn = buttons.find((b) => b.textContent === "Trust This Certificate"); + const cancelBtn = buttons.find((b) => b.textContent === "Cancel"); + expect(trustBtn).toBeTruthy(); + expect(cancelBtn).toBeTruthy(); + + trustBtn!.click(); + expect(onAccept).toHaveBeenCalledTimes(1); + + cancelBtn!.click(); + expect(onReject).toHaveBeenCalledTimes(1); + + modal.destroy?.(); + expect(container.querySelector(".modal-overlay")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index b3f318ac..02b7f789 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -65,7 +65,8 @@ function createMockWs() { sendFailureListeners.add(listener); return () => sendFailureListeners.delete(listener); }, - onCertFirstTrust: vi.fn(() => () => {}), + startCertListener: vi.fn(async () => {}), + onCertFirstUse: vi.fn(() => () => {}), onCertMismatch: vi.fn(() => () => {}), acceptCertFingerprint: vi.fn(async () => {}), getState: vi.fn(() => "disconnected" as const), diff --git a/Client/tauri-client/tests/unit/status-picker-userbar.test.ts b/Client/tauri-client/tests/unit/status-picker-userbar.test.ts index ba74030d..86b38e42 100644 --- a/Client/tauri-client/tests/unit/status-picker-userbar.test.ts +++ b/Client/tauri-client/tests/unit/status-picker-userbar.test.ts @@ -27,7 +27,8 @@ function createMockWs(state: "connected" | "disconnected" = "connected"): WsClie stateListeners.add(listener); return () => stateListeners.delete(listener); }), - onCertFirstTrust: vi.fn().mockReturnValue(() => {}), + startCertListener: vi.fn().mockResolvedValue(undefined), + onCertFirstUse: vi.fn().mockReturnValue(() => {}), onCertMismatch: vi.fn().mockReturnValue(() => {}), acceptCertFingerprint: vi.fn(), getState: vi.fn(() => currentState), diff --git a/Client/tauri-client/tests/unit/ws.test.ts b/Client/tauri-client/tests/unit/ws.test.ts index 3d7c7562..c3a731f6 100644 --- a/Client/tauri-client/tests/unit/ws.test.ts +++ b/Client/tauri-client/tests/unit/ws.test.ts @@ -629,6 +629,42 @@ describe("cert mismatch blocking", () => { expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); }); + it("routes first_use cert events to onCertFirstUse, not onCertMismatch (F4/F8)", async () => { + const firstUse: unknown[] = []; + const mismatch: unknown[] = []; + client.onCertFirstUse((e) => firstUse.push(e)); + client.onCertMismatch((e) => mismatch.push(e)); + + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "first_use", + }); + + expect(firstUse).toHaveLength(1); + expect(mismatch).toHaveLength(0); + }); + + it("startCertListener catches cert events before any WS connect (connect-page path)", async () => { + const firstUse: unknown[] = []; + client.onCertFirstUse((e) => firstUse.push(e)); + + // No connect() — main.ts registers the listener at bootstrap so first-use + // fires during the connect page's health check, before login. + await client.startCertListener(); + + emitTauriEvent("cert-tofu", { + host: "localhost:8443", + fingerprint: "sha256:NEW", + status: "first_use", + }); + + expect(firstUse).toHaveLength(1); + }); + it("should not schedule reconnect when certMismatchBlock is true", async () => { const mismatchEvents: unknown[] = []; client.onCertMismatch((evt) => mismatchEvents.push(evt)); From cac23c763fe3248087e70c272e5901a1cc6371d2 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:57:15 +0200 Subject: [PATCH 06/15] docs(security): add continuation plan for the 2026-07-22 security-scan remediation Handoff doc: F1/F2/F5/F7 and F4/F8 committed, F6 done but riding with the permission-consolidation WIP, and the full F3 (voice E2EE identity keys + TOFU) design + PR split for the remaining work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../security-scan-2026-07-22-remediation.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/plans/security-scan-2026-07-22-remediation.md diff --git a/docs/plans/security-scan-2026-07-22-remediation.md b/docs/plans/security-scan-2026-07-22-remediation.md new file mode 100644 index 00000000..f6615be9 --- /dev/null +++ b/docs/plans/security-scan-2026-07-22-remediation.md @@ -0,0 +1,138 @@ +# Security-Scan Remediation (Claude Security run 2026-07-22) + +**Scan:** `CLAUDE-SECURITY-20260722-184557/` at revision `e983459` (branch `main`). +**Findings:** 8 — 4 MEDIUM (F1–F4), 4 LOW (F5–F8), all confidence `medium`, no HIGH. +**Branch:** `fix/security-scan-2026-07-22`. + +This is a continuation/handoff doc: what is done, what remains, and how to resume. + +## Status at a glance + +| # | Sev | Finding | Status | +|---|-----|---------|--------| +| F1 | MED | Login lockout keyed on un-canonicalized username (vs `COLLATE NOCASE`) | ✅ done, committed `7145f76` | +| F2 | MED | Unsynchronized concurrent wazero module invocation (data race) | ✅ done, committed `71b5f13` | +| F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | ⏳ **TODO — designed, not started** | +| F4 | MED | HTTP TOFU proxy accepts any cert on first use (credential exposure) | ✅ done, committed `f22985a` | +| F5 | LOW | Voice perms use stale connect-time role snapshot | ✅ done, committed `260d038` | +| F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, **uncommitted** (see note) | +| F7 | LOW | ReDoS regex on link-preview HTML | ✅ done, committed `6952202` | +| F8 | LOW | WS TOFU verifier accepts any cert on first use | ✅ done, committed `f22985a` (with F4) | + +## Resume checklist (do these first) + +1. **Confirm the F4/F8 Rust compiles.** It could not be built in the dev sandbox + (no local Tauri builds per `Client/tauri-client/CLAUDE.md`). Run + `cd Client/tauri-client/src-tauri && cargo clippy -- -D warnings` (or push and + let CI do it). Pure `tofu` logic has `#[cfg(test)]` unit tests; the frontend is + covered by the 3311-green unit suite. +2. **F6 commit:** F6 lives in `Server/service/permission.go`, entangled with the + uncommitted permission-consolidation edits. It rides with that work (per + decision) — commit it when the consolidation branch lands, or cherry-pick. +3. **Then F3** — the only remaining finding (below). + +## F6 detail (done, pending commit) + +`getOrPopulate` read the DB then cached the snapshot with no version guard, so a +concurrent `InvalidateUser` racing the populate was silently overwritten (stale +perms served up to `permCacheTTL`). Fix: a `gen uint64` counter bumped by every +`Invalidate*`; `getOrPopulate` snapshots `gen` before its DB read and refuses to +cache if it changed. Test `TestGetOrPopulate_InvalidationDuringPopulateNotLost` +locks it. Verified `-race` + `-tags deadlock` green. + +## F3 — Voice E2EE identity keys + TOFU (the remaining work) + +**Problem.** `voice_e2ee_announce` carries only `{public_key}`; the server +attaches `user_id` on broadcast (`Server/ws/messages.go:136`) and relays/caches +keys — so a malicious server swaps `user_id ↔ ephemeral pubkey` and MITMs the +SFrame room key. Nothing authenticates peer keys; `computeKeyFingerprint` +(`e2eeCrypto.ts:63`) exists but is never used. + +**Approach (approved: Signal-style TOFU). Additive** — the existing ephemeral +ECDH + HKDF + AES-GCM wrap is sound; add the missing authentication layer, do +not rewrite the key exchange. + +**Trust anchor:** TOFU. Each client holds a long-term identity keypair; peers pin +each other's identity key on first sight and flag any later change. A malicious +server can only MITM at first-ever contact (the accepted TOFU window), and the +optional safety-number makes even that detectable. + +### What gets signed +WebCrypto **ECDSA P-256** (same curve family as the existing ECDH; works in all +three webviews — Ed25519 is unreliable on WKWebView/WebKitGTK; zero new deps). +When announcing its ephemeral key `E_pub`, the client signs +`"owncord-voice-e2ee-announce-v1" ‖ myUserId ‖ E_pub_raw` with the identity +private key. Binding `myUserId` stops the server re-attributing a valid announce +to a different user. Receivers verify against the peer's **pinned** identity key. + +### Verify + TOFU-pin (receive path) +In `handleE2EEAnnounce` (`livekitSession.ts` ~1195, before the `_peerPublicKeys` +store at ~1198 and the holder's wrap at ~1207), and the queued-drain at ~852-857: +1. Resolve the peer's identity key — first sight → take it from the member + payload and **pin** it (`identity_pins.json`, key `{host}:{userId}`); + subsequent → use the pin; delivered key differs → emit `identity-tofu`, block/ + warn until the user re-pins (copy of the TLS cert-mismatch flow). +2. Verify the announce signature against the pinned identity key. Invalid → + reject (MITM), do not store/wrap. + +### Infrastructure (mirror existing patterns) +- **Identity private key → OS keyring:** `save/load/delete_identity_key` Tauri + commands mirroring `src-tauri/src/credentials.rs` `save_credential`, account + `identity:{host}`; TS wrapper copies `src/lib/credentials.ts`. Never localStorage. +- **Peer pins → new `identity_pins.json`** `tauri-plugin-store` file + + `store/get_identity_pin` commands, near-verbatim copy of the `certs.json` + cert-pin commands in `src-tauri/src/commands.rs`. +- **Safety number:** repoint `computeKeyFingerprint` at the *stable* identity key; + surface a per-peer/combined safety number in the voice panel (optional OOB verify). + +### Server (db-change + protocol-change workflows) +- Migration `Server/migrations/017_user_identity_key.sql`: + `ALTER TABLE users ADD COLUMN identity_public_key TEXT;` (mirrors `totp_secret`). + Add `UpdateUserIdentityKey` query; include the column in the user + `ListMembers` + SELECTs; `make sqlc-generate`. One column, **not** a multi-device table (YAGNI). +- **Publish:** extend the REST profile update (`Server/api/profile_handler.go` + `updateProfileRequest`) to accept `identity_public_key`; client publishes once + after first-login keygen. +- **Fetch:** add `identity_public_key` to the member payload in `ready`, + `member_join`, `user_update` (`Server/ws/messages.go` `memberUserPayload`, + `buildMemberJoin`, `userUpdatePayload`). Peers pin on first sight — no new WS msg. +- **The one protocol change:** add `signature` to the `voice_e2ee_announce` + payload — `docs/protocol-schema.json` → `make protocol-generate`. Server + validates size/base64 like `public_key`, and **stores the signature alongside + the key in `SetE2EEPubKey`** (`Server/ws/client.go:34`, `voice_e2ee.go`) so the + replay-to-late-joiners path (`voice_join.go:217-218`) doesn't drop it. + +### Client session (`livekitSession.ts`) +Sign the ephemeral announce at all three sites (~916, ~467, ~891); verify+pin on +receive as above. Move the primary announce earlier (~876) so the added identity +round-trip doesn't stack on the existing 10s non-holder stall. + +### Compatibility posture (transition) +Peer has published an identity key but the announce signature is missing/invalid +→ **fail closed** (reject). Peer has no identity key at all (legacy client) → +accept but mark **unverified** in the UI, pin-pending. Avoids a hard cutover for +alpha while closing the hole for upgraded clients. + +### Suggested PR split +- **PR-a (server):** identity-key column + publish/fetch + `voice_e2ee_announce` + signature field + `SetE2EEPubKey` carries the signature. +- **PR-b (client):** keygen + keyring commands, sign/verify, TOFU pin store, + safety-number UI, receive-path verification. + +### Verification (planned) +- vitest for `signEphemeralKey`/`verifyEphemeralKeySignature` and the TOFU pin + (first-sight pins, changed key flags, invalid signature rejects); a "server + substitutes a peer's ephemeral key → verify fails" test; keyring round-trip + (Rust); manual two-client voice call confirming audio decrypts and safety + numbers match; `make protocol-verify` + `make sqlc-verify`; full server + `-race`/`-tags deadlock`; client `npm test` + typecheck/lint/format; `ci-check`. + +## Notes carried from the build +- F4/F8 approach was simplified vs the original design: instead of a new + `check_server_cert` peek command, first-use is handled by **reject-and-retry** + — the proxy captures the fingerprint, rejects (ws `Err` / http `502`), and emits + `cert-tofu{first_use}`; the connect page's existing `getHealth` is the natural + pre-flight. A global `cert-tofu` listener (`ws.startCertListener`, registered at + bootstrap in `main.ts`) surfaces the confirm modal before any WS connect. +- The scan report + machine-readable companion are in + `CLAUDE-SECURITY-20260722-184557/`. From ef58c04ed14acf300a032fbe0319362b3edcde6d Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:45:13 +0200 Subject: [PATCH 07/15] fix(service): don't cache permission snapshots that raced an invalidation An InvalidateUser/InvalidateChannel/InvalidateAll landing between getOrPopulate's DB read and its cache store was silently overwritten by the stale snapshot, serving revoked permissions for up to permCacheTTL (30s). Guard the cache write with a generation counter bumped by every invalidation; a populate that lost the race returns its snapshot for the current request but caches nothing (security scan 2026-07-22, F6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Server/service/permission.go | 16 ++++++- Server/service/permission_test.go | 72 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/Server/service/permission.go b/Server/service/permission.go index ba1389b1..778dce06 100644 --- a/Server/service/permission.go +++ b/Server/service/permission.go @@ -31,6 +31,10 @@ type PermissionService struct { mu sync.RWMutex cache map[int64]*cachedPerms // keyed by userID + // gen is bumped by every Invalidate* call. getOrPopulate snapshots it before + // its DB read and refuses to cache if it changed, so an invalidation that + // races a populate can't be lost (F6). + gen uint64 } // NewPermissionService creates a PermissionService backed by the given DB. @@ -101,6 +105,7 @@ func (s *PermissionService) GetRoleForUser(userID int64) (*db.Role, error) { func (s *PermissionService) InvalidateUser(userID int64) { s.mu.Lock() delete(s.cache, userID) + s.gen++ s.mu.Unlock() } @@ -110,6 +115,7 @@ func (s *PermissionService) InvalidateUser(userID int64) { func (s *PermissionService) InvalidateChannel(_ int64) { s.mu.Lock() s.cache = make(map[int64]*cachedPerms) + s.gen++ s.mu.Unlock() } @@ -117,6 +123,7 @@ func (s *PermissionService) InvalidateChannel(_ int64) { func (s *PermissionService) InvalidateAll() { s.mu.Lock() s.cache = make(map[int64]*cachedPerms) + s.gen++ s.mu.Unlock() } @@ -135,6 +142,7 @@ func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { s.mu.RUnlock() return cp } + startGen := s.gen s.mu.RUnlock() // Populate. @@ -156,7 +164,13 @@ func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { } s.mu.Lock() - s.cache[userID] = cp + // F6: only cache if no invalidation raced our DB read. If gen moved, an + // InvalidateUser/InvalidateChannel/InvalidateAll landed after we snapshotted + // it, so this snapshot may already be stale — return it for this one request + // but don't poison the cache with it for permCacheTTL. + if s.gen == startGen { + s.cache[userID] = cp + } s.mu.Unlock() return cp } diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go index c5898dfa..021c7753 100644 --- a/Server/service/permission_test.go +++ b/Server/service/permission_test.go @@ -190,3 +190,75 @@ func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) { t.Fatal("expected false for unknown user") } } + +// raceHookStore wraps a real *db.DB and fires a hook right after the role read +// inside getOrPopulate, letting a test deterministically inject a concurrent +// invalidation into the populate's read→store window. +type raceHookStore struct { + *db.DB + onGetRole func() +} + +func (s *raceHookStore) GetRoleForUser(userID int64) (*db.Role, error) { + r, err := s.DB.GetRoleForUser(userID) + if s.onGetRole != nil { + s.onGetRole() + } + return r, err +} + +// TestGetOrPopulate_InvalidationDuringPopulateNotLost locks F6: an invalidation +// that races a populate (landing after the DB read but before the cache store) +// must not be silently overwritten by the stale snapshot. Otherwise a just-revoked +// permission keeps being served for up to permCacheTTL (30s). All three +// invalidation entry points bump the generation, so each is locked separately. +func TestGetOrPopulate_InvalidationDuringPopulateNotLost(t *testing.T) { + cases := []struct { + name string + invalidate func(*PermissionService) + }{ + {"InvalidateUser", func(s *PermissionService) { s.InvalidateUser(1) }}, + {"InvalidateChannel", func(s *PermissionService) { s.InvalidateChannel(10) }}, + {"InvalidateAll", func(s *PermissionService) { s.InvalidateAll() }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + 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"}) + + store := &raceHookStore{DB: database} + svc := NewPermissionService(store, permissions.NewChecker(database)) + + fired := false + store.onGetRole = func() { + if fired { + return + } + fired = true + // Admin demotes the role (removes SendMessages) and invalidates, + // racing this populate between its role read and its cache store. + if _, err := database.Exec(`UPDATE roles SET permissions = ? WHERE id = ?`, + permissions.ReadMessages, permissions.MemberRoleID); err != nil { + t.Errorf("demote role: %v", err) + } + tc.invalidate(svc) + } + + // This populate reads the pre-demotion perms; the racing invalidation + // must stop that stale snapshot from being cached. + svc.HasChannelPerm(1, 10, permissions.SendMessages) + + // A fresh check must re-read the DB and see the revoked permission. + if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + t.Fatal("revoked SendMessages served from a stale snapshot; a populate that races an invalidation must not be cached") + } + }) + } +} From a4eca1a55a9d3b4bd65b7579eaeb518d51c45800 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:45:34 +0200 Subject: [PATCH 08/15] fix(perms): own the server-scoped rule in HasServerPerm and fail closed on override-fetch errors (D13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes audit finding A-2026-07-16, two defects in the same rule: - permissions.HasServerPerm (admin bypass OR all-of bit test) replaces the hand-rolled copies in api.RequirePermission (whose raw test was any-of for multi-bit masks) and ModerationService.requireBanPermission. RequirePermission's doc comment now states the scope contract: role bitfield only, channel overrides deliberately not consulted. - PermissionService.getOrPopulate and ChannelService.ListVisibleChannels no longer substitute an empty override map when GetAllChannelPermissionsForRole errors. That silently dropped every channel-level deny — and the permission cache then served the degraded snapshot for permCacheTTL (30s) across ~25 callers. Both fail closed now; admins skip the fetch entirely (they bypass channel checks). - PermissionService.HasChannelPerm delegates to Checker.HasChannelPermBatch and MessageService.GetAccessibleChannelIDs to VisibleChannelIDs — the missed fifth D9 site, making that closure true rather than aspirational. - AuthMiddleware rejects a dangling role_id (GetRoleByID returns nil, nil) with 401 instead of putting a nil role in the request context. Locked by failing-first tests: override-fetch-error denies (cached and uncached paths), admin-outage skip, multi-bit all-of, channel allow override must not grant a server-wide route, 403 locks on both RequirePermission routes, dangling-role 401. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Server/api/diagnostics_handler_test.go | 40 ++++++++++-- Server/api/invite_handler_test.go | 35 ++++++++++ Server/api/middleware.go | 30 +++++---- Server/api/middleware_test.go | 88 ++++++++++++++++++++++++-- Server/permissions/permissions.go | 8 +++ Server/permissions/permissions_test.go | 32 ++++++++++ Server/service/channel.go | 4 +- Server/service/channel_test.go | 42 ++++++++++++ Server/service/message.go | 20 ++---- Server/service/moderation.go | 3 +- Server/service/permission.go | 26 ++++---- Server/service/permission_test.go | 58 +++++++++++++++++ 12 files changed, 337 insertions(+), 49 deletions(-) create mode 100644 Server/service/channel_test.go diff --git a/Server/api/diagnostics_handler_test.go b/Server/api/diagnostics_handler_test.go index 9cb8b6a8..d7f73b68 100644 --- a/Server/api/diagnostics_handler_test.go +++ b/Server/api/diagnostics_handler_test.go @@ -10,11 +10,12 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" ) // setupDiagnosticsRouter creates a full router with an authenticated user for // diagnostics testing. -func setupDiagnosticsRouter(t *testing.T) (http.Handler, string) { +func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) { t.Helper() database, err := db.Open(":memory:") @@ -46,11 +47,11 @@ func setupDiagnosticsRouter(t *testing.T) (http.Handler, string) { uid, hash, ) - return handler, token + return handler, token, database } func TestDiagnosticsConnectivity_ReturnsData(t *testing.T) { - router, token := setupDiagnosticsRouter(t) + router, token, _ := setupDiagnosticsRouter(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil) req.Header.Set("Authorization", "Bearer "+token) @@ -82,7 +83,7 @@ func TestDiagnosticsConnectivity_ReturnsData(t *testing.T) { } func TestDiagnosticsConnectivity_Unauthenticated(t *testing.T) { - router, _ := setupDiagnosticsRouter(t) + router, _, _ := setupDiagnosticsRouter(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil) req.RemoteAddr = "127.0.0.1:9999" @@ -94,6 +95,37 @@ func TestDiagnosticsConnectivity_Unauthenticated(t *testing.T) { } } +// TestDiagnosticsConnectivity_MemberForbidden locks the RequirePermission gate +// on the route. Without it, only 200-for-owner and 401-unauthenticated were +// covered, so deleting the ADMINISTRATOR gate broke no test while exposing the +// server's network topology to every member. +func TestDiagnosticsConnectivity_MemberForbidden(t *testing.T) { + router, _, database := setupDiagnosticsRouter(t) + + uid, err := database.CreateUser("diagmember", "$2a$12$fake", int(permissions.MemberRoleID)) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token := "diagtest-member-token" + if _, err := database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) + VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, + uid, auth.HashToken(token), + ); err != nil { + t.Fatalf("insert session: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String()) + } +} + // ─── isPrivateIP tests ────────────────────────────────────────────────────── func TestIsPrivateIP(t *testing.T) { diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go index 789de56d..8cea1344 100644 --- a/Server/api/invite_handler_test.go +++ b/Server/api/invite_handler_test.go @@ -10,6 +10,7 @@ import ( "github.com/owncord/server/api" "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" "github.com/owncord/server/service" ) @@ -89,6 +90,40 @@ func TestCreateInvite_MemberForbidden(t *testing.T) { } } +// TestCreateInvite_ChannelAllowOverrideDoesNotGrant pins the scope boundary of +// RequirePermission: it gates on SERVER-WIDE bits, so a per-channel allow must +// never open it. The state is reachable — the admin channel-permission handler +// masks override input with permissions.AllPerms, which includes ManageInvites. +// This kills the plausible-looking "just route RequirePermission through +// Checker.HasChannelPerm" refactor, which would pass naive review because +// GetChannelPermissions returns (0, 0, nil) when no override row exists. +func TestCreateInvite_ChannelAllowOverrideDoesNotGrant(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "overrideuser", 4) + + if _, err := database.Exec( + `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`); err != nil { + t.Fatalf("insert channel: %v", err) + } + if _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, ?, 0)`, + permissions.ManageInvites, + ); err != nil { + t.Fatalf("insert channel override: %v", err) + } + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{ + "max_uses": 1, + }) + + if rr.Code != http.StatusForbidden { + t.Errorf("CreateInvite with channel allow override status = %d, want 403", rr.Code) + } +} + func TestCreateInvite_Unlimited(t *testing.T) { database := newAuthTestDB(t) limiter := auth.NewRateLimiter() diff --git a/Server/api/middleware.go b/Server/api/middleware.go index b270d4d5..1e218112 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -84,8 +84,11 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } // Load role for permission checks. + // A dangling role_id returns (nil, nil) from GetRoleByID, so the nil + // check is load-bearing: without it a nil role reaches the context + // and every downstream permission check has to re-guard it. role, err := database.GetRoleByID(user.RoleID) - if err != nil { + if err != nil || role == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "role not found", @@ -106,9 +109,20 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } } -// RequirePermission returns middleware that checks the authenticated user's -// role permissions. Returns 403 if the user lacks the required permission. -// The ADMINISTRATOR bit (0x40000000) bypasses all checks. +// RequirePermission returns middleware gating a route on SERVER-WIDE role +// permissions. Returns 403 if the user lacks them. +// +// Scope contract — this is the whole reason the middleware and the service +// layer look like two permission systems: +// - It consults the role bitfield only. Channel overrides are NOT applied, +// because a route reaching this middleware has no channel id to resolve +// them against, and a per-channel allow must never open a server-wide gate. +// - Anything channel-scoped belongs in the service layer behind +// permissions.Checker (via svc.Permissions), which resolves overrides. +// - ADMINISTRATOR bypasses; multi-bit masks require ALL bits. +// +// The rule itself lives in permissions.HasServerPerm so no call site can +// re-derive it. func RequirePermission(perm int64) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -121,13 +135,7 @@ func RequirePermission(perm int64) func(http.Handler) http.Handler { return } - // ADMINISTRATOR bypasses all permission checks. - if permissions.HasAdmin(role.Permissions) { - next.ServeHTTP(w, r) - return - } - - if role.Permissions&perm == 0 { + if !permissions.HasServerPerm(role.Permissions, perm) { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "insufficient permissions", diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 5366e142..68ea2c56 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -14,6 +14,7 @@ import ( "github.com/owncord/server/api" "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" ) // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -143,6 +144,51 @@ func TestAuthMiddleware_MalformedAuthHeader(t *testing.T) { } } +// TestAuthMiddleware_DanglingRoleUnauthorized pins the `role == nil` guard: +// GetRoleByID returns (nil, nil) for a role_id with no roles row, so without +// the guard a nil role reached the request context and the request only died +// later, at RequirePermission's own nil check (403) — or not at all on routes +// that have no RequirePermission. +func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) { + database := newAPITestDB(t) + + // users.role_id has a FK to roles(id), so the dangling row can only be + // created with FK enforcement momentarily off (db.Open pins the pool to a + // single connection, so the pragma applies to the inserts that follow). + if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + t.Fatalf("disable foreign keys: %v", err) + } + res, err := database.Exec( + `INSERT INTO users (username, password, role_id) VALUES ('dangling', '$2a$12$fake', 999)`) + if err != nil { + t.Fatalf("insert dangling user: %v", err) + } + uid, _ := res.LastInsertId() + if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil { + t.Fatalf("re-enable foreign keys: %v", err) + } + + token, _ := auth.GenerateToken() + if _, err := database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) + VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, + uid, auth.HashToken(token), + ); err != nil { + t.Fatalf("insert session: %v", err) + } + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware dangling role status = %d, want 401", rr.Code) + } +} + // ─── RequirePermission tests ────────────────────────────────────────────────── func TestRequirePermission_Allowed(t *testing.T) { @@ -152,9 +198,8 @@ func TestRequirePermission_Allowed(t *testing.T) { hash := auth.HashToken(token) _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") - // SEND_MESSAGES = 0x1 — Member role has this bit h := api.AuthMiddleware(database)( - api.RequirePermission(0x1)(http.HandlerFunc(ok)), + api.RequirePermission(permissions.SendMessages)(http.HandlerFunc(ok)), ) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) @@ -174,9 +219,8 @@ func TestRequirePermission_Forbidden(t *testing.T) { hash := auth.HashToken(token) _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") - // MANAGE_ROLES = 0x1000000 — Member does not have this h := api.AuthMiddleware(database)( - api.RequirePermission(0x1000000)(http.HandlerFunc(ok)), + api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)), ) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) @@ -199,7 +243,7 @@ func TestRequirePermission_Administrator_Bypass(t *testing.T) { // Any permission should pass for ADMINISTRATOR h := api.AuthMiddleware(database)( - api.RequirePermission(0x1000000)(http.HandlerFunc(ok)), + api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)), ) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) @@ -212,6 +256,31 @@ func TestRequirePermission_Administrator_Bypass(t *testing.T) { } } +// TestRequirePermission_MultiBitRequiresAllBits pins the one behaviour the +// HasServerPerm consolidation changed: a multi-bit mask is ALL-of, not any-of. +// The previous raw `role.Permissions&perm == 0` test returned 200 here because +// Member holds SendMessages, which was enough to make the mask non-zero. +func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("multibit", "hash", 4) // Member role = 1635, has SendMessages, not ManageRoles + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)( + api.RequirePermission(permissions.SendMessages | permissions.ManageRoles)(http.HandlerFunc(ok)), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("RequirePermission partial multi-bit mask status = %d, want 403", rr.Code) + } +} + // ─── RateLimitMiddleware tests ──────────────────────────────────────────────── func TestRateLimitMiddleware_UnderLimit(t *testing.T) { @@ -940,6 +1009,15 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_video INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); + CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, diff --git a/Server/permissions/permissions.go b/Server/permissions/permissions.go index 1068d532..502bf7c6 100644 --- a/Server/permissions/permissions.go +++ b/Server/permissions/permissions.go @@ -71,6 +71,14 @@ func HasAdmin(rolePerms int64) bool { return rolePerms&Administrator != 0 } +// HasServerPerm reports whether a role holds a SERVER-WIDE permission. +// Administrator bypasses. Channel overrides are deliberately NOT consulted — +// use Checker.HasChannelPerm/HasChannelPermBatch whenever a channel id exists. +// Multi-bit masks are ALL-of (every bit must be present), matching HasPerm. +func HasServerPerm(rolePerms, perm int64) bool { + return HasAdmin(rolePerms) || HasPerm(rolePerms, perm) +} + // EffectivePerms computes the resolved permission set for a channel override. // The formula matches Discord's channel override semantics: // diff --git a/Server/permissions/permissions_test.go b/Server/permissions/permissions_test.go index f1e8cf54..3a610056 100644 --- a/Server/permissions/permissions_test.go +++ b/Server/permissions/permissions_test.go @@ -172,6 +172,38 @@ func TestHasAdmin_OwnerRolePermsHasBit(t *testing.T) { } } +// ─── HasServerPerm tests ────────────────────────────────────────────────────── + +// TestHasServerPerm locks the contract api.RequirePermission inherits: admin +// bypass, and ALL-of semantics for multi-bit masks (a raw `perms&mask != 0` +// test would make them any-of). +func TestHasServerPerm(t *testing.T) { + tests := []struct { + name string + rolePerms int64 + perm int64 + want bool + }{ + {"admin bypasses a bit it lacks", permissions.Administrator, permissions.ManageInvites, true}, + // Deliberately UNLIKE HasPerm(Administrator, 0) == false: the admin + // bypass short-circuits before the zero-mask guard. + {"admin with zero mask", permissions.Administrator, 0, true}, + {"exact bit held", permissions.ManageInvites, permissions.ManageInvites, true}, + {"bit not held", permissions.SendMessages, permissions.ManageInvites, false}, + {"non-admin with zero mask", permissions.SendMessages, 0, false}, + {"multi-bit mask partially held", permissions.SendMessages, permissions.SendMessages | permissions.ManageRoles, false}, + {"multi-bit mask fully held", permissions.SendMessages | permissions.ManageRoles, permissions.SendMessages | permissions.ManageRoles, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := permissions.HasServerPerm(tt.rolePerms, tt.perm); got != tt.want { + t.Errorf("HasServerPerm() = %v, want %v", got, tt.want) + } + }) + } +} + // ─── EffectivePerms tests ───────────────────────────────────────────────────── // EffectivePerms(rolePerm, allow, deny) = (rolePerm & ^deny) | allow diff --git a/Server/service/channel.go b/Server/service/channel.go index 1cc6b082..5692091e 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -57,7 +57,9 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) if !permissions.HasAdmin(role.Permissions) { overrides, err = s.st.GetAllChannelPermissionsForRole(role.ID) if err != nil { - overrides = make(map[int64]db.ChannelOverride) + // Fail closed — an empty map would return every denied channel. + slog.Error("ChannelService.ListVisibleChannels GetAllChannelPermissionsForRole", "err", err, "user_id", userID, "role_id", role.ID) + return nil, fmt.Errorf("%w: failed to fetch channel overrides", ErrInternal) } } diff --git a/Server/service/channel_test.go b/Server/service/channel_test.go new file mode 100644 index 00000000..e135297c --- /dev/null +++ b/Server/service/channel_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// TestListVisibleChannels_OverrideFetchErrorFailsClosed is the uncached half of +// the same fail-open bug as TestHasChannelPerm_OverrideFetchErrorDenies: an +// empty override map here would list every channel the role is explicitly +// denied. The listing must error instead of leaking the denied channel. +func TestListVisibleChannels_OverrideFetchErrorFailsClosed(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, + }) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "secret", Type: "text"}) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages) + + st := errOverrideStore{DB: database} + permSvc := NewPermissionService(st, permissions.NewChecker(database)) + svc := NewChannelService(st, permSvc) + + // Either failing path is acceptable and both are ErrInternal: the permission + // cache may short-circuit on its own fail-closed nil, or ListVisibleChannels' + // own override branch may error. What must never happen is a 200 listing. + got, err := svc.ListVisibleChannels(context.Background(), 1) + if !errors.Is(err, ErrInternal) { + t.Fatalf("ListVisibleChannels err = %v, want ErrInternal", err) + } + if got != nil { + t.Fatalf("ListVisibleChannels returned %d channels on override fetch failure, want none", len(got)) + } +} diff --git a/Server/service/message.go b/Server/service/message.go index b45b562d..443beb11 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -636,31 +636,21 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) return nil, fmt.Errorf("%w: failed to get role", ErrInternal) } - isAdmin := permissions.HasAdmin(role.Permissions) var overrides map[int64]db.ChannelOverride - if !isAdmin { + if !permissions.HasAdmin(role.Permissions) { var overrideErr error overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(role.ID) if overrideErr != nil { return nil, fmt.Errorf("%w: failed to fetch channel overrides", ErrInternal) } - if overrides == nil { - overrides = make(map[int64]db.ChannelOverride) - } } + // Single visibility predicate shared with REST ListVisibleChannels and the + // ws ready payload, so no site can drift. + visibleIDs := s.perms.Checker().VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides)) var ids []int64 for i := range channels { - if channels[i].Type == "dm" { - continue - } - if isAdmin { - ids = append(ids, channels[i].ID) - continue - } - o := overrides[channels[i].ID] - effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) - if effective&permissions.ReadMessages == permissions.ReadMessages { + if visibleIDs[channels[i].ID] { ids = append(ids, channels[i].ID) } } diff --git a/Server/service/moderation.go b/Server/service/moderation.go index 1c7f44e0..e6cce0c9 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -35,8 +35,7 @@ func (s *ModerationService) requireBanPermission(actorID int64) error { if err != nil || actorRole == nil { return fmt.Errorf("%w: failed to load actor role", ErrForbidden) } - if !permissions.HasAdmin(actorRole.Permissions) && - !permissions.HasPerm(actorRole.Permissions, permissions.BanMembers) { + if !permissions.HasServerPerm(actorRole.Permissions, permissions.BanMembers) { return fmt.Errorf("%w: missing BAN_MEMBERS permission", ErrForbidden) } return nil diff --git a/Server/service/permission.go b/Server/service/permission.go index 778dce06..498e1a9d 100644 --- a/Server/service/permission.go +++ b/Server/service/permission.go @@ -2,6 +2,7 @@ package service import ( "context" + "log/slog" "sync" "time" @@ -14,7 +15,7 @@ import ( type cachedPerms struct { roleID int64 rolePerms int64 - overrides map[int64]db.ChannelOverride + overrides map[int64]permissions.ChannelOverride populatedAt time.Time } @@ -62,12 +63,7 @@ func (s *PermissionService) HasChannelPerm(userID, channelID, perm int64) bool { if cp == nil { return false } - if permissions.HasAdmin(cp.rolePerms) { - return true - } - o := cp.overrides[channelID] // zero-value (0,0) when no override exists - effective := permissions.EffectivePerms(cp.rolePerms, o.Allow, o.Deny) - return effective&perm == perm + return s.checker.HasChannelPermBatch(cp.rolePerms, cp.overrides, channelID, perm) } // RequireChannelAccess checks whether the user can access the channel with @@ -150,10 +146,18 @@ func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { if err != nil || role == nil { return nil } - overrides, err := s.st.GetAllChannelPermissionsForRole(role.ID) - if err != nil { - // Fall back to uncached if override fetch fails. - overrides = make(map[int64]db.ChannelOverride) + // Admins bypass every channel check, so skip the fetch entirely (mirrors + // ChannelService.ListVisibleChannels and ws.buildReady). + var overrides map[int64]permissions.ChannelOverride + if !permissions.HasAdmin(role.Permissions) { + raw, oErr := s.st.GetAllChannelPermissionsForRole(role.ID) + if oErr != nil { + // Fail closed: an empty map would silently drop every deny bit, + // and caching it would keep doing so for permCacheTTL. + slog.Error("PermissionService.getOrPopulate override fetch failed, denying", "err", oErr, "user_id", userID, "role_id", role.ID) + return nil + } + overrides = permOverrides(raw) } cp = &cachedPerms{ diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go index 021c7753..f584a3b8 100644 --- a/Server/service/permission_test.go +++ b/Server/service/permission_test.go @@ -1,6 +1,7 @@ package service import ( + "errors" "testing" "time" @@ -8,6 +9,41 @@ import ( "github.com/owncord/server/permissions" ) +// errOverrideStore wraps a real *db.DB but always fails the channel-override +// fetch, so the fail-closed contract (A-2026-07-16) is testable. Embedding +// *db.DB satisfies the service Store interface; only the one overridden method +// diverges, every other call still hits the real database. +type errOverrideStore struct { + *db.DB +} + +func (errOverrideStore) GetAllChannelPermissionsForRole(int64) (map[int64]db.ChannelOverride, error) { + return nil, errors.New("boom") +} + +// TestHasChannelPerm_OverrideFetchErrorDenies locks the fail-closed rule: when +// the override fetch errors we must NOT substitute an empty map, because that +// restores every bit a channel-level deny had stripped — and PermissionService +// would then cache that degraded snapshot for permCacheTTL. +func TestHasChannelPerm_OverrideFetchErrorDenies(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, + }) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"}) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages) + + svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database)) + + if svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + t.Fatal("override fetch failure must deny, not fall back to the base role bits") + } +} + // newTestPermService creates a PermissionService backed by a real in-memory DB // pre-populated with a single role and user. func newTestPermService(t *testing.T) (*PermissionService, *db.DB) { @@ -100,6 +136,28 @@ func TestHasChannelPerm_AdminBypass(t *testing.T) { } } +// TestHasChannelPerm_AdminSkipsOverrideFetch locks the admin skip in +// getOrPopulate: fail-closed must not extend to admins, who bypass every +// channel check anyway. Without the skip, an override-fetch outage would +// deny admins everything instead of nothing. +func TestHasChannelPerm_AdminSkipsOverrideFetch(t *testing.T) { + database := newTestDB(t) + seedRole(t, database, &db.Role{ + ID: permissions.AdminRoleID, + Name: "admin", + Permissions: permissions.Administrator, + Position: 90, + }) + seedUserRole(t, database, 1, permissions.AdminRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) + + svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database)) + + if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + t.Fatal("admin must not be denied by an override-fetch outage; the fetch is skipped for admins") + } +} + func TestInvalidateUser_ClearsCacheForUser(t *testing.T) { svc, database := newTestPermService(t) seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) From 83d924c10aede2b288ea71ea59f483f54034cd1c Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:45:42 +0200 Subject: [PATCH 09/15] docs(audit): record D13 closure of A-2026-07-16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design note (permission-middleware-consolidation.md, status implemented), closure-table + §3 rows for A-2026-07-16, the A-2026-07-07 amendment recording the missed fifth site, the D13 decision row, and the settled two-scope authorization contract in architecture/server.md. Backlog row 12 stays untouched: the auth-route sweep is deferred to a future D14. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/architecture/server.md | 16 ++- docs/audit-2026-07-19.md | 6 +- docs/plans/audit-2026-07-19-decisions.md | 5 +- .../permission-middleware-consolidation.md | 127 ++++++++++++++++++ 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 docs/plans/permission-middleware-consolidation.md diff --git a/docs/architecture/server.md b/docs/architecture/server.md index c094d497..bb58f306 100644 --- a/docs/architecture/server.md +++ b/docs/architecture/server.md @@ -119,10 +119,16 @@ sequenceDiagram chi's `middleware.RealIP` is deliberately omitted — client IP is resolved via `clientIPWithProxies` against configured trusted proxies instead, so spoofed `X-Real-IP`/`X-Forwarded-For` headers are not trusted by default. Authentication -is bearer-token (SHA-256-hashed opaque tokens); authorization is enforced -inconsistently — sometimes as `RequirePermission` middleware at mount time, -sometimes in-handler through `svc.Permissions` (an audit finding). The shaded -region marks the two documented bypass paths of the domain layer. +is bearer-token (SHA-256-hashed opaque tokens); authorization is enforced at two +deliberate scopes (D13): `RequirePermission` middleware gates the two +channel-less routes on server-wide role permissions via +`permissions.HasServerPerm` (channel overrides deliberately not consulted — a +per-channel allow must never open a server-wide gate), while anything +channel-scoped is checked in the service layer through `svc.Permissions` / +`permissions.Checker`, which resolves overrides and fails closed if they cannot +be fetched. The shaded region marks the two documented bypass paths of the +domain layer. **Source of truth:** `Server/api/router.go`, `Server/api/middleware.go`, -`Server/api/auth_handler.go`, `Server/admin/middleware.go`, `Server/service/`. +`Server/api/auth_handler.go`, `Server/admin/middleware.go`, +`Server/permissions/`, `Server/service/`. diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index d4b60ecd..7b0f100a 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -20,7 +20,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog | A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | CLOSED 2026-07-20 — the "KNOWN RED" premise was stale: suite verified green on `main` (3261/3261, 114 files) and the annotations in both CLAUDE.md files + `ci.yml` corrected to "green, must stay green". Root cause of the false premise: on Node 22+, native Web Storage shadows jsdom's `localStorage`, failing ~478 unrelated tests locally; with `NODE_OPTIONS=--no-experimental-webstorage` (CI pins Node 20) the suite is fully green. Documented in the client CLAUDE.md and the `ci-check` skill so it is not re-misdiagnosed. Flipping `client-tests` to blocking + adding the nightly Playwright gate remain tracked as backlog #10 | | A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | RESOLVED 2026-07-19 — `dbgen` wired into `db.DB`; 97 methods across all domains delegate to it (no longer dead). Remaining raw queries (variable IN / FTS / tx) tracked in [plans/sqlc-adoption.md](plans/sqlc-adoption.md) | | A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | RESOLVED 2026-07-19 — collapsed to a single sqlc-backed `db` package: dbgen wired in (D2) and the `store` seam deleted (D3). The service layer depends on a narrow `service.Store` interface `*db.DB` satisfies; ws/plugin similarly. Broadening service-only access above the remaining direct-`db` handlers is the residual layering work (A-2026-07-06 backlog item 12) | -| A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | RESOLVED 2026-07-20 (D9) — all four sites (REST `ListVisibleChannels`, ws `buildReady`, replay `computeAllowedChannels`, hub `RefreshChannelVisibility`) route through one `permissions.Checker` predicate (`VisibleChannelIDs` / `HasChannelPerm`); REST/WS agreement test added | +| A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | RESOLVED 2026-07-20 (D9) — all four sites (REST `ListVisibleChannels`, ws `buildReady`, replay `computeAllowedChannels`, hub `RefreshChannelVisibility`) route through one `permissions.Checker` predicate (`VisibleChannelIDs` / `HasChannelPerm`); REST/WS agreement test added. 2026-07-23 (D13): a missed fifth copy (`MessageService.GetAccessibleChannelIDs`) was found re-inlining the rule and now delegates to `VisibleChannelIDs` | | A-2026-07-08 | MEDIUM | Protocol constants on both sides claim generation from `docs/protocol-schema.json`, which does not exist in the repo | CLOSED 2026-07-19 — codegen implemented: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-verify` CI gate | | A-2026-07-09 | MEDIUM | Dual V1+V2 WS dispatch (strangler-fig) still live; two parsers/registries to keep in sync | RESOLVED 2026-07-20 (D10) — the 3 remaining V1 types (`chat_command`, `voice_join`, `voice_leave`) ported to typed V2 handlers; the V1 registry + fallback path deleted. `handleMessage` has a single dispatch generation. Server-internal only, no wire change | | A-2026-07-10 | MEDIUM | `api.NewRouter` god-constructor: builds services, hub, LiveKit, updater, admin, plugins; spawns goroutines; mounts everything | OPEN | @@ -29,6 +29,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog | A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal (correction 2026-07-19: `audit_log_v6` is only a transient rename inside migration 003, not a coexisting table) | OPEN | | A-2026-07-14 | LOW | Scattered client constants (`#5865F2` ×18, `localhost:8443` ×3); 64 timer call sites with manual lifecycle | OPEN | | A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` partly stale (references deleted `store/postgres.go`) | OPEN | +| A-2026-07-16 | HIGH | Server-wide permission rule hand-rolled at 2 sites (`RequirePermission` raw any-of bit test; `ModerationService`); channel-level `deny` silently dropped — and cached for 30s — when the override fetch errors, at 2 of 5 sites | RESOLVED 2026-07-23 (D13) — `permissions.HasServerPerm` now owns the server-scoped rule (both sites collapse onto it; multi-bit masks are all-of); both override-fetch sites fail closed (`getOrPopulate` skips the fetch for admins, denies and caches nothing on error; `ListVisibleChannels` returns `ErrInternal`); the fifth D9 site (`GetAccessibleChannelIDs`) routes through `VisibleChannelIDs`. Locked by failing-first tests. See [plans/permission-middleware-consolidation.md](plans/permission-middleware-consolidation.md) | --- @@ -119,12 +120,13 @@ tooling). The findings are about the seams that grew around that design. |----|-----|------|----------|---------|----------------|--------| | A-2026-07-05 | MEDIUM | Data layer | `Server/db/dbgen/` (~3.5k LOC), `sqlc.yaml`, CI `sqlc-verify` job | sqlc output is generated, version-pinned, CI-verified — and imported by nothing. Hand-written raw SQL in `Server/db/*_queries.go` is what runs. | Decide the Phase-A question: adopt dbgen inside `db.DB` method bodies, or delete `dbgen/` + `queries/` + the CI job. Either ends the illusion of a second data layer. | S | | A-2026-07-06 | MEDIUM | Layering | All `Mount*Routes` signatures take `database *db.DB` alongside `svc` (`Server/api/*_handler.go`); `Server/admin` handlers take `*db.DB` | ~359 direct `database.*` calls above the store seam; three access styles coexist. The abstraction exists but cannot be relied on (e.g. for a future backend swap or for test doubles). | Consolidate incrementally: new handlers service-only; migrate one mount per PR, starting with auth (prior #9). | L | -| A-2026-07-07 | MEDIUM | Correctness risk | `Server/ws/serve.go` (`buildReady`, `computeAllowedChannels`), `Server/ws/hub.go` (`RefreshChannelVisibility`), REST `handleListChannels` | Channel-visibility filtering implemented ~4× with comments instructing they "must mirror" each other. The recent private-channel fixes (e.g. `2bfe6d6`) show this is actively churning — a drift between copies is an information-disclosure bug waiting to happen. | **Resolved 2026-07-20 (D9)** — added `permissions.Checker.VisibleChannelIDs`; all four sites route through it (`RefreshChannelVisibility` uses the single-channel `HasChannelPerm`); REST/WS agreement test added. | M | +| A-2026-07-07 | MEDIUM | Correctness risk | `Server/ws/serve.go` (`buildReady`, `computeAllowedChannels`), `Server/ws/hub.go` (`RefreshChannelVisibility`), REST `handleListChannels` | Channel-visibility filtering implemented ~4× with comments instructing they "must mirror" each other. The recent private-channel fixes (e.g. `2bfe6d6`) show this is actively churning — a drift between copies is an information-disclosure bug waiting to happen. | **Resolved 2026-07-20 (D9)** — added `permissions.Checker.VisibleChannelIDs`; all four sites route through it (`RefreshChannelVisibility` uses the single-channel `HasChannelPerm`); REST/WS agreement test added. **2026-07-23 (D13):** a missed fifth copy (`MessageService.GetAccessibleChannelIDs`, admin bypass + dm-skip + raw READ mask) found and routed through `VisibleChannelIDs`. | M | | A-2026-07-08 | MEDIUM | Protocol integrity | `Server/ws/message_types.go` header comment; `Client/…/src/lib/protocolTypes.ts` header + "Extensions (not in protocol-schema.json…)" comments | Both sides claim `docs/protocol-schema.json` is the generated single source of truth. The file does not exist; the two constant sets are maintained by hand and have already grown divergent "extension" entries. | Either commit a real `protocol-schema.json` + generator (best: also emits protocol.md tables), or delete the claim and add a cross-language equality test over the two constant sets. | M | | A-2026-07-09 | MEDIUM | Real-time | `Server/ws/handlers.go` (`handleMessage` V2-then-V1 fallback), dual registration in `NewHub` (`Server/ws/hub.go`) | Strangler-fig V1+V2 dispatch is live: two parsers (lenient/strict), two registries, per-type duplication. | **Resolved 2026-07-20 (D10)** — ported the last 3 V1 types (`chat_command`, `voice_join`, `voice_leave`) to typed V2 handlers and deleted the V1 registry + `handleMessage` fallback; a parity guard test locks the single dispatch path shut. | M/L | | A-2026-07-10 | MEDIUM | Composition | `Server/api/router.go:34` (`NewRouter`, ~278 lines) | God-constructor builds rate limiter, TOTP key, storage, services, hub, LiveKit client+process, updater, admin + plugin handlers; spawns goroutines; returns a cleanup closure covering only one of them. Hard to test wiring in isolation; lifecycle ownership is implicit. | Split construction (a `Deps`/`App` struct built in `main.go`) from route mounting (`NewRouter(deps)`); return a composite `io.Closer`. | M | | A-2026-07-11 | MEDIUM | Real-time | `Server/ws/hub.go` (`SetLiveKit`, `SetEventPersister`, `SetPluginRegistry`, …) | Hub is a mega-object wired post-construction via setters that "must be called before Run" — temporal coupling; a missed setter is a nil-deref at runtime, not a compile error. | Move required collaborators into `NewHub` params (or an options struct validated before `Run`). Full Hub decomposition is a separate, larger effort (backlog 12). | S (constructor) / L (decomposition) | | — | MEDIUM | Layering | `Server/ws/hub.go:182` (`refreshSettingsLocked`) | Hub runs inline `SELECT value FROM settings WHERE key='server_name'` instead of using `SettingsStore` — the only raw SQL in the real-time layer. | **Fixed 2026-07-19** — now uses `db.GetSetting`; full consolidation folds into A-2026-07-06. | S | +| A-2026-07-16 | HIGH | Correctness risk | `Server/api/middleware.go` (`RequirePermission`), `Server/service/moderation.go:38`, `Server/service/permission.go` (`getOrPopulate`), `Server/service/channel.go` (`ListVisibleChannels`), `Server/service/message.go` (`GetAccessibleChannelIDs`) | Found 2026-07-21 pulling on the D9 thread. (1) The server-wide rule (admin bypass + bit test) had no owner: two sites hand-rolled it, `RequirePermission` as any-of (`&perm != 0`) where `HasPerm` is all-of — identical for today's single-bit constants, silently divergent for any multi-bit mask. (2) On `GetAllChannelPermissionsForRole` error, two of five sites substituted an *empty override map*: every `deny` for that role evaporates, and `PermissionService` caches the degraded snapshot for `permCacheTTL` (30s) across `HasChannelPerm`'s ~25 callers. The three sibling sites fail closed on the identical error. | **Resolved 2026-07-23 (D13)** — `permissions.HasServerPerm` (admin bypass, all-of) owns the server rule; both fetch sites fail closed (admin skip + deny-and-cache-nothing / `ErrInternal`, `slog.Error` at the fail point); `HasChannelPerm` delegates to `Checker.HasChannelPermBatch`; `GetAccessibleChannelIDs` delegates to `VisibleChannelIDs`. See [plans/permission-middleware-consolidation.md](plans/permission-middleware-consolidation.md). | M | | — | LOW | Scaling posture | `Server/auth/ratelimit.go` (documented), in-memory pub/sub + ring buffer, process-local TOTP replay | Single-instance coupling is structural and *documented* — this is a deliberate design, not a bug. Recorded here so the constraint stays visible ([architecture/system-overview.md D8](architecture/system-overview.md)). | No action now; revisit only if multi-instance ever becomes a goal. | — | | A-2026-07-13 | LOW | Schema hygiene | `sounds` table (`Server/migrations/001`), `audit_log` + `audit_log_v6` (`003`) | Dead/duplicated schema: soundboard was removed but its table remains; two audit-log tables coexist after the 003 rebuild. | Add a cleanup migration (drop `sounds`, finish the audit_log consolidation) next time a migration ships anyway. | S | diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index b66652d4..5266cb9e 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -1,8 +1,8 @@ # Audit 2026-07-19 — Maintainer Decisions -**Date decided:** 2026-07-19 (D1–D8); 2026-07-20 (D9–D11) +**Date decided:** 2026-07-19 (D1–D8); 2026-07-20 (D9–D12); 2026-07-21 (D13) **Decided by:** J3vb -**Status:** decisions recorded; greenlit items (D4, D7, D8) implemented 2026-07-19 — see per-row Status. **2026-07-20:** backlog items 3 and 11 (D9, D10) implemented — channel-visibility unified through `permissions.Checker`; V2 dispatch migration finished and V1 deleted. **2026-07-20 (P3):** the five plugin CRITICALs carried over from audit-2026-04-07 dispositioned (D11) — four closed, one accepted as residual risk, which keeps plugins default-disabled at the beta gate. +**Status:** decisions recorded; greenlit items (D4, D7, D8) implemented 2026-07-19 — see per-row Status. **2026-07-20:** backlog items 3 and 11 (D9, D10) implemented — channel-visibility unified through `permissions.Checker`; V2 dispatch migration finished and V1 deleted. **2026-07-20 (P3):** the five plugin CRITICALs carried over from audit-2026-04-07 dispositioned (D11) — four closed, one accepted as residual risk, which keeps plugins default-disabled at the beta gate. **2026-07-23:** D13 implemented — server-scoped permission rule unified in `permissions.HasServerPerm`; override-fetch errors fail closed instead of dropping (and caching the loss of) every channel `deny`; the fifth D9 site routed through the `Checker`. **Source:** decision points raised by [docs/audit-2026-07-19.md](../audit-2026-07-19.md) This document records the maintainer's answers to the open decision points from @@ -26,6 +26,7 @@ here (and the audit's closure table) as items land. | D10 | Finish the V2 dispatch migration; delete V1 | A-2026-07-09 / backlog 11 | **Greenlit 2026-07-20 — implement**: port the 3 remaining V1 types (`chat_command`, `voice_join`, `voice_leave`) to V2, then delete the V1 registry + fallback path. Server-internal only, no wire change. See [v2-dispatch-migration.md](v2-dispatch-migration.md). | **Implemented 2026-07-20** — the 3 types ported to typed V2 handlers (voice join/leave hand off to the hub routines via new `Result.JoinVoice`/`LeaveVoice` appliers); V1 registry + `handleMessage` fallback deleted; a constructor↔handler parity guard test locks it shut. No wire change. | | D11 | Disposition of the five plugin CRITICALs from audit-2026-04-07 (§1 carried-over row) | prior #1–#5 | **Close what the code already closes; fix the one cheap real gap; accept the one that hardening cannot fix.** Verified each against `Server/plugin/` rather than the tracker: #1 (no `invokeCommand` timeout) closed by PR #1182 — per-call CPU budget with a 100 ms floor plus `WithCloseOnContextDone` and lazy re-instantiation so an overrun does not brick the plugin. #2 (storage key isolation) closed as structural — the namespace is the caller's `Instance.ID` and `plugin_kv PRIMARY KEY (plugin_id, key)`; no parameter exists by which a plugin could name another's namespace, so the finding's premise was wrong. #3 (per-command ACL) was a **real gap** and is fixed here: the manifest gains a `commands` block and `RegisterCommand` refuses undeclared names, so `list_commands` can no longer widen a plugin's command surface behind the admin's back. #4 (event rate limit) closed because no guest code executes on the event path — precisely: `EventSink.Dispatch` has exactly one caller outside the plugin package's tests (`Server/ws/hub.go:1034`, on every broadcast when plugins are enabled, on the hub goroutine under `seqMu`), but its loop body invokes no guest code and no production code calls `EventSink.Subscribe`, so the subscriber set is always empty. Rather than build a limiter for guest calls that do not happen, the requirement is recorded as a SECURITY GATE comment on `Dispatch` and `Subscribe` — the exact places someone would wire delivery — including the warning that the hot call site already exists and sits under the hub's `seqMu`. #5 (HTTP exfiltration to an allowlisted host) **stays open as accepted residual risk** — an allowlisted host is by definition permitted, so closing it needs egress content policy and per-plugin allowlists (a runtime redesign, ~1–2 weeks), explicitly out of scope for P3. | **Implemented 2026-07-20** — closure tables in [audit-2026-04-07.md](../audit-2026-04-07.md) and the §1 row of [audit-2026-07-19.md](../audit-2026-07-19.md) updated; manifest `commands` ACL + key-size cap + five pinning tests landed (`Server/plugin/audit_closure_test.go`). Because #5 remains open, the standing rule fires as written: **plugins ship default-disabled at the beta gate** — re-verified in `config.DefaultConfig()` (`Plugins.Enabled: false`, empty `HTTPAllowlist`). | | D12 | Who supplies the GIF (Klipy) API key | P3 item 1 / A-2026-07-02 family | **Decided 2026-07-20 — per-operator key, feature default-off.** The key previously shipped inside the client bundle via `VITE_KLIPY_API_KEY`. That is not a sharing arrangement but a disclosure: Vite inlines the value verbatim, so anyone who downloaded the client could extract the maintainer's key and use it for any purpose, with the maintainer carrying the quota, abuse and terms-of-service exposure. The key is now server-side only (`gif.api_key` / `OWNCORD_GIF_API_KEY`). **Alternatives considered and rejected:** a project-hosted proxy holding the maintainer's key (preserves zero-config GIFs and keeps revoke/rate-limit control, but introduces a hard central dependency into a self-hosted product, puts every server's search queries through maintainer infrastructure, and leaves the maintainer paying the quota), and a hybrid falling back to that proxy when unconfigured (same objections, opt-out only). **Consequence accepted:** each operator requests their own key at partner.klipy.com; fresh installs have GIFs off and the client shows "GIFs are not enabled on this server". Discoverability is handled in the README feature list and a quick-start section rather than by defaulting the feature on. | **Implemented 2026-07-20** — server proxy + default-off contract in #1198; `VITE_KLIPY_API_KEY` deleted from source and from all three release build jobs. Old key rotation is a maintainer action, sequenced after the new path is verified working. | +| D13 | Server-wide permission rule hand-rolled outside `permissions`; channel `deny` dropped — and cached — on override-fetch error | A-2026-07-16 | **Greenlit 2026-07-21 — implement**: add `permissions.HasServerPerm` (admin bypass OR all-of bit test, four lines, no DB) and collapse `RequirePermission` + `ModerationService.requireBanPermission` onto it; fail closed at both override-fetch sites; delegate `PermissionService.HasChannelPerm` and `GetAccessibleChannelIDs` to the `Checker`. Explicitly **not** making `RequirePermission` channel-aware — neither of its routes has a channel id, and a per-channel allow must never open a server-wide gate. Auth-route direct-db sweep (backlog row 12 / A-2026-07-06) deferred to a future D14; row 12 unchanged by this work. See [permission-middleware-consolidation.md](permission-middleware-consolidation.md). | **Implemented 2026-07-23** — `HasServerPerm` owns the rule (multi-bit masks now all-of); both fetch sites fail closed with `slog.Error` (nothing cached on error, so the next request retries); fifth D9 site (`GetAccessibleChannelIDs`) routes through `VisibleChannelIDs`; `AuthMiddleware` gains the dangling-role nil guard (401). Locked by failing-first deny tests plus 403 locks on both `RequirePermission` routes. | ## Suggested sequencing diff --git a/docs/plans/permission-middleware-consolidation.md b/docs/plans/permission-middleware-consolidation.md new file mode 100644 index 00000000..76ea754c --- /dev/null +++ b/docs/plans/permission-middleware-consolidation.md @@ -0,0 +1,127 @@ +# Permission-Middleware Consolidation (audit finding A-2026-07-16) — Design + +**Status:** implemented 2026-07-23 (D13) +**Decision:** D13 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) +**Closes:** audit finding A-2026-07-16 (new, HIGH). Closes **none** of backlog §6 +item 12's findings — A-2026-07-06, A-2026-07-10 and A-2026-07-11 all remain +exactly as recorded; row 12 stays unstruck and unannotated. + +## Problem + +Two separate defects in the same rule, found by pulling on the same thread. + +**1. The server-wide rule has no home.** `api.RequirePermission` (`middleware.go:112`) +does an admin bypass and then a raw `role.Permissions&perm == 0` bit test. +`service.ModerationService` (`moderation.go:38`) writes the same rule as +`!HasAdmin(p) && !HasPerm(p, BanMembers)`. The `permissions` package exposes +`HasPerm`, `HasAdmin`, `EffectivePerms` and four **channel-scoped** `Checker` +methods — nothing combining the admin bypass with a channel-less bit, so both +sites hand-rolled it. The raw test is also any-of (`&perm != 0`) where `HasPerm` +is all-of: identical for the single-bit constants both call sites pass today, +silently divergent for any future multi-bit mask. + +**2. A channel-level `deny` is genuinely not honoured — one layer down.** +`PermissionService.getOrPopulate` (`permission.go:145-149`) and +`ChannelService.ListVisibleChannels` (`channel.go:58-61`) substitute an *empty +override map* when `GetAllChannelPermissionsForRole` errors. Every `deny` bit for +that role evaporates, and `PermissionService` then **caches** the degraded +snapshot for `permCacheTTL` (30s), across `HasChannelPerm`'s ~25 callers: message +reads, pins, attachment serving, WS. Meanwhile `permissions.Checker` +(`checker.go:60-63`), `MessageService.GetAccessibleChannelIDs` +(`message.go:643-646`) and `ws.buildReady` (`serve.go:622-624`) all fail *closed* +on the identical error. Two of five sites dissent, and they are the cached ones. + +D9 also declared `VisibleChannelIDs` the single visibility predicate; it missed a +fifth site — `GetAccessibleChannelIDs` still re-inlines admin bypass + dm-skip + +`EffectivePerms` + a raw READ mask (`message.go:639-666`). + +## Approach — one server-scoped predicate, and fail closed on override load + +1. Add `permissions.HasServerPerm(rolePerms, perm int64) bool` — four lines, + `HasAdmin(rolePerms) || HasPerm(rolePerms, perm)`, no DB, no interface. + `RequirePermission` and `ModerationService.BanUser` both collapse onto it. + `RequirePermission`'s signature is unchanged, so nothing needs replumbing. +2. Fail closed at both override-fetch sites. `getOrPopulate` first **skips the + fetch entirely for admins** (mirroring `channel.go:57` and `serve.go:619` — + they bypass every channel check anyway), then `return nil` on error, caching + nothing so the next request retries. `ListVisibleChannels` returns + `ErrInternal`. Both log `slog.Error` at the fail point. +3. Delete the two remaining copies of the channel rule. + `PermissionService.HasChannelPerm` delegates to the `Checker` it already holds + (`HasChannelPermBatch`) once `cachedPerms.overrides` carries + `permissions.ChannelOverride` — converted once at populate time by the + existing `permOverrides` helper (`channel.go:86`, same package, no adapter + needed). `GetAccessibleChannelIDs` calls `VisibleChannelIDs`, making D9's + closure true rather than aspirational. + +Routing `RequirePermission` through the `Checker` is explicitly **not** the fix — +see Non-goals. + +## Files touched + +- `Server/permissions/permissions.go` — add `HasServerPerm`. +- `Server/api/middleware.go` — `RequirePermission` uses it; `AuthMiddleware` + gains the missing `|| role == nil` (`GetRoleByID` returns `(nil, nil)` for a + missing row, so a dangling `role_id` puts a typed-nil `*db.Role` in ctx today; + `admin/middleware.go:52` already checks). +- `Server/service/moderation.go` — second server-scoped site collapses. +- `Server/service/permission.go` — admin skip + fail closed in `getOrPopulate`; + `HasChannelPerm` delegates; `cachedPerms.overrides` retyped. +- `Server/service/channel.go` — `ListVisibleChannels` fails closed. +- `Server/service/message.go` — `GetAccessibleChannelIDs` delegates to + `VisibleChannelIDs`. +- Docs: `docs/audit-2026-07-19.md` — new §1 + §3 rows for A-2026-07-16 + (RESOLVED 2026-07-23 (D13)); amend the A-2026-07-07 rows to record the missed + fifth site; row 12 untouched. `docs/plans/audit-2026-07-19-decisions.md` — D13 + row + status clause. `docs/architecture/server.md` — D3 prose (§"enforced + inconsistently") and the source-of-truth list. + +## Test plan + +- `TestHasChannelPerm_OverrideFetchErrorDenies` and + `TestListVisibleChannels_OverrideFetchErrorFailsClosed` — a `Store` double + embedding `*db.DB` (the `pwStore` pattern, `user_test.go:16`) that fails only + `GetAllChannelPermissionsForRole`, over a real seeded `deny`. **Both fail on + today's code**; they are the headline locks. +- `TestHasServerPerm` — table-driven, pins the all-of contract and the admin + bypass at the layer that owns the rule. +- `TestRequirePermission_MultiBitRequiresAllBits` — the any-of → all-of + tightening is the only semantic change to the middleware; nothing else fails if + someone reverts to `&perm != 0`. +- `TestCreateInvite_ChannelAllowOverrideDoesNotGrant` — a channel override + granting `MANAGE_INVITES` must not open a server-wide route. Reachable state: + `admin/handlers_channel_perms.go:100` masks with `AllPerms`, which permits it. +- `TestDiagnosticsConnectivity_MemberForbidden` and + `TestAuthMiddleware_DanglingRoleUnauthorized` — the second `RequirePermission` + route has no 403 lock at all today, and the nil-role guard is a 403→401 flip + that must not ship untested. +- Existing deny locks stay green untouched and are the regression net: + `channel_authz_test.go:94/110`, `channel_handler_test.go:788`, + `upload_handler_test.go:1222`, `permission_test.go:50`, `can_send_test.go:34`. + +## Non-goals + +- **Making `RequirePermission` channel-aware.** Neither route has a channel, chi + cannot hand a `r.Use` middleware a `{id}` declared on its own mux (v5.2.5 + `mux.go:513`), `GET /api/v1/files/{id}` could never use it (its channel id + comes from the DB row), and ws has no HTTP middleware — so it would be a + *second* enforcement point for a rule the `Checker` owns. `channelID=0` would + issue a query whose right-looking answer is an accident of `ErrNoRows` + handling (`db/channel_queries.go:140`), not a design. +- **The auth-route DB sweep (item 12 / A-2026-07-06).** `AuthMiddleware` has 20 + call sites and the auth handlers ride on raw db sentinels (`handleLogin`'s + enumeration defence needs `GetUserByUsername`'s `(nil, nil)`; + `ErrLastAdmin`→403; `IsUniqueConstraintError`→400). **D14**, first slice: a + `service.AuthService` behind `AuthMiddleware`, which also deletes the + `database *db.DB` parameter from the five Mount funcs that feed it nothing + else. That is when row 12 earns `PARTIAL`, not this PR. +- **A source-scanning guard test** for raw bit patterns — a homegrown regexp + lint with a known ceiling, catching what the two new tests plus review already + catch. Revisit as a `golangci-lint` rule if it recurs. +- **`ws.channelCanSend`** (`serve.go:583-590`) — the last hand-rolled copy. It + holds an override *value*, not a map, so reducing it needs a one-entry map + allocation on the ready hot path or a new value-taking predicate. Disclosed + deliberately rather than fixed; separate PR. +- No `(bool, error)` permission signatures (`ws/deps.go:86-90`'s + INTERNAL-vs-FORBIDDEN precedent is right but is a ~25-site change), no + rate-limiter reordering, no `IsOwnerRole` deletion, no 403 body change. From 2cef29bc71707d43c7174b8b4ae36975cc294ea0 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:54:59 +0200 Subject: [PATCH 10/15] chore: delete dead code and tracked junk (deletion audit 2026-07-23) Verified-safe deletions from the 2026-07-23 deletion audit, applied now that the permission-consolidation work (which deferred IsOwnerRole) has landed: - Server/service/voice.go: VoiceService was constructed in service.New and never called by any handler, ws routine, or test. - permissions.IsOwnerRole: zero callers. - Server/admin/static/admin-mockup.html: 1299 lines embedded into every release binary via //go:embed static, referenced by nothing. - .cache/project-map/*.json: tool cache committed before .gitignore grew the .cache/ rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .cache/project-map/git-data.json | 6020 ------------------------- .cache/project-map/session-data.json | 619 --- Server/admin/static/admin-mockup.html | 1299 ------ Server/permissions/permissions.go | 7 - Server/service/service.go | 2 - Server/service/voice.go | 150 - 6 files changed, 8097 deletions(-) delete mode 100644 .cache/project-map/git-data.json delete mode 100644 .cache/project-map/session-data.json delete mode 100644 Server/admin/static/admin-mockup.html delete mode 100644 Server/service/voice.go diff --git a/.cache/project-map/git-data.json b/.cache/project-map/git-data.json deleted file mode 100644 index 3301eab6..00000000 --- a/.cache/project-map/git-data.json +++ /dev/null @@ -1,6020 +0,0 @@ -{ - "timestamp": "2026-03-31T09:22:37.883Z", - "commitsByModule": { - "root": { - "count": 80, - "lastCommit": "2026-03-30 22:36:38 +0200", - "commits": [ - { - "hash": "f7372aec3219d36b76bc269bb7940ee914e660eb", - "date": "2026-03-30 22:36:38 +0200", - "message": "docs: add screenshots to README", - "type": "docs" - }, - { - "hash": "86541ac3aa4af84c4d787d73a1f0ffc1e4119d7f", - "date": "2026-03-30 22:32:31 +0200", - "message": "chore: gitignore internal docs subdirectories", - "type": "chore" - }, - { - "hash": "cec6ca6a97df645d41c781cc257dad24e5a407ca", - "date": "2026-03-30 22:31:06 +0200", - "message": "docs: add public documentation for contributors and users", - "type": "docs" - }, - { - "hash": "bfe3404e14ab7143410a77c69bf7c28b4a161af1", - "date": "2026-03-30 21:07:50 +0200", - "message": "updated gitignore", - "type": "other" - }, - { - "hash": "44390ad886be392b55d1a15995eba0fb9c4a80d1", - "date": "2026-03-30 21:05:28 +0200", - "message": "chore: clean up tracked files for v1.0.0 public release", - "type": "chore" - }, - { - "hash": "ad7ac75c74d0351d37e0eded5e4594528041866d", - "date": "2026-03-30 20:50:44 +0200", - "message": "docs: v1.0.0 release prep — version bump, license, README overhaul", - "type": "docs" - }, - { - "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", - "date": "2026-03-30 19:12:36 +0200", - "message": "feat: sidebar stream preview + screenshare focus fix", - "type": "feat" - }, - { - "hash": "8882cfa330e28f273cd1e53234e4e95b37a6c7eb", - "date": "2026-03-30 16:47:19 +0200", - "message": "docs: regenerate codemaps from current codebase", - "type": "docs" - }, - { - "hash": "ad76d4a4139460d24974d6c436ba9782ac5ddfb2", - "date": "2026-03-30 16:37:28 +0200", - "message": "docs: update session log with TS error fix details", - "type": "docs" - }, - { - "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", - "date": "2026-03-30 16:35:02 +0200", - "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", - "type": "test" - }, - { - "hash": "3f0d5309374562116f211eab06e170ca2f31818c", - "date": "2026-03-30 14:24:12 +0200", - "message": "yu", - "type": "other" - }, - { - "hash": "773c811f06c6523b599a880908ac670c654437fb", - "date": "2026-03-30 14:23:39 +0200", - "message": "docs: add repo copilot assets section to CLAUDE.md and README", - "type": "docs" - }, - { - "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", - "date": "2026-03-30 12:42:31 +0200", - "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", - "type": "fix" - }, - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "5eeff05e892ec554f0a3f6c4903301f48c14e7c8", - "date": "2026-03-29 19:42:07 +0200", - "message": "chore: remove accidental empty file", - "type": "chore" - }, - { - "hash": "7521fbb41fa474d2d0cd6dfd85f319897a12e67e", - "date": "2026-03-29 19:40:47 +0200", - "message": "docs: update task tracking and dashboard for v1.3.0", - "type": "docs" - }, - { - "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", - "date": "2026-03-29 19:40:11 +0200", - "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", - "type": "chore" - }, - { - "hash": "1294d9a2b104df51b48607bf8491b1028cf9c620", - "date": "2026-03-29 12:35:29 +0200", - "message": "chore: remove accidental empty files", - "type": "chore" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "829a8b0efddabb52e58b9e8bf8a7324be30d0cea", - "date": "2026-03-29 10:13:04 +0200", - "message": "fix: repair 14 failing E2E tests and expand voice lifecycle coverage", - "type": "fix" - }, - { - "hash": "e98ac2a36a76a912b122b898ad24f36bda38e71c", - "date": "2026-03-29 08:28:54 +0200", - "message": "docs: update CLAUDE.md with voice polish changes, mark TODOS complete", - "type": "docs" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "effa9a69e2cb157facc671965e1b2736943a6f08", - "date": "2026-03-28 21:09:00 +0100", - "message": "docs: update CLAUDE.md with observability features and new files", - "type": "docs" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "f15db645c7f79886196b61695f5a8827c85ce86e", - "date": "2026-03-28 12:23:29 +0100", - "message": "docs: update CLAUDE.md with settings panel redesign and accent color fix", - "type": "docs" - }, - { - "hash": "435f00964728e747e159640793e4ca7fb358fd79", - "date": "2026-03-28 11:30:02 +0100", - "message": "docs: update CLAUDE.md with auto-login, health endpoint, sidebar changes", - "type": "docs" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "534e993d965a8be91e0033d39529602ed2da2ee4", - "date": "2026-03-27 16:17:18 +0100", - "message": "docs: update CLAUDE.md with voice timer, accent restore, DM auth rule", - "type": "docs" - }, - { - "hash": "35ac3a268485d16598737d1004c800abd813fa5d", - "date": "2026-03-27 15:02:09 +0100", - "message": "docs: add DM system, unified sidebar, themes, and quick-switch features", - "type": "docs" - }, - { - "hash": "568237ef7fa1c941edc8209d34510d97478edac8", - "date": "2026-03-27 13:41:58 +0100", - "message": "docs: add DM system design spec", - "type": "docs" - }, - { - "hash": "21e92ec1ec9051b585c764b0adfd4cddbe0625e7", - "date": "2026-03-27 10:52:16 +0100", - "message": "docs: add connection quality indicator to CLAUDE.md features + lib listing", - "type": "docs" - }, - { - "hash": "c838e92d87a6d1a3248c211325c112580bb8fbed", - "date": "2026-03-27 10:47:06 +0100", - "message": "chore: add .superpowers/ to gitignore", - "type": "chore" - }, - { - "hash": "03c8acd2213343bab99c2d72d46a61e0f6359d43", - "date": "2026-03-26 22:13:47 +0100", - "message": "t", - "type": "other" - }, - { - "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", - "date": "2026-03-26 22:09:56 +0100", - "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", - "type": "chore" - }, - { - "hash": "21fe4b8991ed63b884e71385fa82bae324bac551", - "date": "2026-03-26 18:39:50 +0100", - "message": "docs: add specs for screenshare audio and video focus mode", - "type": "docs" - }, - { - "hash": "d1cce204f1f09c27c922cf6fff9d8d1b7ce1c59d", - "date": "2026-03-25 17:29:34 +0100", - "message": "chore: gitignore .claude-flow/ and .mcp.json", - "type": "chore" - }, - { - "hash": "019cafd90a1dc07a0b9ed011bebc27f1fb99a847", - "date": "2026-03-24 21:42:16 +0100", - "message": "docs: sync documentation with codebase — version alignment, config fixes, test scripts", - "type": "docs" - }, - { - "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", - "date": "2026-03-22 21:10:02 +0100", - "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", - "type": "fix" - }, - { - "hash": "25625cfd6ec3527247fb5c134d0509a067cbe51e", - "date": "2026-03-22 19:30:11 +0100", - "message": "docs: add voice metrics and DESIGN.md TODOs from review", - "type": "docs" - }, - { - "hash": "9949e9de9ea8e84a6b8448bc78e04df83cb392be", - "date": "2026-03-22 15:34:26 +0100", - "message": "fix: restore audio track attachment for remote playback", - "type": "fix" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "fc63aec2f9294c6898f9afe4b8b6b7c6c69ece2d", - "date": "2026-03-20 05:50:14 +0100", - "message": "docs: update specs for LiveKit migration (Phase 4)", - "type": "docs" - }, - { - "hash": "c39bd09c714f650650847899bf70ccb9cc53162c", - "date": "2026-03-19 19:47:32 +0100", - "message": "fix: pass signing key to Tauri build step in CI", - "type": "fix" - }, - { - "hash": "bbbe983230d0a807c9e115c9c2c0dfb8b4b2ee1f", - "date": "2026-03-19 18:19:51 +0100", - "message": "chore: restrict Dependabot to minor/patch bumps only", - "type": "chore" - }, - { - "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", - "date": "2026-03-19 17:54:13 +0100", - "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", - "type": "docs" - }, - { - "hash": "d9a33b6c4e727d8205bf6020978445f4df558eb9", - "date": "2026-03-19 05:40:22 +0100", - "message": "docs: update README and CLAUDE.md for v1.0.0 release", - "type": "docs" - }, - { - "hash": "f8c54a7ba5cafe29f28bec19b855b3582d3e1b6c", - "date": "2026-03-18 23:09:52 +0100", - "message": "docs: update README with voice NAT traversal, per-user volume, noise suppression", - "type": "docs" - }, - { - "hash": "def313beb1389cb117d330dc83b29e72ce9dcbdc", - "date": "2026-03-18 23:03:14 +0100", - "message": "chore: add chatserver.exe~ to gitignore", - "type": "chore" - }, - { - "hash": "8fd8d3e91ecebd6d03420133a0d5e6b739ac8d29", - "date": "2026-03-18 17:56:03 +0100", - "message": "docs: rewrite README with current features, architecture, and config", - "type": "docs" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", - "date": "2026-03-18 07:03:53 +0100", - "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", - "type": "fix" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "1914067941b8512b895e32075fe2ced0b3da3841", - "date": "2026-03-17 03:20:37 +0100", - "message": "fix: address PR review findings (issues #3-#8)", - "type": "fix" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "264b4f0f65fccc8ec7ec8fa9e8c61a10284a3cb0", - "date": "2026-03-17 02:52:49 +0100", - "message": "chore: clean up remaining WPF artifacts and track missing files", - "type": "chore" - }, - { - "hash": "5c4a232bde25f75b72cbe338d530e15d2631f430", - "date": "2026-03-17 02:49:45 +0100", - "message": "chore: remove legacy WPF client code and references", - "type": "chore" - }, - { - "hash": "fb4b57743d63e0f783a839dc1f14e97cbcc28d4a", - "date": "2026-03-17 02:45:44 +0100", - "message": "ci: add GitHub best practices config (templates, dependabot, CI optimization)", - "type": "other" - }, - { - "hash": "e6247cde5b8c3ee2078938f4337edb3dc095256c", - "date": "2026-03-17 02:34:01 +0100", - "message": "test: complete E2E improvement phases 4-6", - "type": "test" - }, - { - "hash": "4dc61a506f18398c424d04eb6dbf31d44a5cbc30", - "date": "2026-03-17 02:25:49 +0100", - "message": "feat: add virtual scrolling to MessageList for large channels", - "type": "feat" - }, - { - "hash": "ceba8b9cf0401b8d07a3412d9e16dbff46f10236", - "date": "2026-03-17 02:21:41 +0100", - "message": "docs: mark TODOS #9 (split oversized files) as complete", - "type": "docs" - }, - { - "hash": "ff0f761b121d6b14e52b635ade04b328a029c41c", - "date": "2026-03-17 02:09:46 +0100", - "message": "\"Claude Code Review workflow\"", - "type": "other" - }, - { - "hash": "0b9f929d081d8d7d8422084d920633b6bff08168", - "date": "2026-03-17 02:09:45 +0100", - "message": "\"Claude PR Assistant workflow\"", - "type": "other" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "08049e7275f87f55321024cc2efbee37820bb493", - "date": "2026-03-16 17:11:17 +0100", - "message": "docs: align API.md paths to /api/v1/, fix CLAUDE.md contradiction, mark review resolved", - "type": "docs" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - }, - { - "hash": "743a2d974738f76db7764bdd43e78fbf25889d4f", - "date": "2026-03-15 16:54:55 +0100", - "message": "chore: update .gitignore to exclude local tooling, build artifacts, and internal docs", - "type": "chore" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "7e9a7bbf623a188879b21dc5a792577231a83c27", - "date": "2026-03-15 07:09:01 +0100", - "message": "chore: gitignore Claude Code local config and research notes", - "type": "chore" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", - "date": "2026-03-15 00:31:39 +0100", - "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", - "type": "feat" - }, - { - "hash": "07636447ab328ced8a3561a1446df76561259010", - "date": "2026-03-14 22:38:08 +0100", - "message": "chore: gitignore server runtime artifacts (binary, config, data)", - "type": "chore" - }, - { - "hash": "03bea4690bf9d4cd16dbf385eb559f44e696ded6", - "date": "2026-03-14 22:11:42 +0100", - "message": "chore: add .claude/settings.local.json to gitignore", - "type": "chore" - }, - { - "hash": "fb6b56e87686550da1f07387e6052bdc1efe6e5e", - "date": "2026-03-14 22:09:11 +0100", - "message": "docs: add Phase 7 distribution and updates design spec", - "type": "docs" - }, - { - "hash": "9083599730b58b84e6b8daf9320a515b94e440de", - "date": "2026-03-14 22:03:12 +0100", - "message": "docs: add server_restart message type and update endpoints to specs", - "type": "docs" - }, - { - "hash": "065715abf5979f1bf43cbf928153ef7947b2bd73", - "date": "2026-03-14 21:58:53 +0100", - "message": "docs: add README, SECURITY, CONTRIBUTING, and setup guides", - "type": "docs" - }, - { - "hash": "95dc1bc1d1734f0664a4848867eeeaf6e8989f64", - "date": "2026-03-14 21:58:06 +0100", - "message": "ci: add GitHub Actions CI and release workflows", - "type": "other" - }, - { - "hash": "a0da1e82c72368dfef08cf54731387ed937c5ebb", - "date": "2026-03-14 19:57:39 +0100", - "message": "chore: initial commit with project specs and configuration", - "type": "chore" - } - ] - }, - "ts:config": { - "count": 110, - "lastCommit": "2026-03-30 21:54:24 +0200", - "commits": [ - { - "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", - "date": "2026-03-30 21:54:24 +0200", - "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", - "type": "fix" - }, - { - "hash": "ad7ac75c74d0351d37e0eded5e4594528041866d", - "date": "2026-03-30 20:50:44 +0200", - "message": "docs: v1.0.0 release prep — version bump, license, README overhaul", - "type": "docs" - }, - { - "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", - "date": "2026-03-30 20:13:34 +0200", - "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", - "type": "feat" - }, - { - "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", - "date": "2026-03-30 19:12:36 +0200", - "message": "feat: sidebar stream preview + screenshare focus fix", - "type": "feat" - }, - { - "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", - "date": "2026-03-30 16:35:02 +0200", - "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", - "type": "test" - }, - { - "hash": "4ffa0c5d4755545fb4146fe62d93b7c4bf8c5260", - "date": "2026-03-30 14:23:31 +0200", - "message": "fix: add missing getFloatTimeDomainData mock to silence VAD timer errors", - "type": "fix" - }, - { - "hash": "0b22f073945487c390cf71143061fbfd5812225a", - "date": "2026-03-30 14:20:05 +0200", - "message": "test: Phase 4 coverage — meaningful behavior tests push client to 95%", - "type": "test" - }, - { - "hash": "78d2e70ccea3cb7eff8de19c89618ba38b9205dc", - "date": "2026-03-30 13:59:47 +0200", - "message": "test: Phase 3 coverage — 22 files bumped from 70-92% to 95-100%", - "type": "test" - }, - { - "hash": "5b6d6d4ba24c008ee920b3b03bc0bc4d90784cbd", - "date": "2026-03-30 13:38:20 +0200", - "message": "test: Phase 2 coverage — below-70% files now at 95-100%", - "type": "test" - }, - { - "hash": "414efdbd28d9282acebfaabb652aea54f23e8525", - "date": "2026-03-30 13:21:29 +0200", - "message": "test: Phase 1 coverage — 10 zero-coverage files now at 95-100%", - "type": "test" - }, - { - "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", - "date": "2026-03-30 12:42:31 +0200", - "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", - "type": "fix" - }, - { - "hash": "23b62a964c798ca99996be4de65e210a05b1af02", - "date": "2026-03-29 21:34:54 +0200", - "message": "fix: TypeScript build errors in embeds.ts and totp-settings test", - "type": "fix" - }, - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", - "date": "2026-03-29 19:40:11 +0200", - "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", - "type": "chore" - }, - { - "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", - "date": "2026-03-29 19:39:22 +0200", - "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", - "type": "fix" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "829a8b0efddabb52e58b9e8bf8a7324be30d0cea", - "date": "2026-03-29 10:13:04 +0200", - "message": "fix: repair 14 failing E2E tests and expand voice lifecycle coverage", - "type": "fix" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", - "date": "2026-03-28 20:42:37 +0100", - "message": "feat: add observability, debugging, and diagnostics across all layers", - "type": "feat" - }, - { - "hash": "57c35c2b6821a3f43297295b2e4edc5cb9943433", - "date": "2026-03-28 18:55:11 +0100", - "message": "fix: CI failures — correct chat delete test expectations and coverage threshold", - "type": "fix" - }, - { - "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", - "date": "2026-03-28 18:23:18 +0100", - "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", - "type": "fix" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "9636d0e9761b69b762ef869a5eaa1eb65cbb1320", - "date": "2026-03-27 14:25:54 +0100", - "message": "fix: DM header shows @ instead of #, DM welcome message, DM appears in sidebar list", - "type": "fix" - }, - { - "hash": "6858c67408f19f973ac02f8658522825db85646e", - "date": "2026-03-27 13:10:21 +0100", - "message": "chore: stage QuickSwitchOverlay files missed from earlier commit", - "type": "chore" - }, - { - "hash": "cfcc76760d0408115971b4224ebcd6e2644301c1", - "date": "2026-03-27 12:56:40 +0100", - "message": "feat: add member list header/resize, DM section in sidebar, remove member toggle from chat header", - "type": "feat" - }, - { - "hash": "2829220f06432cc6ae7e502059b898de79b12357", - "date": "2026-03-27 12:22:22 +0100", - "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", - "type": "fix" - }, - { - "hash": "00a92851d4e2e92ed9412825fb2a70292770b889", - "date": "2026-03-27 12:09:38 +0100", - "message": "refactor: remove ServerStrip component and CSS (replaced by unified sidebar)", - "type": "refactor" - }, - { - "hash": "e6b5d43e669b1aa1ec4bfccf3aa4a235dc8980f0", - "date": "2026-03-27 11:37:57 +0100", - "message": "feat: add theme manager with built-in + custom theme support", - "type": "feat" - }, - { - "hash": "fbbb87229e13e2247bbe6d2afd91c8ac753188c3", - "date": "2026-03-27 11:34:45 +0100", - "message": "fix: add sidebarMode/activeDmUserId to test UiState mocks", - "type": "fix" - }, - { - "hash": "7be56f95458b31c8435ce50ef52d60bd1820639b", - "date": "2026-03-27 11:29:23 +0100", - "message": "feat: add sidebarMode and activeDmUserId to UI store", - "type": "feat" - }, - { - "hash": "bdff04fd080b5e9a2bd9f4371db4a1e94d9ae146", - "date": "2026-03-27 10:25:40 +0100", - "message": "fix: regenerate ICO with proper multi-size PNG entries (7 sizes)", - "type": "fix" - }, - { - "hash": "9c38bacddb19c0354248b6403b99de15b08befb1", - "date": "2026-03-27 10:09:53 +0100", - "message": "feat: replace app icon with OC neon glow logo", - "type": "feat" - }, - { - "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", - "date": "2026-03-27 08:05:32 +0100", - "message": "fix: client code review — 27 fixes across 17 files", - "type": "fix" - }, - { - "hash": "2bc47efc6e4d3d10b091a98be60bdeb0e5eed6f6", - "date": "2026-03-26 22:10:49 +0100", - "message": "feat: add RNNoise worklet TypeScript source", - "type": "feat" - }, - { - "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", - "date": "2026-03-26 22:09:56 +0100", - "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", - "type": "chore" - }, - { - "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", - "date": "2026-03-26 22:07:52 +0100", - "message": "feat: stream quality presets, nuclear mute, sidebar width fix", - "type": "feat" - }, - { - "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", - "date": "2026-03-26 20:53:09 +0100", - "message": "fix: voice mute pipeline, security hardening, and video tile controls", - "type": "fix" - }, - { - "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", - "date": "2026-03-26 18:07:20 +0100", - "message": "feat: full screenshare support — button state, video grid, auto-reconnect", - "type": "feat" - }, - { - "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", - "date": "2026-03-25 19:07:52 +0100", - "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", - "date": "2026-03-22 21:10:02 +0100", - "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", - "type": "fix" - }, - { - "hash": "73a757b47f425fca0c5c6da9748c4fc032d1ec04", - "date": "2026-03-22 20:14:27 +0100", - "message": "fix: resolve TS2556 spread argument error in livekit-session test", - "type": "fix" - }, - { - "hash": "076402ab010e0eb1659cb776bd8bee8417d31e48", - "date": "2026-03-22 20:06:59 +0100", - "message": "fix: resolve CI failures — lint errors and coverage threshold", - "type": "fix" - }, - { - "hash": "067597a507a417d4128c5f7dabcb705681292f6c", - "date": "2026-03-22 19:30:02 +0100", - "message": "test: fix 4 pre-existing test failures for updated settings UI", - "type": "test" - }, - { - "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", - "date": "2026-03-22 10:04:49 +0100", - "message": "feat: add user status selector to Account settings tab", - "type": "feat" - }, - { - "hash": "c3a13d09a142bcf4b233a278c9178d0f05fc3cfa", - "date": "2026-03-22 09:30:03 +0100", - "message": "fix: address review issues in settings redesign", - "type": "fix" - }, - { - "hash": "32faf87b3c5c67bf730f587921373dfb7e1f3d75", - "date": "2026-03-22 08:26:21 +0100", - "message": "test: update invite manager selectors for modal redesign", - "type": "test" - }, - { - "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", - "date": "2026-03-21 23:33:18 +0100", - "message": "feat: replace all emoji icons with Lucide SVG icons", - "type": "feat" - }, - { - "hash": "30a6c7692a1a6b7ca28a88bc135159574c24b7d5", - "date": "2026-03-21 22:00:40 +0100", - "message": "test: update pinned messages tests for Discord-style panel", - "type": "test" - }, - { - "hash": "3365567063ad8d400f4e781300d4cc44b4fd496d", - "date": "2026-03-21 21:24:29 +0100", - "message": "fix: add missing channelName to test createMessageList calls", - "type": "fix" - }, - { - "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", - "date": "2026-03-21 21:08:58 +0100", - "message": "feat: add Discord-style welcome state for empty channels", - "type": "feat" - }, - { - "hash": "6ee3698525b22e2471c05e9e0addb8b42e963470", - "date": "2026-03-21 20:53:47 +0100", - "message": "fix: prevent virtual scroll rebuild loops and improve image height caching", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", - "date": "2026-03-20 07:11:45 +0100", - "message": "fix: speaking ring, devtools, CSP, and connection fixes", - "type": "fix" - }, - { - "hash": "91ebce72727a54fb96c72dadeb257bd92124ef4f", - "date": "2026-03-20 06:33:57 +0100", - "message": "fix: wire LiveKit speaker detection to voice activation ring", - "type": "fix" - }, - { - "hash": "ff2b82280c6002a5363f25af434e1898b15322ca", - "date": "2026-03-20 06:27:43 +0100", - "message": "fix: resolve LiveKit connection issues", - "type": "fix" - }, - { - "hash": "24c6b54c49b2041e3dcef0cd8add2502931df32f", - "date": "2026-03-20 05:47:11 +0100", - "message": "fix: update settings overlay test for 4 audio toggles", - "type": "fix" - }, - { - "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", - "date": "2026-03-20 05:46:19 +0100", - "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", - "type": "feat" - }, - { - "hash": "bd307eb41c112d51926b6d1324a4464b32da1002", - "date": "2026-03-20 05:07:37 +0100", - "message": "chore: bump version to 1.2.0 for LiveKit migration", - "type": "chore" - }, - { - "hash": "17600081dfef4d5bccf6777279559599acfc1eff", - "date": "2026-03-20 02:00:29 +0100", - "message": "feat: wire search bar to server FTS5 search API (T-065)", - "type": "feat" - }, - { - "hash": "f11a53913ffb192b6d2978834e793d1053e45d27", - "date": "2026-03-20 01:33:43 +0100", - "message": "refactor: extract MainPage into focused controllers with tests", - "type": "refactor" - }, - { - "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", - "date": "2026-03-19 21:35:18 +0100", - "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", - "type": "fix" - }, - { - "hash": "b387f1880ae8997b745a8054cfd9a24319cebf3c", - "date": "2026-03-19 19:22:42 +0100", - "message": "update gitignore", - "type": "other" - }, - { - "hash": "4505c8d4648e5611907cef68cca9a7a4a13ce886", - "date": "2026-03-19 19:22:23 +0100", - "message": "chore: update Tauri updater public key for v1.1.0 signing keypair", - "type": "chore" - }, - { - "hash": "0f3c9e578279b62182a8d61727ae15dba8cfed71", - "date": "2026-03-19 18:44:29 +0100", - "message": "chore: bump version to 1.1.0", - "type": "chore" - }, - { - "hash": "ec3d7bee921ce202e26aa2cde7cc40a43ae4a8bd", - "date": "2026-03-19 18:25:49 +0100", - "message": "test: add coverage for GIF picker, Tenor API, PTT, and image rendering", - "type": "test" - }, - { - "hash": "445da87055685485522399a4bdcb47f477c8d1d6", - "date": "2026-03-19 18:06:07 +0100", - "message": "fix: add ResizeObserver stub to message list tests", - "type": "fix" - }, - { - "hash": "c6e864580946b4017e67182c777b19936f436458", - "date": "2026-03-19 17:43:32 +0100", - "message": "fix: stop camera preview and mic meter when settings overlay closes", - "type": "fix" - }, - { - "hash": "3010750b6f9dfe171bad7f13be0be2da7dd4ea3e", - "date": "2026-03-19 16:51:49 +0100", - "message": "test: add unit tests for voice session camera lifecycle", - "type": "test" - }, - { - "hash": "9ad91c6393d28dc8b75e9b229b9b4b46bb1386bb", - "date": "2026-03-19 16:47:18 +0100", - "message": "feat: add webcam device selector and preview to settings", - "type": "feat" - }, - { - "hash": "81b45d55e312c7f6a4085073ffbc8842d7c5bd6f", - "date": "2026-03-19 16:39:35 +0100", - "message": "feat: add VideoGrid component for rendering camera streams", - "type": "feat" - }, - { - "hash": "5b10bc28f323b8b2365036de84a72f5d2897e3bc", - "date": "2026-03-19 16:39:29 +0100", - "message": "feat: add video device manager for camera capture", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "4ac2167570ec73ebb0f8f4ed2b5462bba2e8961e", - "date": "2026-03-19 07:41:58 +0100", - "message": "fix: use v1Compatible for createUpdaterArtifacts (v2Compatible not valid)", - "type": "fix" - }, - { - "hash": "d4fd9e3db26e4ecf6a09600476079a1a2f207aae", - "date": "2026-03-19 07:33:59 +0100", - "message": "fix: enable Tauri updater artifacts and bump client to v1.0.0", - "type": "fix" - }, - { - "hash": "9f5be0819df487fff856a9237fa8ecc8131ce44a", - "date": "2026-03-19 06:28:08 +0100", - "message": "fix: adjust coverage thresholds and exclude Tauri-runtime files", - "type": "fix" - }, - { - "hash": "22706a795581a003059a9838ace4ac05d43fefb2", - "date": "2026-03-19 06:20:00 +0100", - "message": "fix: resolve CI failures in server lint and client tests", - "type": "fix" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", - "date": "2026-03-18 17:10:16 +0100", - "message": "feat: native file downloads, upload size fix, native E2E tests", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "a37abb47a266fca465ebaaa6866308f3868df373", - "date": "2026-03-18 11:33:59 +0100", - "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", - "type": "fix" - }, - { - "hash": "9c63dd6efed184908b192fe60162683019a89d5c", - "date": "2026-03-18 11:28:13 +0100", - "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", - "type": "feat" - }, - { - "hash": "498d34a3705a11293738c4ba07ef3c1d6eb35886", - "date": "2026-03-18 07:09:45 +0100", - "message": "fix: add missing localCamera/localScreenshare to VoiceState resets in tests", - "type": "fix" - }, - { - "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", - "date": "2026-03-18 07:03:53 +0100", - "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", - "type": "fix" - }, - { - "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", - "date": "2026-03-18 06:45:05 +0100", - "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", - "type": "fix" - }, - { - "hash": "d3d1e067db271a199c641771604f55e05acd809c", - "date": "2026-03-18 06:40:12 +0100", - "message": "fix: render actual images for attachments, remove orphaned components (BUG-026, BUG-030)", - "type": "fix" - }, - { - "hash": "c1e2733f11a746aef74897bed712f69693e72acb", - "date": "2026-03-18 06:28:45 +0100", - "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", - "type": "fix" - }, - { - "hash": "c49a8bc4d63beba7900459981cdb152f77b6624c", - "date": "2026-03-18 05:38:53 +0100", - "message": "fix: harden E2E tests with anti-flakiness config and voice widget selector fixes", - "type": "fix" - }, - { - "hash": "bc12273ab78155ba942b2d9296736cec8254326b", - "date": "2026-03-18 04:30:38 +0100", - "message": "fix: resolve CI failures — coverage exclusion and stale test removal", - "type": "fix" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", - "date": "2026-03-17 20:03:34 +0100", - "message": "fix: voice channel join/leave visibility and disconnect bugs", - "type": "fix" - }, - { - "hash": "17eb67811f7844324d6703bfddce0d7ae8996267", - "date": "2026-03-17 14:01:26 +0000", - "message": "fix: resolve CI failures — errcheck lint and TS noUncheckedIndexedAccess errors", - "type": "fix" - }, - { - "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", - "date": "2026-03-17 14:54:50 +0100", - "message": "fix: address PR #15 review issues (#16-#23)", - "type": "fix" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "264b4f0f65fccc8ec7ec8fa9e8c61a10284a3cb0", - "date": "2026-03-17 02:52:49 +0100", - "message": "chore: clean up remaining WPF artifacts and track missing files", - "type": "chore" - }, - { - "hash": "e6247cde5b8c3ee2078938f4337edb3dc095256c", - "date": "2026-03-17 02:34:01 +0100", - "message": "test: complete E2E improvement phases 4-6", - "type": "test" - }, - { - "hash": "4dc61a506f18398c424d04eb6dbf31d44a5cbc30", - "date": "2026-03-17 02:25:49 +0100", - "message": "feat: add virtual scrolling to MessageList for large channels", - "type": "feat" - }, - { - "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", - "date": "2026-03-17 02:17:26 +0100", - "message": "refactor: split oversized files + add store notification batching", - "type": "refactor" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "eef229a74fcf70acf92f385f6386d27af32436ac", - "date": "2026-03-15 20:46:38 +0100", - "message": "test: add test helpers and integration tests for dispatcher + stores", - "type": "test" - }, - { - "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", - "date": "2026-03-15 20:43:13 +0100", - "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "go:admin": { - "count": 27, - "lastCommit": "2026-03-30 21:54:24 +0200", - "commits": [ - { - "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", - "date": "2026-03-30 21:54:24 +0200", - "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", - "type": "fix" - }, - { - "hash": "d9aaeb5f4c8c018f5ffbd9c64ae87126e5b5348a", - "date": "2026-03-30 21:48:14 +0200", - "message": "fix: admin panel CSP blocking inline event handlers and boolean toggle display", - "type": "fix" - }, - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "97f186041da8052d054cff73c93f23886bd374d5", - "date": "2026-03-29 19:39:46 +0200", - "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", - "type": "refactor" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "b15738c4d80f4983f31d1076e8aaff07171a0a7c", - "date": "2026-03-19 05:32:40 +0100", - "message": "feat: redesign admin panel, add live server logs and audit log filters", - "type": "feat" - }, - { - "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", - "date": "2026-03-19 04:19:03 +0100", - "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", - "type": "fix" - }, - { - "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", - "date": "2026-03-19 04:04:53 +0100", - "message": "fix: resolve 6 medium issues from full go-review", - "type": "fix" - }, - { - "hash": "9c63dd6efed184908b192fe60162683019a89d5c", - "date": "2026-03-18 11:28:13 +0100", - "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", - "type": "feat" - }, - { - "hash": "f8ac121cfd684c241fe2d659bb4a7ec1987c96c6", - "date": "2026-03-18 05:15:54 +0100", - "message": "fix: add nil hub tests for PatchUser ban and role change paths (BUG-001)", - "type": "fix" - }, - { - "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", - "date": "2026-03-17 14:54:50 +0100", - "message": "fix: address PR #15 review issues (#16-#23)", - "type": "fix" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "1914067941b8512b895e32075fe2ced0b3da3841", - "date": "2026-03-17 03:20:37 +0100", - "message": "fix: address PR review findings (issues #3-#8)", - "type": "fix" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", - "date": "2026-03-17 02:17:26 +0100", - "message": "refactor: split oversized files + add store notification batching", - "type": "refactor" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", - "date": "2026-03-15 00:31:39 +0100", - "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", - "type": "feat" - }, - { - "hash": "61dc275a1545f772753bee1585ac8b030d8cae1a", - "date": "2026-03-14 22:36:35 +0100", - "message": "feat: add setup wizard for initial owner account creation", - "type": "feat" - }, - { - "hash": "40c269bc7a60ccfa0ec52c8524834491c554ec67", - "date": "2026-03-14 22:06:54 +0100", - "message": "feat: add update notification banner to admin dashboard", - "type": "feat" - }, - { - "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", - "date": "2026-03-14 22:05:13 +0100", - "message": "feat: implement server auto-update API endpoints with download, verify, and restart", - "type": "feat" - }, - { - "hash": "dcae0b91b23c178e3b747cdf25176c8d09ffd103", - "date": "2026-03-14 21:37:47 +0100", - "message": "fix: correct embed path (static not admin/static) and simplify audit_log migration", - "type": "fix" - }, - { - "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", - "date": "2026-03-14 21:31:03 +0100", - "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:db": { - "count": 44, - "lastCommit": "2026-03-30 21:54:24 +0200", - "commits": [ - { - "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", - "date": "2026-03-30 21:54:24 +0200", - "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", - "type": "fix" - }, - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "97f186041da8052d054cff73c93f23886bd374d5", - "date": "2026-03-29 19:39:46 +0200", - "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", - "type": "refactor" - }, - { - "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", - "date": "2026-03-29 19:39:22 +0200", - "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", - "type": "fix" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "3dff9fef64db54cdfe153d48f46d9cc7d7bac9d0", - "date": "2026-03-27 13:58:48 +0100", - "message": "feat(server): add DM REST endpoints, WebSocket routing, and ready payload", - "type": "feat" - }, - { - "hash": "c8d547ce15643085eaed6e65c33044eef098f427", - "date": "2026-03-27 13:46:06 +0100", - "message": "feat(server): add DM schema migration and database query layer", - "type": "feat" - }, - { - "hash": "738f4970576467e285c051549a4d78241966958c", - "date": "2026-03-24 21:35:40 +0100", - "message": "fix: address code review — 8 issues across server and client", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "f52e4d7e49852cf3cbae2ba789a1b382dcc6f1c8", - "date": "2026-03-21 21:42:14 +0100", - "message": "refactor: deduplicate pin handler and DB scan logic", - "type": "refactor" - }, - { - "hash": "85ef4f0d1d8d9e6b9eb2550f4d0b9b74542b792a", - "date": "2026-03-21 21:36:10 +0100", - "message": "feat: add server-side pin/unpin endpoints", - "type": "feat" - }, - { - "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", - "date": "2026-03-21 12:34:31 +0100", - "message": "fix: virtual scroll jumping with images/GIFs", - "type": "fix" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", - "date": "2026-03-19 21:35:18 +0100", - "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", - "type": "fix" - }, - { - "hash": "aa713390e4669998f964cc11dec0ddc8c3f443cc", - "date": "2026-03-19 04:27:11 +0100", - "message": "fix: add rows.Err() check in GetAttachmentsByMessageIDs", - "type": "fix" - }, - { - "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", - "date": "2026-03-19 04:19:03 +0100", - "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", - "type": "fix" - }, - { - "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", - "date": "2026-03-19 04:04:53 +0100", - "message": "fix: resolve 6 medium issues from full go-review", - "type": "fix" - }, - { - "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", - "date": "2026-03-19 03:53:40 +0100", - "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", - "type": "fix" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", - "date": "2026-03-18 05:03:38 +0100", - "message": "test: boost server test coverage to 80%+ across all packages", - "type": "test" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", - "date": "2026-03-17 14:54:50 +0100", - "message": "fix: address PR #15 review issues (#16-#23)", - "type": "fix" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "1914067941b8512b895e32075fe2ced0b3da3841", - "date": "2026-03-17 03:20:37 +0100", - "message": "fix: address PR review findings (issues #3-#8)", - "type": "fix" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "642cd541c781649698799245a6a3d0f4a8ae0a02", - "date": "2026-03-16 17:00:09 +0100", - "message": "feat: add attachment persistence and link on chat_send (High #3)", - "type": "feat" - }, - { - "hash": "0bd9165f61f361a5acb9fb85ffd0e3cf8aca6d29", - "date": "2026-03-16 16:57:01 +0100", - "message": "feat: align REST responses with API.md spec (High #1)", - "type": "feat" - }, - { - "hash": "0680a32496e69f60a21d76a80d2a2d40244fe805", - "date": "2026-03-16 16:54:56 +0100", - "message": "fix: resolve 4 Critical + 2 High + 1 Medium server protocol violations", - "type": "fix" - }, - { - "hash": "aa194f45b39c17f9bf74868941f3e31dd5bf73e4", - "date": "2026-03-15 12:21:10 +0100", - "message": "fix: prevent stale \"online\" status for users not connected via WebSocket", - "type": "fix" - }, - { - "hash": "c56244366097c8b44013e130994360d421a50c01", - "date": "2026-03-15 12:18:19 +0100", - "message": "fix: add JSON tags to Role/VoiceState, fix WebSocket error surfacing", - "type": "fix" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", - "date": "2026-03-15 00:31:39 +0100", - "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", - "type": "feat" - }, - { - "hash": "61dc275a1545f772753bee1585ac8b030d8cae1a", - "date": "2026-03-14 22:36:35 +0100", - "message": "feat: add setup wizard for initial owner account creation", - "type": "feat" - }, - { - "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", - "date": "2026-03-14 21:31:03 +0100", - "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", - "type": "feat" - }, - { - "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", - "date": "2026-03-14 21:17:09 +0100", - "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", - "type": "feat" - }, - { - "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", - "date": "2026-03-14 20:52:11 +0100", - "message": "feat: implement Phase 2 auth & security with TDD", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:scripts": { - "count": 3, - "lastCommit": "2026-03-30 21:54:24 +0200", - "commits": [ - { - "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", - "date": "2026-03-30 21:54:24 +0200", - "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - } - ] - }, - "go:ws": { - "count": 72, - "lastCommit": "2026-03-30 21:54:24 +0200", - "commits": [ - { - "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", - "date": "2026-03-30 21:54:24 +0200", - "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", - "type": "fix" - }, - { - "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", - "date": "2026-03-30 16:35:02 +0200", - "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", - "type": "test" - }, - { - "hash": "97f186041da8052d054cff73c93f23886bd374d5", - "date": "2026-03-29 19:39:46 +0200", - "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", - "type": "refactor" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", - "date": "2026-03-28 20:42:37 +0100", - "message": "feat: add observability, debugging, and diagnostics across all layers", - "type": "feat" - }, - { - "hash": "57c35c2b6821a3f43297295b2e4edc5cb9943433", - "date": "2026-03-28 18:55:11 +0100", - "message": "fix: CI failures — correct chat delete test expectations and coverage threshold", - "type": "fix" - }, - { - "hash": "26b3f954bf0e165f601f5b562f0c76eb47afb828", - "date": "2026-03-28 18:43:23 +0100", - "message": "chore: remove unused dmChannelClosePayload and buildDMChannelClose", - "type": "chore" - }, - { - "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", - "date": "2026-03-28 18:23:18 +0100", - "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", - "type": "fix" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "3dff9fef64db54cdfe153d48f46d9cc7d7bac9d0", - "date": "2026-03-27 13:58:48 +0100", - "message": "feat(server): add DM REST endpoints, WebSocket routing, and ready payload", - "type": "feat" - }, - { - "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", - "date": "2026-03-26 20:53:09 +0100", - "message": "fix: voice mute pipeline, security hardening, and video tile controls", - "type": "fix" - }, - { - "hash": "30b5c0203971be61594535a3df9e58044c2c0648", - "date": "2026-03-25 17:28:14 +0100", - "message": "fix: remove invalid active_loopback_prevention from LiveKit config", - "type": "fix" - }, - { - "hash": "738f4970576467e285c051549a4d78241966958c", - "date": "2026-03-24 21:35:40 +0100", - "message": "fix: address code review — 8 issues across server and client", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", - "date": "2026-03-24 20:23:40 +0100", - "message": "fix: address code review — security hardening, leak fixes, credential safety", - "type": "fix" - }, - { - "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", - "date": "2026-03-22 21:10:02 +0100", - "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", - "type": "fix" - }, - { - "hash": "076402ab010e0eb1659cb776bd8bee8417d31e48", - "date": "2026-03-22 20:06:59 +0100", - "message": "fix: resolve CI failures — lint errors and coverage threshold", - "type": "fix" - }, - { - "hash": "19aa699f17ecb3cadf770b70b15009dd8e1572fb", - "date": "2026-03-22 19:29:42 +0100", - "message": "fix: address CEO review findings — health check, error logging, camera guard, metrics, tests", - "type": "fix" - }, - { - "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", - "date": "2026-03-21 12:34:31 +0100", - "message": "fix: virtual scroll jumping with images/GIFs", - "type": "fix" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "ff2b82280c6002a5363f25af434e1898b15322ca", - "date": "2026-03-20 06:27:43 +0100", - "message": "fix: resolve LiveKit connection issues", - "type": "fix" - }, - { - "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", - "date": "2026-03-20 06:11:09 +0100", - "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", - "type": "fix" - }, - { - "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", - "date": "2026-03-20 05:34:38 +0100", - "message": "feat: add LiveKit webhook handler (Phase 1.5)", - "type": "feat" - }, - { - "hash": "a7b53d42f30bbdf99d13a943d0e3b24aab261eb2", - "date": "2026-03-20 05:30:27 +0100", - "message": "feat: rewrite server voice handlers for LiveKit (Phase 1)", - "type": "feat" - }, - { - "hash": "923d071a71342c771f3314744e81f5f16e8ce845", - "date": "2026-03-20 05:14:52 +0100", - "message": "feat: add LiveKit infrastructure (Phase 0)", - "type": "feat" - }, - { - "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", - "date": "2026-03-19 21:35:18 +0100", - "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", - "type": "fix" - }, - { - "hash": "92be1372292e3dc1fb342752bf326d87af6f2a57", - "date": "2026-03-19 17:13:59 +0100", - "message": "fix: use unique stream IDs for audio and video tracks to prevent dedup collision", - "type": "fix" - }, - { - "hash": "e16651093770e7920ad6e84df93d458ab72887ab", - "date": "2026-03-19 16:52:15 +0100", - "message": "test: add video track coexistence integration test", - "type": "test" - }, - { - "hash": "0be1250c71d5726eb44d7f4281e0d8535bac81d7", - "date": "2026-03-19 16:33:47 +0100", - "message": "feat: extend SFU to forward video tracks and enforce MaxVideo limit", - "type": "feat" - }, - { - "hash": "2bab20ea4f45aa10867280c4e86f89b4a0e428fe", - "date": "2026-03-19 16:25:12 +0100", - "message": "refactor: use composite track keys in VoiceRoom for multi-track support", - "type": "refactor" - }, - { - "hash": "22706a795581a003059a9838ace4ac05d43fefb2", - "date": "2026-03-19 06:20:00 +0100", - "message": "fix: resolve CI failures in server lint and client tests", - "type": "fix" - }, - { - "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", - "date": "2026-03-19 04:04:53 +0100", - "message": "fix: resolve 6 medium issues from full go-review", - "type": "fix" - }, - { - "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", - "date": "2026-03-19 03:53:40 +0100", - "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", - "type": "fix" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "a9b816cf2646f7aa6689812feb770d83af95c0e7", - "date": "2026-03-18 07:24:19 +0100", - "message": "fix: prevent stale PeerConnection callbacks from killing new voice sessions", - "type": "fix" - }, - { - "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", - "date": "2026-03-18 05:03:38 +0100", - "message": "test: boost server test coverage to 80%+ across all packages", - "type": "test" - }, - { - "hash": "bc12273ab78155ba942b2d9296736cec8254326b", - "date": "2026-03-18 04:30:38 +0100", - "message": "fix: resolve CI failures — coverage exclusion and stale test removal", - "type": "fix" - }, - { - "hash": "a97c4e928ce471ee92c836ad964d7b284cd49be1", - "date": "2026-03-18 04:16:49 +0100", - "message": "fix: guard ICE close handler to prevent double voice_leave and SQLITE_BUSY", - "type": "fix" - }, - { - "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", - "date": "2026-03-18 04:13:12 +0100", - "message": "fix: voice session cleanup on ICE close and connection failure", - "type": "fix" - }, - { - "hash": "74736439302946749bce01f4d23f38221315bf7f", - "date": "2026-03-18 03:53:22 +0100", - "message": "feat: wire SFU track forwarding and renegotiation in voice handlers", - "type": "feat" - }, - { - "hash": "fe4b5494d1e6cf00500a8d79ea6acc70e9f70181", - "date": "2026-03-18 03:49:23 +0100", - "message": "feat: add renegotiateParticipant with Perfect Negotiation", - "type": "feat" - }, - { - "hash": "62efd74a4dc2e7649b6a976c31bb8a6b0cf4efac", - "date": "2026-03-18 03:47:30 +0100", - "message": "feat: add GetClient helper and ICE candidate callback", - "type": "feat" - }, - { - "hash": "e3ee8a460a1470284446ebb646c5c45488e3a8c7", - "date": "2026-03-18 03:44:38 +0100", - "message": "feat: add buildVoiceOffer and buildVoiceICE message builders", - "type": "feat" - }, - { - "hash": "63ca0b8e00469754e13c978fee9eef8dfc499b83", - "date": "2026-03-18 03:41:48 +0100", - "message": "feat: add VoiceTrack struct and track CRUD to VoiceRoom", - "type": "feat" - }, - { - "hash": "00bbb46b62c3aab6e5791795c3602ed2a1f05ed8", - "date": "2026-03-18 02:41:55 +0100", - "message": "fix: reject duplicate WebSocket logins to prevent reconnect ping-pong", - "type": "fix" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", - "date": "2026-03-17 20:03:34 +0100", - "message": "fix: voice channel join/leave visibility and disconnect bugs", - "type": "fix" - }, - { - "hash": "17eb67811f7844324d6703bfddce0d7ae8996267", - "date": "2026-03-17 14:01:26 +0000", - "message": "fix: resolve CI failures — errcheck lint and TS noUncheckedIndexedAccess errors", - "type": "fix" - }, - { - "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", - "date": "2026-03-17 14:54:50 +0100", - "message": "fix: address PR #15 review issues (#16-#23)", - "type": "fix" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "1914067941b8512b895e32075fe2ced0b3da3841", - "date": "2026-03-17 03:20:37 +0100", - "message": "fix: address PR review findings (issues #3-#8)", - "type": "fix" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "46e811031e23a6631ab475926af034e35802903a", - "date": "2026-03-16 17:17:00 +0100", - "message": "test: add authorization and contract tests for channel access", - "type": "test" - }, - { - "hash": "642cd541c781649698799245a6a3d0f4a8ae0a02", - "date": "2026-03-16 17:00:09 +0100", - "message": "feat: add attachment persistence and link on chat_send (High #3)", - "type": "feat" - }, - { - "hash": "0680a32496e69f60a21d76a80d2a2d40244fe805", - "date": "2026-03-16 16:54:56 +0100", - "message": "fix: resolve 4 Critical + 2 High + 1 Medium server protocol violations", - "type": "fix" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", - "date": "2026-03-15 00:31:39 +0100", - "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", - "type": "feat" - }, - { - "hash": "09efb58697a546435cd3258f89249dbc97662254", - "date": "2026-03-14 21:58:18 +0100", - "message": "feat: add server_restart WebSocket message type for update notifications", - "type": "feat" - }, - { - "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", - "date": "2026-03-14 21:31:03 +0100", - "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", - "type": "feat" - }, - { - "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", - "date": "2026-03-14 21:17:09 +0100", - "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "client:other": { - "count": 15, - "lastCommit": "2026-03-30 21:05:28 +0200", - "commits": [ - { - "hash": "44390ad886be392b55d1a15995eba0fb9c4a80d1", - "date": "2026-03-30 21:05:28 +0200", - "message": "chore: clean up tracked files for v1.0.0 public release", - "type": "chore" - }, - { - "hash": "264b4f0f65fccc8ec7ec8fa9e8c61a10284a3cb0", - "date": "2026-03-17 02:52:49 +0100", - "message": "chore: clean up remaining WPF artifacts and track missing files", - "type": "chore" - }, - { - "hash": "5c4a232bde25f75b72cbe338d530e15d2631f430", - "date": "2026-03-17 02:49:45 +0100", - "message": "chore: remove legacy WPF client code and references", - "type": "chore" - }, - { - "hash": "743a2d974738f76db7764bdd43e78fbf25889d4f", - "date": "2026-03-15 16:54:55 +0100", - "message": "chore: update .gitignore to exclude local tooling, build artifacts, and internal docs", - "type": "chore" - }, - { - "hash": "c56244366097c8b44013e130994360d421a50c01", - "date": "2026-03-15 12:18:19 +0100", - "message": "fix: add JSON tags to Role/VoiceState, fix WebSocket error surfacing", - "type": "fix" - }, - { - "hash": "eac665ac88f3ce301b6ab523d507d934dc432777", - "date": "2026-03-15 12:00:28 +0100", - "message": "perf: fix O(n) allocations, freeze brushes, improve disposal and nullability", - "type": "perf" - }, - { - "hash": "86f95173051af898db3e623c746d2b37ca01e3c2", - "date": "2026-03-15 11:53:52 +0100", - "message": "fix: resolve critical TLS race, invisible messages, and 5 other review issues", - "type": "fix" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "32f2833eb458e9472c865439d3b2832967eab496", - "date": "2026-03-15 00:33:07 +0100", - "message": "chore: gitignore client publish output directories", - "type": "chore" - }, - { - "hash": "6de2764cf72a5eee45682bb9ab3a9b825220d683", - "date": "2026-03-15 00:31:56 +0100", - "message": "fix: include SavePassword/LoadPassword/DeletePassword in ICredentialService interface", - "type": "fix" - }, - { - "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", - "date": "2026-03-15 00:31:39 +0100", - "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", - "type": "feat" - }, - { - "hash": "a7ef09c69b9da0d70b60722826fb4f178d02da10", - "date": "2026-03-14 22:04:24 +0100", - "message": "feat: implement client auto-update with GitHub Release checking and update dialog", - "type": "feat" - }, - { - "hash": "94d0539f7f8d9065381e92bf69572ae6467eece7", - "date": "2026-03-14 21:07:25 +0100", - "message": "chore: add Client .gitignore, remove tracked build artifacts", - "type": "chore" - }, - { - "hash": "213c346bfaa292aaaca1eb81ef6b7a020ddf6417", - "date": "2026-03-14 21:07:07 +0100", - "message": "feat: scaffold Phase 3 WPF client shell with MVVM and TDD structure", - "type": "feat" - } - ] - }, - "ts:components": { - "count": 102, - "lastCommit": "2026-03-30 20:13:34 +0200", - "commits": [ - { - "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", - "date": "2026-03-30 20:13:34 +0200", - "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", - "type": "feat" - }, - { - "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", - "date": "2026-03-30 19:12:36 +0200", - "message": "feat: sidebar stream preview + screenshare focus fix", - "type": "feat" - }, - { - "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", - "date": "2026-03-30 12:42:31 +0200", - "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", - "type": "fix" - }, - { - "hash": "23b62a964c798ca99996be4de65e210a05b1af02", - "date": "2026-03-29 21:34:54 +0200", - "message": "fix: TypeScript build errors in embeds.ts and totp-settings test", - "type": "fix" - }, - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", - "date": "2026-03-29 19:40:11 +0200", - "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", - "type": "chore" - }, - { - "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", - "date": "2026-03-29 19:39:22 +0200", - "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", - "type": "fix" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", - "date": "2026-03-28 20:42:37 +0100", - "message": "feat: add observability, debugging, and diagnostics across all layers", - "type": "feat" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "21a438dca52f7c20ac31a03b900900fc3c09973e", - "date": "2026-03-28 12:16:57 +0100", - "message": "fix: accent color persistence and Discord-style settings panel", - "type": "fix" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "9636d0e9761b69b762ef869a5eaa1eb65cbb1320", - "date": "2026-03-27 14:25:54 +0100", - "message": "fix: DM header shows @ instead of #, DM welcome message, DM appears in sidebar list", - "type": "fix" - }, - { - "hash": "29d16f5976cc2d92050c56e88cff7295bc62a0c4", - "date": "2026-03-27 13:19:07 +0100", - "message": "fix: make neon-glow default theme, accent color picker now overrides theme accent", - "type": "fix" - }, - { - "hash": "6858c67408f19f973ac02f8658522825db85646e", - "date": "2026-03-27 13:10:21 +0100", - "message": "chore: stage QuickSwitchOverlay files missed from earlier commit", - "type": "chore" - }, - { - "hash": "2829220f06432cc6ae7e502059b898de79b12357", - "date": "2026-03-27 12:22:22 +0100", - "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", - "type": "fix" - }, - { - "hash": "80cd05923678a8552faf970c3f1e101d44f48938", - "date": "2026-03-27 12:09:47 +0100", - "message": "feat: add neon-glow to theme selector and apply body class on theme switch", - "type": "feat" - }, - { - "hash": "00a92851d4e2e92ed9412825fb2a70292770b889", - "date": "2026-03-27 12:09:38 +0100", - "message": "refactor: remove ServerStrip component and CSS (replaced by unified sidebar)", - "type": "refactor" - }, - { - "hash": "de5952fd269275179111c100031947ce49fd1ddd", - "date": "2026-03-27 11:46:40 +0100", - "message": "feat: add back-to-server header to DmSidebar", - "type": "feat" - }, - { - "hash": "57985d0a7f40496bc1dd65527fcbe617176b9ca7", - "date": "2026-03-27 11:39:48 +0100", - "message": "feat: add disconnect/switch-server button to UserBar", - "type": "feat" - }, - { - "hash": "8731750f75ca66c8870ddca5a03bce5fa2d5cb03", - "date": "2026-03-27 08:35:37 +0100", - "message": "feat: voice connection quality indicator with transport stats", - "type": "feat" - }, - { - "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", - "date": "2026-03-27 08:05:32 +0100", - "message": "fix: client code review — 27 fixes across 17 files", - "type": "fix" - }, - { - "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", - "date": "2026-03-26 22:09:56 +0100", - "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", - "type": "chore" - }, - { - "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", - "date": "2026-03-26 22:07:52 +0100", - "message": "feat: stream quality presets, nuclear mute, sidebar width fix", - "type": "feat" - }, - { - "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", - "date": "2026-03-26 20:53:09 +0100", - "message": "fix: voice mute pipeline, security hardening, and video tile controls", - "type": "fix" - }, - { - "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", - "date": "2026-03-26 18:07:20 +0100", - "message": "feat: full screenshare support — button state, video grid, auto-reconnect", - "type": "feat" - }, - { - "hash": "c1d3d34768be3213e6dff6728e9b558c208d1283", - "date": "2026-03-25 19:15:50 +0100", - "message": "fix: invert sensitivity slider direction to match Discord UX", - "type": "fix" - }, - { - "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", - "date": "2026-03-25 19:07:52 +0100", - "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", - "type": "fix" - }, - { - "hash": "738f4970576467e285c051549a4d78241966958c", - "date": "2026-03-24 21:35:40 +0100", - "message": "fix: address code review — 8 issues across server and client", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", - "date": "2026-03-24 20:23:40 +0100", - "message": "fix: address code review — security hardening, leak fixes, credential safety", - "type": "fix" - }, - { - "hash": "10063e0487172f606cab3fe385e33edd57befd29", - "date": "2026-03-22 19:29:53 +0100", - "message": "fix: address design review findings — empty states, ARIA, token refresh", - "type": "fix" - }, - { - "hash": "46be81081a009c022710b0275a3994ed1cbb191a", - "date": "2026-03-22 12:07:24 +0100", - "message": "fix: address code review issues in settings and message rendering", - "type": "fix" - }, - { - "hash": "78c13c7ec43e0867cbe6d7b4cb9a705c3d99c1bb", - "date": "2026-03-22 11:16:21 +0100", - "message": "fix: use Rust invoke for Open DevTools button", - "type": "fix" - }, - { - "hash": "5df1cf71cb05ff82a2c9c67ff3d263fad4a0dee6", - "date": "2026-03-22 11:02:38 +0100", - "message": "feat: wire up settings preferences to their consuming features", - "type": "feat" - }, - { - "hash": "6734deb57e0f3247089c2dc5eeb5c8c84136d554", - "date": "2026-03-22 10:31:00 +0100", - "message": "fix: scope settings CSS classes and use correct separator class", - "type": "fix" - }, - { - "hash": "8215c500cbbf4a60edcef31e5dfccec75c10f743", - "date": "2026-03-22 10:29:46 +0100", - "message": "feat: add Navigation, Communication, and Messages sections to Keybinds tab", - "type": "feat" - }, - { - "hash": "7a2feb1a55eb85037e68b0a9fa385a749d680287", - "date": "2026-03-22 10:26:41 +0100", - "message": "feat: add Advanced settings tab with Developer Mode, Hardware Acceleration, and DevTools", - "type": "feat" - }, - { - "hash": "57137d1a416374dd4f4c46943ec6bc7cf5634779", - "date": "2026-03-22 10:18:18 +0100", - "message": "feat: add Accessibility settings tab with reduced motion, high contrast, and large font", - "type": "feat" - }, - { - "hash": "f9bdeca12e1cd70fb0885afb41ec4f0aab5fd63f", - "date": "2026-03-22 10:09:41 +0100", - "message": "feat: add Text & Images settings tab", - "type": "feat" - }, - { - "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", - "date": "2026-03-22 10:04:49 +0100", - "message": "feat: add user status selector to Account settings tab", - "type": "feat" - }, - { - "hash": "aed8734828503b21e5a908bcac8b592e9e184a1a", - "date": "2026-03-22 09:54:07 +0100", - "message": "feat: add input/output volume sliders to Voice & Audio settings tab", - "type": "feat" - }, - { - "hash": "20e30bee96237c1d8970c294a4c249fe1f864137", - "date": "2026-03-22 09:50:32 +0100", - "message": "feat: add accent color picker to Appearance settings tab", - "type": "feat" - }, - { - "hash": "ea2edcd6896d58a8f6d0b4b466aa92912136f1bd", - "date": "2026-03-22 09:44:46 +0100", - "message": "fix: remove duplicate h1 headers in settings tabs and guard camera auto-start", - "type": "fix" - }, - { - "hash": "c3a13d09a142bcf4b233a278c9178d0f05fc3cfa", - "date": "2026-03-22 09:30:03 +0100", - "message": "fix: address review issues in settings redesign", - "type": "fix" - }, - { - "hash": "5bf9f89ed3aa1546d0e16577425d6553210eb434", - "date": "2026-03-22 09:18:52 +0100", - "message": "feat: Discord-style account profile card redesign (Phase 2 + 3)", - "type": "feat" - }, - { - "hash": "39eb2ab2b8545fe875cf5a5c80451ac3d0d035cf", - "date": "2026-03-22 09:14:55 +0100", - "message": "feat: Discord-style settings sidebar with icons, profile section, and categories", - "type": "feat" - }, - { - "hash": "e0a811fc156c90f24cdb92232699cbf3d1bc4825", - "date": "2026-03-22 09:00:48 +0100", - "message": "feat: Discord-style timestamps, reply avatars, virtual scroll fixes", - "type": "feat" - }, - { - "hash": "1679d071783a26d514d1a603d0e9912c8f398da6", - "date": "2026-03-22 08:47:09 +0100", - "message": "feat: add Discord-style timestamp formatting and message layout tokens", - "type": "feat" - }, - { - "hash": "5048a077810934e800d1e2e26b15da257f511fcc", - "date": "2026-03-22 08:19:22 +0100", - "message": "style: redesign server invites modal with card layout and icons", - "type": "other" - }, - { - "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", - "date": "2026-03-21 23:33:18 +0100", - "message": "feat: replace all emoji icons with Lucide SVG icons", - "type": "feat" - }, - { - "hash": "fa690a9ad41ac70fc20c10282ed148382209d4fe", - "date": "2026-03-21 21:55:28 +0100", - "message": "polish: pinned panel — avatar colors, timestamp formatting, CSS tokens", - "type": "other" - }, - { - "hash": "8445fa6799341ef00b0a1fd668a726423ea5b0d2", - "date": "2026-03-21 21:51:05 +0100", - "message": "feat: Discord-style pinned messages panel with avatars and animations", - "type": "feat" - }, - { - "hash": "e9ddef659ea489550f0787cfad4c528de604ff3c", - "date": "2026-03-21 21:15:55 +0100", - "message": "feat: add copy button to code blocks", - "type": "feat" - }, - { - "hash": "a522ff4aa26b85413a91aa73ac4458cc10a0eb72", - "date": "2026-03-21 21:14:23 +0100", - "message": "fix: Escape cancels reply/edit + full date tooltips on timestamps", - "type": "fix" - }, - { - "hash": "6878138b33c3fb7b79bb898a1c6dfb025eaf5bdf", - "date": "2026-03-21 21:12:42 +0100", - "message": "feat: add scroll-to-bottom floating button in MessageList", - "type": "feat" - }, - { - "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", - "date": "2026-03-21 21:08:58 +0100", - "message": "feat: add Discord-style welcome state for empty channels", - "type": "feat" - }, - { - "hash": "b460bf925523db1fb28464dd143d686b05308287", - "date": "2026-03-21 21:00:54 +0100", - "message": "perf: only rebuild message list DOM on member role changes", - "type": "perf" - }, - { - "hash": "b1868a99fef8fdf8a60e0c2ba65ef29220a0908b", - "date": "2026-03-21 21:00:54 +0100", - "message": "fix: use null-safe check for file input selection in MessageInput", - "type": "fix" - }, - { - "hash": "f9bc7b1a6b33dd33f619178ddb7255455ce4015b", - "date": "2026-03-21 20:59:32 +0100", - "message": "fix: remove duplicate lightbox and fix event listener leaks in openImageLightbox", - "type": "fix" - }, - { - "hash": "a7fb5a95ddc599b39cde297f58f8f3c96699cce7", - "date": "2026-03-21 20:55:57 +0100", - "message": "fix: remove duplicate lightbox and fix event listener leaks in openImageLightbox", - "type": "fix" - }, - { - "hash": "6ee3698525b22e2471c05e9e0addb8b42e963470", - "date": "2026-03-21 20:53:47 +0100", - "message": "fix: prevent virtual scroll rebuild loops and improve image height caching", - "type": "fix" - }, - { - "hash": "4dd1580f43564f6faa0f500914e2ac1b62eb08ae", - "date": "2026-03-21 12:39:17 +0100", - "message": "fix: measure DOM before setting spacers + update spacers on unchanged range", - "type": "fix" - }, - { - "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", - "date": "2026-03-21 12:34:31 +0100", - "message": "fix: virtual scroll jumping with images/GIFs", - "type": "fix" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "dba5f75fd8c8f066af3520e9194e15ae0750ee4b", - "date": "2026-03-20 07:48:13 +0100", - "message": "feat: sensitivity slider controls mic gating + speaking ring", - "type": "feat" - }, - { - "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", - "date": "2026-03-20 05:46:19 +0100", - "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", - "type": "feat" - }, - { - "hash": "f8e41180f48632937e8184d48106dc05287c1721", - "date": "2026-03-20 02:21:53 +0100", - "message": "fix: settings bugs, accessibility, and camera stream leak", - "type": "fix" - }, - { - "hash": "17600081dfef4d5bccf6777279559599acfc1eff", - "date": "2026-03-20 02:00:29 +0100", - "message": "feat: wire search bar to server FTS5 search API (T-065)", - "type": "feat" - }, - { - "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", - "date": "2026-03-19 17:54:13 +0100", - "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", - "type": "docs" - }, - { - "hash": "c6e864580946b4017e67182c777b19936f436458", - "date": "2026-03-19 17:43:32 +0100", - "message": "fix: stop camera preview and mic meter when settings overlay closes", - "type": "fix" - }, - { - "hash": "ccc51015fc984f12438c9ceb6594449867e41018", - "date": "2026-03-19 16:47:26 +0100", - "message": "feat: add camera active indicator to voice widget", - "type": "feat" - }, - { - "hash": "9ad91c6393d28dc8b75e9b229b9b4b46bb1386bb", - "date": "2026-03-19 16:47:18 +0100", - "message": "feat: add webcam device selector and preview to settings", - "type": "feat" - }, - { - "hash": "81b45d55e312c7f6a4085073ffbc8842d7c5bd6f", - "date": "2026-03-19 16:39:35 +0100", - "message": "feat: add VideoGrid component for rendering camera streams", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "04ae86b0b2166cd8a74e18d36e74f29bfd25b4c5", - "date": "2026-03-19 09:51:16 +0100", - "message": "feat: show client version on settings Logs tab", - "type": "feat" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", - "date": "2026-03-18 17:10:16 +0100", - "message": "feat: native file downloads, upload size fix, native E2E tests", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "a37abb47a266fca465ebaaa6866308f3868df373", - "date": "2026-03-18 11:33:59 +0100", - "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", - "type": "fix" - }, - { - "hash": "9c63dd6efed184908b192fe60162683019a89d5c", - "date": "2026-03-18 11:28:13 +0100", - "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", - "type": "feat" - }, - { - "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", - "date": "2026-03-18 07:03:53 +0100", - "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", - "type": "fix" - }, - { - "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", - "date": "2026-03-18 06:45:05 +0100", - "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", - "type": "fix" - }, - { - "hash": "d3d1e067db271a199c641771604f55e05acd809c", - "date": "2026-03-18 06:40:12 +0100", - "message": "fix: render actual images for attachments, remove orphaned components (BUG-026, BUG-030)", - "type": "fix" - }, - { - "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", - "date": "2026-03-18 06:33:17 +0100", - "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", - "type": "fix" - }, - { - "hash": "c1e2733f11a746aef74897bed712f69693e72acb", - "date": "2026-03-18 06:28:45 +0100", - "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", - "type": "fix" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", - "date": "2026-03-17 20:03:34 +0100", - "message": "fix: voice channel join/leave visibility and disconnect bugs", - "type": "fix" - }, - { - "hash": "1914067941b8512b895e32075fe2ced0b3da3841", - "date": "2026-03-17 03:20:37 +0100", - "message": "fix: address PR review findings (issues #3-#8)", - "type": "fix" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "4dc61a506f18398c424d04eb6dbf31d44a5cbc30", - "date": "2026-03-17 02:25:49 +0100", - "message": "feat: add virtual scrolling to MessageList for large channels", - "type": "feat" - }, - { - "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", - "date": "2026-03-17 02:17:26 +0100", - "message": "refactor: split oversized files + add store notification batching", - "type": "refactor" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", - "date": "2026-03-15 20:43:13 +0100", - "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "ts:styles": { - "count": 61, - "lastCommit": "2026-03-30 20:13:34 +0200", - "commits": [ - { - "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", - "date": "2026-03-30 20:13:34 +0200", - "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", - "type": "feat" - }, - { - "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", - "date": "2026-03-30 19:12:36 +0200", - "message": "feat: sidebar stream preview + screenshare focus fix", - "type": "feat" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "21a438dca52f7c20ac31a03b900900fc3c09973e", - "date": "2026-03-28 12:16:57 +0100", - "message": "fix: accent color persistence and Discord-style settings panel", - "type": "fix" - }, - { - "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", - "date": "2026-03-28 11:24:31 +0100", - "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "81d97c027929a2c308e328f073ef4a094805bd2b", - "date": "2026-03-27 14:51:38 +0100", - "message": "feat: move invite button to unified header, hide duplicate channel-sidebar-header", - "type": "feat" - }, - { - "hash": "29d16f5976cc2d92050c56e88cff7295bc62a0c4", - "date": "2026-03-27 13:19:07 +0100", - "message": "fix: make neon-glow default theme, accent color picker now overrides theme accent", - "type": "fix" - }, - { - "hash": "b8be76041a879e6ed256c745d2fe10fbd945088a", - "date": "2026-03-27 13:04:35 +0100", - "message": "fix: compact member items in sidebar, increase max resize height to 65%", - "type": "fix" - }, - { - "hash": "cfcc76760d0408115971b4224ebcd6e2644301c1", - "date": "2026-03-27 12:56:40 +0100", - "message": "feat: add member list header/resize, DM section in sidebar, remove member toggle from chat header", - "type": "feat" - }, - { - "hash": "ca0016ecac8b96ba25977de1e5d04316020987b6", - "date": "2026-03-27 12:29:44 +0100", - "message": "feat: relocate MemberList into unified sidebar as collapsible section", - "type": "feat" - }, - { - "hash": "2829220f06432cc6ae7e502059b898de79b12357", - "date": "2026-03-27 12:22:22 +0100", - "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", - "type": "fix" - }, - { - "hash": "00a92851d4e2e92ed9412825fb2a70292770b889", - "date": "2026-03-27 12:09:38 +0100", - "message": "refactor: remove ServerStrip component and CSS (replaced by unified sidebar)", - "type": "refactor" - }, - { - "hash": "1f4875c781845a7408d89fa6a5934e06edf884a3", - "date": "2026-03-27 12:01:25 +0100", - "message": "feat: rewrite SidebarArea as unified sidebar (removes ServerStrip)", - "type": "feat" - }, - { - "hash": "de5952fd269275179111c100031947ce49fd1ddd", - "date": "2026-03-27 11:46:40 +0100", - "message": "feat: add back-to-server header to DmSidebar", - "type": "feat" - }, - { - "hash": "7715ac29553df52a6632fe34729823ca99a5f299", - "date": "2026-03-27 11:32:36 +0100", - "message": "feat: add OC Neon Glow theme CSS with theme contract variables", - "type": "feat" - }, - { - "hash": "e19b0aa05f828850da3f27dd5142bbda64251d8a", - "date": "2026-03-27 10:00:03 +0100", - "message": "feat: login screen redesign with OC branding and animations", - "type": "feat" - }, - { - "hash": "8731750f75ca66c8870ddca5a03bce5fa2d5cb03", - "date": "2026-03-27 08:35:37 +0100", - "message": "feat: voice connection quality indicator with transport stats", - "type": "feat" - }, - { - "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", - "date": "2026-03-26 22:07:52 +0100", - "message": "feat: stream quality presets, nuclear mute, sidebar width fix", - "type": "feat" - }, - { - "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", - "date": "2026-03-26 20:53:09 +0100", - "message": "fix: voice mute pipeline, security hardening, and video tile controls", - "type": "fix" - }, - { - "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", - "date": "2026-03-25 19:07:52 +0100", - "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", - "type": "fix" - }, - { - "hash": "10063e0487172f606cab3fe385e33edd57befd29", - "date": "2026-03-22 19:29:53 +0100", - "message": "fix: address design review findings — empty states, ARIA, token refresh", - "type": "fix" - }, - { - "hash": "46be81081a009c022710b0275a3994ed1cbb191a", - "date": "2026-03-22 12:07:24 +0100", - "message": "fix: address code review issues in settings and message rendering", - "type": "fix" - }, - { - "hash": "57137d1a416374dd4f4c46943ec6bc7cf5634779", - "date": "2026-03-22 10:18:18 +0100", - "message": "feat: add Accessibility settings tab with reduced motion, high contrast, and large font", - "type": "feat" - }, - { - "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", - "date": "2026-03-22 10:04:49 +0100", - "message": "feat: add user status selector to Account settings tab", - "type": "feat" - }, - { - "hash": "20e30bee96237c1d8970c294a4c249fe1f864137", - "date": "2026-03-22 09:50:32 +0100", - "message": "feat: add accent color picker to Appearance settings tab", - "type": "feat" - }, - { - "hash": "c3a13d09a142bcf4b233a278c9178d0f05fc3cfa", - "date": "2026-03-22 09:30:03 +0100", - "message": "fix: address review issues in settings redesign", - "type": "fix" - }, - { - "hash": "5bf9f89ed3aa1546d0e16577425d6553210eb434", - "date": "2026-03-22 09:18:52 +0100", - "message": "feat: Discord-style account profile card redesign (Phase 2 + 3)", - "type": "feat" - }, - { - "hash": "39eb2ab2b8545fe875cf5a5c80451ac3d0d035cf", - "date": "2026-03-22 09:14:55 +0100", - "message": "feat: Discord-style settings sidebar with icons, profile section, and categories", - "type": "feat" - }, - { - "hash": "e0a811fc156c90f24cdb92232699cbf3d1bc4825", - "date": "2026-03-22 09:00:48 +0100", - "message": "feat: Discord-style timestamps, reply avatars, virtual scroll fixes", - "type": "feat" - }, - { - "hash": "94a4bdd0ef960df16c238a13cc563f892281b840", - "date": "2026-03-22 08:58:15 +0100", - "message": "style: update compact mode spacing for Discord layout", - "type": "other" - }, - { - "hash": "1679d071783a26d514d1a603d0e9912c8f398da6", - "date": "2026-03-22 08:47:09 +0100", - "message": "feat: add Discord-style timestamp formatting and message layout tokens", - "type": "feat" - }, - { - "hash": "5048a077810934e800d1e2e26b15da257f511fcc", - "date": "2026-03-22 08:19:22 +0100", - "message": "style: redesign server invites modal with card layout and icons", - "type": "other" - }, - { - "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", - "date": "2026-03-21 23:33:18 +0100", - "message": "feat: replace all emoji icons with Lucide SVG icons", - "type": "feat" - }, - { - "hash": "3cf8544cca8477671ffafc3b16565184457dcc1f", - "date": "2026-03-21 22:27:55 +0100", - "message": "fix: address code review — spin keyframe collision, mentioned border shift, use hover token", - "type": "fix" - }, - { - "hash": "359cb2802a3cfd771fe886d1547a3d55b28d243c", - "date": "2026-03-21 22:23:38 +0100", - "message": "style: mention highlights, skeleton loaders, tooltips, role color support", - "type": "other" - }, - { - "hash": "7557d4bb8361345af455e28241c1638774c0eaae", - "date": "2026-03-21 22:21:33 +0100", - "message": "style: input focus glow, picker/action animations, spring popup", - "type": "other" - }, - { - "hash": "6e3143c4a864fcac01465fee41b14c087f763b2f", - "date": "2026-03-21 22:19:23 +0100", - "message": "style: Discord polish — unread pill, offline desaturation, hover tints, animations", - "type": "other" - }, - { - "hash": "2cb0e99e6a8979ae5c5e905b938ddad1a93f0f9d", - "date": "2026-03-21 22:12:11 +0100", - "message": "style: Discord-style scrollbars, font rendering, and header elevation shadows", - "type": "other" - }, - { - "hash": "413f0c0022fa68bace6ba3844a072e9c4fdd5cfc", - "date": "2026-03-21 22:10:30 +0100", - "message": "style: add Discord-style design tokens for hover, interactive, elevation, typography", - "type": "other" - }, - { - "hash": "fa690a9ad41ac70fc20c10282ed148382209d4fe", - "date": "2026-03-21 21:55:28 +0100", - "message": "polish: pinned panel — avatar colors, timestamp formatting, CSS tokens", - "type": "other" - }, - { - "hash": "8445fa6799341ef00b0a1fd668a726423ea5b0d2", - "date": "2026-03-21 21:51:05 +0100", - "message": "feat: Discord-style pinned messages panel with avatars and animations", - "type": "feat" - }, - { - "hash": "f39f7c6d7e1ba2664731f30667bd16e9cb675482", - "date": "2026-03-21 21:19:48 +0100", - "message": "polish: CSS visual consistency pass — hover/focus/transitions", - "type": "other" - }, - { - "hash": "e9ddef659ea489550f0787cfad4c528de604ff3c", - "date": "2026-03-21 21:15:55 +0100", - "message": "feat: add copy button to code blocks", - "type": "feat" - }, - { - "hash": "6878138b33c3fb7b79bb898a1c6dfb025eaf5bdf", - "date": "2026-03-21 21:12:42 +0100", - "message": "feat: add scroll-to-bottom floating button in MessageList", - "type": "feat" - }, - { - "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", - "date": "2026-03-21 21:08:58 +0100", - "message": "feat: add Discord-style welcome state for empty channels", - "type": "feat" - }, - { - "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", - "date": "2026-03-21 12:34:31 +0100", - "message": "fix: virtual scroll jumping with images/GIFs", - "type": "fix" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "17600081dfef4d5bccf6777279559599acfc1eff", - "date": "2026-03-20 02:00:29 +0100", - "message": "feat: wire search bar to server FTS5 search API (T-065)", - "type": "feat" - }, - { - "hash": "a764d12bafb818dc0ffe1a93bf85f4d33cb00e8d", - "date": "2026-03-19 16:47:34 +0100", - "message": "feat: add video grid CSS styles", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", - "date": "2026-03-18 17:10:16 +0100", - "message": "feat: native file downloads, upload size fix, native E2E tests", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9c63dd6efed184908b192fe60162683019a89d5c", - "date": "2026-03-18 11:28:13 +0100", - "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", - "type": "feat" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", - "date": "2026-03-15 20:43:13 +0100", - "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "ts:lib": { - "count": 85, - "lastCommit": "2026-03-30 19:12:36 +0200", - "commits": [ - { - "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", - "date": "2026-03-30 19:12:36 +0200", - "message": "feat: sidebar stream preview + screenshare focus fix", - "type": "feat" - }, - { - "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", - "date": "2026-03-30 12:42:31 +0200", - "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", - "type": "fix" - }, - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", - "date": "2026-03-29 19:40:11 +0200", - "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", - "type": "chore" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", - "date": "2026-03-28 20:42:37 +0100", - "message": "feat: add observability, debugging, and diagnostics across all layers", - "type": "feat" - }, - { - "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", - "date": "2026-03-28 18:23:18 +0100", - "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", - "type": "fix" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "21a438dca52f7c20ac31a03b900900fc3c09973e", - "date": "2026-03-28 12:16:57 +0100", - "message": "fix: accent color persistence and Discord-style settings panel", - "type": "fix" - }, - { - "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", - "date": "2026-03-28 11:24:31 +0100", - "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", - "type": "feat" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "e5afe7d20136f78e09ffd7fd951814a036c84fb3", - "date": "2026-03-27 14:08:25 +0100", - "message": "feat(client): add DM store, API methods, dispatcher events, and sidebar wiring", - "type": "feat" - }, - { - "hash": "29d16f5976cc2d92050c56e88cff7295bc62a0c4", - "date": "2026-03-27 13:19:07 +0100", - "message": "fix: make neon-glow default theme, accent color picker now overrides theme accent", - "type": "fix" - }, - { - "hash": "2829220f06432cc6ae7e502059b898de79b12357", - "date": "2026-03-27 12:22:22 +0100", - "message": "fix: address code review — test regression, theme consolidation, validation, dead CSS", - "type": "fix" - }, - { - "hash": "57985d0a7f40496bc1dd65527fcbe617176b9ca7", - "date": "2026-03-27 11:39:48 +0100", - "message": "feat: add disconnect/switch-server button to UserBar", - "type": "feat" - }, - { - "hash": "e6b5d43e669b1aa1ec4bfccf3aa4a235dc8980f0", - "date": "2026-03-27 11:37:57 +0100", - "message": "feat: add theme manager with built-in + custom theme support", - "type": "feat" - }, - { - "hash": "c7809347499263f3b4de078a19b6043eb4470f88", - "date": "2026-03-27 08:41:34 +0100", - "message": "fix: connection stats RTT detection and rate formatting", - "type": "fix" - }, - { - "hash": "8731750f75ca66c8870ddca5a03bce5fa2d5cb03", - "date": "2026-03-27 08:35:37 +0100", - "message": "feat: voice connection quality indicator with transport stats", - "type": "feat" - }, - { - "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", - "date": "2026-03-27 08:05:32 +0100", - "message": "fix: client code review — 27 fixes across 17 files", - "type": "fix" - }, - { - "hash": "8f2f106399ed19a3d3c53f6b21f25fc6a620acf1", - "date": "2026-03-26 22:07:52 +0100", - "message": "feat: stream quality presets, nuclear mute, sidebar width fix", - "type": "feat" - }, - { - "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", - "date": "2026-03-26 20:53:09 +0100", - "message": "fix: voice mute pipeline, security hardening, and video tile controls", - "type": "fix" - }, - { - "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", - "date": "2026-03-26 18:07:20 +0100", - "message": "feat: full screenshare support — button state, video grid, auto-reconnect", - "type": "feat" - }, - { - "hash": "c496f830aee2145a9f7c6c08c41b6fb67118f327", - "date": "2026-03-25 19:07:52 +0100", - "message": "fix: voice audio pipeline — autoplay unlock and GainNode-based VAD", - "type": "fix" - }, - { - "hash": "738f4970576467e285c051549a4d78241966958c", - "date": "2026-03-24 21:35:40 +0100", - "message": "fix: address code review — 8 issues across server and client", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", - "date": "2026-03-24 20:23:40 +0100", - "message": "fix: address code review — security hardening, leak fixes, credential safety", - "type": "fix" - }, - { - "hash": "d498e8fcce75ec39eee072f307d1b62f16be29d9", - "date": "2026-03-22 21:10:02 +0100", - "message": "fix: resolve LiveKit voice issues — duplicate audio, tunnel effect with 5+ users", - "type": "fix" - }, - { - "hash": "10063e0487172f606cab3fe385e33edd57befd29", - "date": "2026-03-22 19:29:53 +0100", - "message": "fix: address design review findings — empty states, ARIA, token refresh", - "type": "fix" - }, - { - "hash": "9949e9de9ea8e84a6b8448bc78e04df83cb392be", - "date": "2026-03-22 15:34:26 +0100", - "message": "fix: restore audio track attachment for remote playback", - "type": "fix" - }, - { - "hash": "46be81081a009c022710b0275a3994ed1cbb191a", - "date": "2026-03-22 12:07:24 +0100", - "message": "fix: address code review issues in settings and message rendering", - "type": "fix" - }, - { - "hash": "5df1cf71cb05ff82a2c9c67ff3d263fad4a0dee6", - "date": "2026-03-22 11:02:38 +0100", - "message": "feat: wire up settings preferences to their consuming features", - "type": "feat" - }, - { - "hash": "f9bdeca12e1cd70fb0885afb41ec4f0aab5fd63f", - "date": "2026-03-22 10:09:41 +0100", - "message": "feat: add Text & Images settings tab", - "type": "feat" - }, - { - "hash": "aed8734828503b21e5a908bcac8b592e9e184a1a", - "date": "2026-03-22 09:54:07 +0100", - "message": "feat: add input/output volume sliders to Voice & Audio settings tab", - "type": "feat" - }, - { - "hash": "39eb2ab2b8545fe875cf5a5c80451ac3d0d035cf", - "date": "2026-03-22 09:14:55 +0100", - "message": "feat: Discord-style settings sidebar with icons, profile section, and categories", - "type": "feat" - }, - { - "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", - "date": "2026-03-21 23:33:18 +0100", - "message": "feat: replace all emoji icons with Lucide SVG icons", - "type": "feat" - }, - { - "hash": "6ee3698525b22e2471c05e9e0addb8b42e963470", - "date": "2026-03-21 20:53:47 +0100", - "message": "fix: prevent virtual scroll rebuild loops and improve image height caching", - "type": "fix" - }, - { - "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", - "date": "2026-03-21 12:34:31 +0100", - "message": "fix: virtual scroll jumping with images/GIFs", - "type": "fix" - }, - { - "hash": "a9998714d7b39ae40a30dd1b66829a83683d97fd", - "date": "2026-03-21 12:07:34 +0100", - "message": "fix: reset camera state on voice leave to prevent stale video grid", - "type": "fix" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "dba5f75fd8c8f066af3520e9194e15ae0750ee4b", - "date": "2026-03-20 07:48:13 +0100", - "message": "feat: sensitivity slider controls mic gating + speaking ring", - "type": "feat" - }, - { - "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", - "date": "2026-03-20 07:11:45 +0100", - "message": "fix: speaking ring, devtools, CSP, and connection fixes", - "type": "fix" - }, - { - "hash": "91ebce72727a54fb96c72dadeb257bd92124ef4f", - "date": "2026-03-20 06:33:57 +0100", - "message": "fix: wire LiveKit speaker detection to voice activation ring", - "type": "fix" - }, - { - "hash": "ff2b82280c6002a5363f25af434e1898b15322ca", - "date": "2026-03-20 06:27:43 +0100", - "message": "fix: resolve LiveKit connection issues", - "type": "fix" - }, - { - "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", - "date": "2026-03-20 06:11:09 +0100", - "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", - "type": "fix" - }, - { - "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", - "date": "2026-03-20 05:46:19 +0100", - "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", - "type": "feat" - }, - { - "hash": "f8e41180f48632937e8184d48106dc05287c1721", - "date": "2026-03-20 02:21:53 +0100", - "message": "fix: settings bugs, accessibility, and camera stream leak", - "type": "fix" - }, - { - "hash": "f11a53913ffb192b6d2978834e793d1053e45d27", - "date": "2026-03-20 01:33:43 +0100", - "message": "refactor: extract MainPage into focused controllers with tests", - "type": "refactor" - }, - { - "hash": "f7a6e0e0957bfaa1f3e84d12188cacbf3f749382", - "date": "2026-03-19 21:35:18 +0100", - "message": "fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging", - "type": "fix" - }, - { - "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", - "date": "2026-03-19 17:54:13 +0100", - "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", - "type": "docs" - }, - { - "hash": "92be1372292e3dc1fb342752bf326d87af6f2a57", - "date": "2026-03-19 17:13:59 +0100", - "message": "fix: use unique stream IDs for audio and video tracks to prevent dedup collision", - "type": "fix" - }, - { - "hash": "594fe3ebb486e2a6cd1f8a06c06635cc993f6a45", - "date": "2026-03-19 17:07:30 +0100", - "message": "fix: add local camera self-view to video grid", - "type": "fix" - }, - { - "hash": "e41da3707094a7a38eb8a0884058e326d60f2607", - "date": "2026-03-19 16:42:24 +0100", - "message": "feat: add camera enable/disable and remote video handling to voice session", - "type": "feat" - }, - { - "hash": "5b10bc28f323b8b2365036de84a72f5d2897e3bc", - "date": "2026-03-19 16:39:29 +0100", - "message": "feat: add video device manager for camera capture", - "type": "feat" - }, - { - "hash": "9399dfe1254f16d72a6c6c8ae490c0fc48a7e7be", - "date": "2026-03-19 16:38:43 +0100", - "message": "feat: add video track add/remove to WebRTC service", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9c63dd6efed184908b192fe60162683019a89d5c", - "date": "2026-03-18 11:28:13 +0100", - "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", - "type": "feat" - }, - { - "hash": "a9b816cf2646f7aa6689812feb770d83af95c0e7", - "date": "2026-03-18 07:24:19 +0100", - "message": "fix: prevent stale PeerConnection callbacks from killing new voice sessions", - "type": "fix" - }, - { - "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", - "date": "2026-03-18 07:03:53 +0100", - "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", - "type": "fix" - }, - { - "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", - "date": "2026-03-18 06:45:05 +0100", - "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", - "type": "fix" - }, - { - "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", - "date": "2026-03-18 06:33:17 +0100", - "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", - "type": "fix" - }, - { - "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", - "date": "2026-03-18 04:13:12 +0100", - "message": "fix: voice session cleanup on ICE close and connection failure", - "type": "fix" - }, - { - "hash": "eb5f399075bc19e6d004adf1d09b0f2a87f71e78", - "date": "2026-03-18 03:59:41 +0100", - "message": "feat: add voice_offer/answer/ice dispatcher handlers", - "type": "feat" - }, - { - "hash": "be5efad53661ea32c385f911cc236be070893669", - "date": "2026-03-18 03:57:57 +0100", - "message": "feat: create voiceSession module for voice lifecycle orchestration", - "type": "feat" - }, - { - "hash": "a66b2d24062366867f60c2ad78e63b16e938ae9b", - "date": "2026-03-18 03:55:49 +0100", - "message": "feat: add handleServerOffer with SDP rollback and createOffer", - "type": "feat" - }, - { - "hash": "ffc9252810072c2dba70c0da12df510314fa6b85", - "date": "2026-03-18 03:38:04 +0100", - "message": "fix: correct VoiceIcePayload candidate type to RTCIceCandidateInit", - "type": "fix" - }, - { - "hash": "00bbb46b62c3aab6e5791795c3602ed2a1f05ed8", - "date": "2026-03-18 02:41:55 +0100", - "message": "fix: reject duplicate WebSocket logins to prevent reconnect ping-pong", - "type": "fix" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "17eb67811f7844324d6703bfddce0d7ae8996267", - "date": "2026-03-17 14:01:26 +0000", - "message": "fix: resolve CI failures — errcheck lint and TS noUncheckedIndexedAccess errors", - "type": "fix" - }, - { - "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", - "date": "2026-03-17 14:54:50 +0100", - "message": "fix: address PR #15 review issues (#16-#23)", - "type": "fix" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "a1968078806dc7ce2c65dda30e4e685d22594f20", - "date": "2026-03-17 02:17:26 +0100", - "message": "refactor: split oversized files + add store notification batching", - "type": "refactor" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", - "date": "2026-03-15 20:43:13 +0100", - "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "ts:pages": { - "count": 72, - "lastCommit": "2026-03-30 19:12:36 +0200", - "commits": [ - { - "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", - "date": "2026-03-30 19:12:36 +0200", - "message": "feat: sidebar stream preview + screenshare focus fix", - "type": "feat" - }, - { - "hash": "34f1fe3454157a41d3e0e567a7cb45c1bb67bd30", - "date": "2026-03-30 12:42:31 +0200", - "message": "fix: settings tab bug fixes, expanded tests, and coverage improvements", - "type": "fix" - }, - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", - "date": "2026-03-29 19:40:11 +0200", - "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", - "type": "chore" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", - "date": "2026-03-28 11:24:31 +0100", - "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", - "type": "feat" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "81d97c027929a2c308e328f073ef4a094805bd2b", - "date": "2026-03-27 14:51:38 +0100", - "message": "feat: move invite button to unified header, hide duplicate channel-sidebar-header", - "type": "feat" - }, - { - "hash": "3dba5dd42c923c80de180c38be4ec51acd77e711", - "date": "2026-03-27 14:46:09 +0100", - "message": "fix: closing DM switches to next DM or channels, resolve DM recipient name for header", - "type": "fix" - }, - { - "hash": "f96be172b40560287f482aaee18294ea95dc0bab", - "date": "2026-03-27 14:41:36 +0100", - "message": "fix: only save non-DM channels as \"channel before DM\" for back navigation", - "type": "fix" - }, - { - "hash": "1331fce0ae6509cbf9c12a233e3a15719a049349", - "date": "2026-03-27 14:39:47 +0100", - "message": "fix: DM close removes from sidebar, DM header shows real user status", - "type": "fix" - }, - { - "hash": "6207a0c936d3e3170ec2b5d816969b5b21f6a1b6", - "date": "2026-03-27 14:35:17 +0100", - "message": "fix: restore previous channel when leaving DM mode via back button", - "type": "fix" - }, - { - "hash": "9636d0e9761b69b762ef869a5eaa1eb65cbb1320", - "date": "2026-03-27 14:25:54 +0100", - "message": "fix: DM header shows @ instead of #, DM welcome message, DM appears in sidebar list", - "type": "fix" - }, - { - "hash": "59a752bd3b1e357509586a4dbf138640542f030e", - "date": "2026-03-27 14:13:52 +0100", - "message": "fix: add visible class to DM member picker modal overlay", - "type": "fix" - }, - { - "hash": "e5afe7d20136f78e09ffd7fd951814a036c84fb3", - "date": "2026-03-27 14:08:25 +0100", - "message": "feat(client): add DM store, API methods, dispatcher events, and sidebar wiring", - "type": "feat" - }, - { - "hash": "b8be76041a879e6ed256c745d2fe10fbd945088a", - "date": "2026-03-27 13:04:35 +0100", - "message": "fix: compact member items in sidebar, increase max resize height to 65%", - "type": "fix" - }, - { - "hash": "cfcc76760d0408115971b4224ebcd6e2644301c1", - "date": "2026-03-27 12:56:40 +0100", - "message": "feat: add member list header/resize, DM section in sidebar, remove member toggle from chat header", - "type": "feat" - }, - { - "hash": "5a2d943960ca816b0c853815adef5dace99b4983", - "date": "2026-03-27 12:39:34 +0100", - "message": "feat: wire DM conversations to member data in sidebar", - "type": "feat" - }, - { - "hash": "3c98e1b984e181df2f58387880b6e2332b7258dc", - "date": "2026-03-27 12:34:28 +0100", - "message": "feat: wire quick-switch overlay to disconnect and navigate to ConnectPage", - "type": "feat" - }, - { - "hash": "ca0016ecac8b96ba25977de1e5d04316020987b6", - "date": "2026-03-27 12:29:44 +0100", - "message": "feat: relocate MemberList into unified sidebar as collapsible section", - "type": "feat" - }, - { - "hash": "90230a2f90b0a86c33db4973b726968ea291b6da", - "date": "2026-03-27 12:04:48 +0100", - "message": "feat: update MainPage layout — remove server strip and standalone member list", - "type": "feat" - }, - { - "hash": "1f4875c781845a7408d89fa6a5934e06edf884a3", - "date": "2026-03-27 12:01:25 +0100", - "message": "feat: rewrite SidebarArea as unified sidebar (removes ServerStrip)", - "type": "feat" - }, - { - "hash": "39b036220679c77ca421a9a153f086d59f244dbb", - "date": "2026-03-27 11:46:49 +0100", - "message": "feat: add DM mode support to ChatHeader with recipient info", - "type": "feat" - }, - { - "hash": "e92772ce79ef6b7750c69e9e3ec00e71f1baed79", - "date": "2026-03-27 10:13:31 +0100", - "message": "fix: move status dot out of icon div to fix letter centering", - "type": "fix" - }, - { - "hash": "e954348d8e279c0213fa516e08ffc968746dd601", - "date": "2026-03-27 10:05:18 +0100", - "message": "fix: replace form logo with OC neon glow SVG to match branding", - "type": "fix" - }, - { - "hash": "aee0e5659a6166010b9ee3ac268e2ae922e44b4c", - "date": "2026-03-27 10:04:08 +0100", - "message": "fix: server icon shows single initial for compact 28px size", - "type": "fix" - }, - { - "hash": "e19b0aa05f828850da3f27dd5142bbda64251d8a", - "date": "2026-03-27 10:00:03 +0100", - "message": "feat: login screen redesign with OC branding and animations", - "type": "feat" - }, - { - "hash": "63a3dfd79000e7d0e970207d8143471bd1249db7", - "date": "2026-03-26 22:09:56 +0100", - "message": "chore: remaining unstaged changes — rnnoise worklet, tests, renderers", - "type": "chore" - }, - { - "hash": "d87291e90fa491b4b396ce547b0cfea4a74c7024", - "date": "2026-03-26 18:07:20 +0100", - "message": "feat: full screenshare support — button state, video grid, auto-reconnect", - "type": "feat" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "f89226b68d6f5c1a7a5772f6b46ffa38684f969c", - "date": "2026-03-22 10:04:49 +0100", - "message": "feat: add user status selector to Account settings tab", - "type": "feat" - }, - { - "hash": "2536b619bdecfeb29f084932c9214049ba0c3c1c", - "date": "2026-03-21 23:33:18 +0100", - "message": "feat: replace all emoji icons with Lucide SVG icons", - "type": "feat" - }, - { - "hash": "fa690a9ad41ac70fc20c10282ed148382209d4fe", - "date": "2026-03-21 21:55:28 +0100", - "message": "polish: pinned panel — avatar colors, timestamp formatting, CSS tokens", - "type": "other" - }, - { - "hash": "8445fa6799341ef00b0a1fd668a726423ea5b0d2", - "date": "2026-03-21 21:51:05 +0100", - "message": "feat: Discord-style pinned messages panel with avatars and animations", - "type": "feat" - }, - { - "hash": "659ce1b7517b0890027c17a93d8499e1cc90a162", - "date": "2026-03-21 21:08:58 +0100", - "message": "feat: add Discord-style welcome state for empty channels", - "type": "feat" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", - "date": "2026-03-20 06:11:09 +0100", - "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", - "type": "fix" - }, - { - "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", - "date": "2026-03-20 05:46:19 +0100", - "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", - "type": "feat" - }, - { - "hash": "17600081dfef4d5bccf6777279559599acfc1eff", - "date": "2026-03-20 02:00:29 +0100", - "message": "feat: wire search bar to server FTS5 search API (T-065)", - "type": "feat" - }, - { - "hash": "f11a53913ffb192b6d2978834e793d1053e45d27", - "date": "2026-03-20 01:33:43 +0100", - "message": "refactor: extract MainPage into focused controllers with tests", - "type": "refactor" - }, - { - "hash": "21d1622893919cee4d22d5a87b8245a6d0066db8", - "date": "2026-03-19 17:29:20 +0100", - "message": "fix: remove frozen video tiles when remote user disables camera", - "type": "fix" - }, - { - "hash": "d28321fe48d385dc277d5b0bca84adc49bb0295d", - "date": "2026-03-19 17:19:56 +0100", - "message": "fix: add USE_VIDEO permission to Member role and fix single-user video mode", - "type": "fix" - }, - { - "hash": "594fe3ebb486e2a6cd1f8a06c06635cc993f6a45", - "date": "2026-03-19 17:07:30 +0100", - "message": "fix: add local camera self-view to video grid", - "type": "fix" - }, - { - "hash": "ed7af5ff2d383916a03ba903a74fd1f67e66101e", - "date": "2026-03-19 16:46:49 +0100", - "message": "feat: wire video grid into main page with chat/video toggle", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "a37abb47a266fca465ebaaa6866308f3868df373", - "date": "2026-03-18 11:33:59 +0100", - "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", - "type": "fix" - }, - { - "hash": "9c63dd6efed184908b192fe60162683019a89d5c", - "date": "2026-03-18 11:28:13 +0100", - "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", - "type": "feat" - }, - { - "hash": "c0fa05c67d2f231500025d33aeb2bbd6176c7b01", - "date": "2026-03-18 07:03:53 +0100", - "message": "fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)", - "type": "fix" - }, - { - "hash": "3b81396964b1a2c762cd28e6569115dfb3541445", - "date": "2026-03-18 06:45:05 +0100", - "message": "fix: device switching, DM highlight, WebRTC error toast, close false positives (BUG-031, BUG-032, BUG-033, BUG-034, BUG-035, BUG-036)", - "type": "fix" - }, - { - "hash": "48be1e43e8ec64036fcf7aa4e89afca70238760f", - "date": "2026-03-18 06:35:55 +0100", - "message": "fix: message operations — reaction toggle, delete confirm, edit validation, toasts (BUG-024, BUG-028, BUG-029, BUG-037, BUG-038)", - "type": "fix" - }, - { - "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", - "date": "2026-03-18 06:33:17 +0100", - "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", - "type": "fix" - }, - { - "hash": "c1e2733f11a746aef74897bed712f69693e72acb", - "date": "2026-03-18 06:28:45 +0100", - "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", - "type": "fix" - }, - { - "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", - "date": "2026-03-18 04:13:12 +0100", - "message": "fix: voice session cleanup on ICE close and connection failure", - "type": "fix" - }, - { - "hash": "1f8c517df8560f8715ee1f5d44e7ca5b84260ed7", - "date": "2026-03-18 04:02:05 +0100", - "message": "feat: wire voiceSession into MainPage and main.ts lifecycle", - "type": "feat" - }, - { - "hash": "00bbb46b62c3aab6e5791795c3602ed2a1f05ed8", - "date": "2026-03-18 02:41:55 +0100", - "message": "fix: reject duplicate WebSocket logins to prevent reconnect ping-pong", - "type": "fix" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", - "date": "2026-03-17 20:03:34 +0100", - "message": "fix: voice channel join/leave visibility and disconnect bugs", - "type": "fix" - }, - { - "hash": "1914067941b8512b895e32075fe2ced0b3da3841", - "date": "2026-03-17 03:20:37 +0100", - "message": "fix: address PR review findings (issues #3-#8)", - "type": "fix" - }, - { - "hash": "644bd05842886115c24d3bd1c1ceeaca79fd1880", - "date": "2026-03-17 02:21:03 +0100", - "message": "refactor: split MainPage.ts into ChatHeader and OverlayManagers modules", - "type": "refactor" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", - "date": "2026-03-15 20:43:13 +0100", - "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "tauri-rust": { - "count": 20, - "lastCommit": "2026-03-30 16:35:02 +0200", - "commits": [ - { - "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", - "date": "2026-03-30 16:35:02 +0200", - "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", - "type": "test" - }, - { - "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", - "date": "2026-03-28 20:42:37 +0100", - "message": "feat: add observability, debugging, and diagnostics across all layers", - "type": "feat" - }, - { - "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", - "date": "2026-03-28 18:23:18 +0100", - "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", - "type": "fix" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "27f05a5a1ac1426b9758a9222aaec4f0ab9cb1b6", - "date": "2026-03-27 08:48:54 +0100", - "message": "fix: remember password now actually stores the password", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", - "date": "2026-03-24 20:23:40 +0100", - "message": "fix: address code review — security hardening, leak fixes, credential safety", - "type": "fix" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", - "date": "2026-03-20 07:11:45 +0100", - "message": "fix: speaking ring, devtools, CSP, and connection fixes", - "type": "fix" - }, - { - "hash": "f8e41180f48632937e8184d48106dc05287c1721", - "date": "2026-03-20 02:21:53 +0100", - "message": "fix: settings bugs, accessibility, and camera stream leak", - "type": "fix" - }, - { - "hash": "d1ec5883f688ea130d2be723204ba1d7ca904c49", - "date": "2026-03-19 17:54:13 +0100", - "message": "docs: update documentation for video chat, GIF picker, PTT, and notifications", - "type": "docs" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", - "date": "2026-03-18 17:10:16 +0100", - "message": "feat: native file downloads, upload size fix, native E2E tests", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "2cc0be227e0aa31170a6fdc79af660de16d5ecae", - "date": "2026-03-17 14:54:50 +0100", - "message": "fix: address PR #15 review issues (#16-#23)", - "type": "fix" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "go:api": { - "count": 53, - "lastCommit": "2026-03-29 21:31:18 +0200", - "commits": [ - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", - "date": "2026-03-29 19:39:22 +0200", - "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", - "type": "fix" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", - "date": "2026-03-28 20:42:37 +0100", - "message": "feat: add observability, debugging, and diagnostics across all layers", - "type": "feat" - }, - { - "hash": "894b4ed829e6820d6199121f4c6709363df7280d", - "date": "2026-03-28 13:21:07 +0100", - "message": "fix: resolve all 11 open bugs, add account deletion, harden security", - "type": "fix" - }, - { - "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", - "date": "2026-03-28 11:24:31 +0100", - "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", - "type": "feat" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "3dff9fef64db54cdfe153d48f46d9cc7d7bac9d0", - "date": "2026-03-27 13:58:48 +0100", - "message": "feat(server): add DM REST endpoints, WebSocket routing, and ready payload", - "type": "feat" - }, - { - "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", - "date": "2026-03-26 20:53:09 +0100", - "message": "fix: voice mute pipeline, security hardening, and video tile controls", - "type": "fix" - }, - { - "hash": "738f4970576467e285c051549a4d78241966958c", - "date": "2026-03-24 21:35:40 +0100", - "message": "fix: address code review — 8 issues across server and client", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", - "date": "2026-03-24 20:23:40 +0100", - "message": "fix: address code review — security hardening, leak fixes, credential safety", - "type": "fix" - }, - { - "hash": "076402ab010e0eb1659cb776bd8bee8417d31e48", - "date": "2026-03-22 20:06:59 +0100", - "message": "fix: resolve CI failures — lint errors and coverage threshold", - "type": "fix" - }, - { - "hash": "19aa699f17ecb3cadf770b70b15009dd8e1572fb", - "date": "2026-03-22 19:29:42 +0100", - "message": "fix: address CEO review findings — health check, error logging, camera guard, metrics, tests", - "type": "fix" - }, - { - "hash": "f52e4d7e49852cf3cbae2ba789a1b382dcc6f1c8", - "date": "2026-03-21 21:42:14 +0100", - "message": "refactor: deduplicate pin handler and DB scan logic", - "type": "refactor" - }, - { - "hash": "85ef4f0d1d8d9e6b9eb2550f4d0b9b74542b792a", - "date": "2026-03-21 21:36:10 +0100", - "message": "feat: add server-side pin/unpin endpoints", - "type": "feat" - }, - { - "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", - "date": "2026-03-21 12:34:31 +0100", - "message": "fix: virtual scroll jumping with images/GIFs", - "type": "fix" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", - "date": "2026-03-20 06:11:09 +0100", - "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", - "type": "fix" - }, - { - "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", - "date": "2026-03-20 05:34:38 +0100", - "message": "feat: add LiveKit webhook handler (Phase 1.5)", - "type": "feat" - }, - { - "hash": "a7b53d42f30bbdf99d13a943d0e3b24aab261eb2", - "date": "2026-03-20 05:30:27 +0100", - "message": "feat: rewrite server voice handlers for LiveKit (Phase 1)", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "22706a795581a003059a9838ace4ac05d43fefb2", - "date": "2026-03-19 06:20:00 +0100", - "message": "fix: resolve CI failures in server lint and client tests", - "type": "fix" - }, - { - "hash": "b15738c4d80f4983f31d1076e8aaff07171a0a7c", - "date": "2026-03-19 05:32:40 +0100", - "message": "feat: redesign admin panel, add live server logs and audit log filters", - "type": "feat" - }, - { - "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", - "date": "2026-03-19 04:19:03 +0100", - "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", - "type": "fix" - }, - { - "hash": "a1d1560e76b058fe991d539d5ec03829a15e5ce8", - "date": "2026-03-19 04:04:53 +0100", - "message": "fix: resolve 6 medium issues from full go-review", - "type": "fix" - }, - { - "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", - "date": "2026-03-19 03:53:40 +0100", - "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", - "type": "fix" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "6f4579880e52dc38c7130a009cfacd2e6fb67b59", - "date": "2026-03-18 17:10:16 +0100", - "message": "feat: native file downloads, upload size fix, native E2E tests", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "79f0040f376c989650af60b35a7449b45f40935b", - "date": "2026-03-17 02:56:19 +0100", - "message": "feat: server enhancements, client test selectors, and UI polish", - "type": "feat" - }, - { - "hash": "46e811031e23a6631ab475926af034e35802903a", - "date": "2026-03-16 17:17:00 +0100", - "message": "test: add authorization and contract tests for channel access", - "type": "test" - }, - { - "hash": "642cd541c781649698799245a6a3d0f4a8ae0a02", - "date": "2026-03-16 17:00:09 +0100", - "message": "feat: add attachment persistence and link on chat_send (High #3)", - "type": "feat" - }, - { - "hash": "0bd9165f61f361a5acb9fb85ffd0e3cf8aca6d29", - "date": "2026-03-16 16:57:01 +0100", - "message": "feat: align REST responses with API.md spec (High #1)", - "type": "feat" - }, - { - "hash": "0680a32496e69f60a21d76a80d2a2d40244fe805", - "date": "2026-03-16 16:54:56 +0100", - "message": "fix: resolve 4 Critical + 2 High + 1 Medium server protocol violations", - "type": "fix" - }, - { - "hash": "aa194f45b39c17f9bf74868941f3e31dd5bf73e4", - "date": "2026-03-15 12:21:10 +0100", - "message": "fix: prevent stale \"online\" status for users not connected via WebSocket", - "type": "fix" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", - "date": "2026-03-15 00:31:39 +0100", - "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", - "type": "feat" - }, - { - "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", - "date": "2026-03-14 22:05:13 +0100", - "message": "feat: implement server auto-update API endpoints with download, verify, and restart", - "type": "feat" - }, - { - "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", - "date": "2026-03-14 21:31:03 +0100", - "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", - "type": "feat" - }, - { - "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", - "date": "2026-03-14 21:17:09 +0100", - "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", - "type": "feat" - }, - { - "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", - "date": "2026-03-14 20:52:11 +0100", - "message": "feat: implement Phase 2 auth & security with TDD", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:auth": { - "count": 9, - "lastCommit": "2026-03-29 21:31:18 +0200", - "commits": [ - { - "hash": "9bb99f76eb90b28754f50ffd584c3246c592f3b3", - "date": "2026-03-29 21:31:18 +0200", - "message": "feat: TOTP 2FA settings UI, server hardening, full validation pass", - "type": "feat" - }, - { - "hash": "d8ce044436771842f39f679dcf62c189b9b755dc", - "date": "2026-03-29 19:39:22 +0200", - "message": "fix: atomic invite registration, fail-closed search, proxy-aware rate limiting", - "type": "fix" - }, - { - "hash": "738f4970576467e285c051549a4d78241966958c", - "date": "2026-03-24 21:35:40 +0100", - "message": "fix: address code review — 8 issues across server and client", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", - "date": "2026-03-14 20:52:11 +0100", - "message": "feat: implement Phase 2 auth & security with TDD", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "ts:main.ts": { - "count": 22, - "lastCommit": "2026-03-29 19:40:11 +0200", - "commits": [ - { - "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", - "date": "2026-03-29 19:40:11 +0200", - "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", - "type": "chore" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "9f31e8b017a0004f8d7d04c33741a484edd7557b", - "date": "2026-03-28 20:42:37 +0100", - "message": "feat: add observability, debugging, and diagnostics across all layers", - "type": "feat" - }, - { - "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", - "date": "2026-03-28 11:24:31 +0100", - "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", - "type": "feat" - }, - { - "hash": "3c98e1b984e181df2f58387880b6e2332b7258dc", - "date": "2026-03-27 12:34:28 +0100", - "message": "feat: wire quick-switch overlay to disconnect and navigate to ConnectPage", - "type": "feat" - }, - { - "hash": "72f34ea8f6d9a9ad82e60d82c62b14ce98c0e0de", - "date": "2026-03-27 12:09:56 +0100", - "message": "feat: restore saved theme on app startup", - "type": "feat" - }, - { - "hash": "7715ac29553df52a6632fe34729823ca99a5f299", - "date": "2026-03-27 11:32:36 +0100", - "message": "feat: add OC Neon Glow theme CSS with theme contract variables", - "type": "feat" - }, - { - "hash": "27f05a5a1ac1426b9758a9222aaec4f0ab9cb1b6", - "date": "2026-03-27 08:48:54 +0100", - "message": "fix: remember password now actually stores the password", - "type": "fix" - }, - { - "hash": "2dfb73083f42c00a354cac57187d47c2e8f19401", - "date": "2026-03-20 15:21:56 +0100", - "message": "refactor: UI architecture improvements + GIF auto-pause", - "type": "refactor" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "2df58251ac5c674fb73d0079f42f0e012878fe20", - "date": "2026-03-20 07:11:45 +0100", - "message": "fix: speaking ring, devtools, CSP, and connection fixes", - "type": "fix" - }, - { - "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", - "date": "2026-03-20 05:46:19 +0100", - "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "a37abb47a266fca465ebaaa6866308f3868df373", - "date": "2026-03-18 11:33:59 +0100", - "message": "fix: remove duplicate mute/deafen buttons from user bar, disable browser context menu", - "type": "fix" - }, - { - "hash": "9cc9ae95cc69cf85ad2e49826cd9aa89ca6bbfed", - "date": "2026-03-18 04:13:12 +0100", - "message": "fix: voice session cleanup on ICE close and connection failure", - "type": "fix" - }, - { - "hash": "1f8c517df8560f8715ee1f5d44e7ca5b84260ed7", - "date": "2026-03-18 04:02:05 +0100", - "message": "feat: wire voiceSession into MainPage and main.ts lifecycle", - "type": "feat" - }, - { - "hash": "fd3b65b0655ee2fc043491eccf2f0d67b0acd122", - "date": "2026-03-17 20:03:34 +0100", - "message": "fix: voice channel join/leave visibility and disconnect bugs", - "type": "fix" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", - "date": "2026-03-15 20:43:13 +0100", - "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "ts:stores": { - "count": 28, - "lastCommit": "2026-03-29 19:40:11 +0200", - "commits": [ - { - "hash": "b386163dabdaa4c046a692cdeb517974affa4ec5", - "date": "2026-03-29 19:40:11 +0200", - "message": "chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update", - "type": "chore" - }, - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "8a972fad433ef4a3ee7316c966749b5f04812b2e", - "date": "2026-03-29 00:51:54 +0100", - "message": "feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements", - "type": "feat" - }, - { - "hash": "a9dcb47b6d946e50bfd8ab5015cfdf53724e5f2b", - "date": "2026-03-28 11:24:31 +0100", - "message": "feat: auto-login, online user count, DM sidebar improvements, periodic health checks", - "type": "feat" - }, - { - "hash": "c2b5d8e15b814e7ce5d065446fce738b8f7d3c4c", - "date": "2026-03-28 10:39:23 +0100", - "message": "feat: comprehensive spec docs, test suite, E2E overhaul, and security hardening", - "type": "feat" - }, - { - "hash": "c76534a2230904b773316c6d74c1e13b80cff4d4", - "date": "2026-03-27 16:14:54 +0100", - "message": "fix: security hardening, DM auth, LiveKit stability, and voice call timer", - "type": "fix" - }, - { - "hash": "cb07a57254d6c6e981971229003f2b1fb914909b", - "date": "2026-03-27 14:31:00 +0100", - "message": "fix: filter DM channels from channel sidebar list", - "type": "fix" - }, - { - "hash": "e5afe7d20136f78e09ffd7fd951814a036c84fb3", - "date": "2026-03-27 14:08:25 +0100", - "message": "feat(client): add DM store, API methods, dispatcher events, and sidebar wiring", - "type": "feat" - }, - { - "hash": "a34f4cb971b4439e84707dc8a939f66e07ec197c", - "date": "2026-03-27 12:39:46 +0100", - "message": "feat: persist collapsible section state per-server in localStorage", - "type": "feat" - }, - { - "hash": "80cd05923678a8552faf970c3f1e101d44f48938", - "date": "2026-03-27 12:09:47 +0100", - "message": "feat: add neon-glow to theme selector and apply body class on theme switch", - "type": "feat" - }, - { - "hash": "7be56f95458b31c8435ce50ef52d60bd1820639b", - "date": "2026-03-27 11:29:23 +0100", - "message": "feat: add sidebarMode and activeDmUserId to UI store", - "type": "feat" - }, - { - "hash": "394b8e4b2da490e269e5d4760ad66925cf4180d1", - "date": "2026-03-27 08:05:32 +0100", - "message": "fix: client code review — 27 fixes across 17 files", - "type": "fix" - }, - { - "hash": "738f4970576467e285c051549a4d78241966958c", - "date": "2026-03-24 21:35:40 +0100", - "message": "fix: address code review — 8 issues across server and client", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "91ebce72727a54fb96c72dadeb257bd92124ef4f", - "date": "2026-03-20 06:33:57 +0100", - "message": "fix: wire LiveKit speaker detection to voice activation ring", - "type": "fix" - }, - { - "hash": "5d67e91ca680247b84ee2f26e23e6d0ba607cf1c", - "date": "2026-03-20 05:46:19 +0100", - "message": "feat: replace client WebRTC with LiveKit SDK (Phase 2)", - "type": "feat" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "6dbb945213901429f50a77c4648a5eab67271418", - "date": "2026-03-18 14:13:52 +0100", - "message": "feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements", - "type": "feat" - }, - { - "hash": "9c63dd6efed184908b192fe60162683019a89d5c", - "date": "2026-03-18 11:28:13 +0100", - "message": "feat: channel management — create, edit, delete, reorder with category-type enforcement", - "type": "feat" - }, - { - "hash": "e1a0610f9fdc77f576817903e35dc4ca1891630a", - "date": "2026-03-18 06:33:17 +0100", - "message": "fix: wire voice controls — camera, screenshare, UserBar mute/deafen, VAD (BUG-021, BUG-022, BUG-023, BUG-027)", - "type": "fix" - }, - { - "hash": "c1e2733f11a746aef74897bed712f69693e72acb", - "date": "2026-03-18 06:28:45 +0100", - "message": "fix: wire account settings callbacks and theme store sync (BUG-020, BUG-025)", - "type": "fix" - }, - { - "hash": "1548e6496c3677160a0f29538f77edc7276ba37c", - "date": "2026-03-17 20:25:15 +0100", - "message": "feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes", - "type": "feat" - }, - { - "hash": "45d1cf9747814e7688155e10a0637e5118e6ae54", - "date": "2026-03-17 01:59:34 +0100", - "message": "fix: resolve 15 post-review issues across server and client", - "type": "fix" - }, - { - "hash": "db982a7cf8f373c74fb56724f0b8d468c018695d", - "date": "2026-03-16 16:43:46 +0100", - "message": "feat: fix all E2E failures, add credentials/window-state, wire QuickSwitcher + SettingsOverlay", - "type": "feat" - }, - { - "hash": "a08755e3be44d60e43db3ac1f0a77294add85f63", - "date": "2026-03-15 20:43:13 +0100", - "message": "feat: align UI to mockup, wire WS handlers, fix 5 HIGH review issues", - "type": "feat" - }, - { - "hash": "dbbe55275786aa58877f1ade8cf4cd18cb2513e3", - "date": "2026-03-15 19:44:02 +0100", - "message": "feat: add Tauri v2 desktop client with full chat UI and security hardening", - "type": "feat" - } - ] - }, - "go:go.mod": { - "count": 10, - "lastCommit": "2026-03-29 19:39:46 +0200", - "commits": [ - { - "hash": "97f186041da8052d054cff73c93f23886bd374d5", - "date": "2026-03-29 19:39:46 +0200", - "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", - "type": "refactor" - }, - { - "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", - "date": "2026-03-20 05:34:38 +0100", - "message": "feat: add LiveKit webhook handler (Phase 1.5)", - "type": "feat" - }, - { - "hash": "a7b53d42f30bbdf99d13a943d0e3b24aab261eb2", - "date": "2026-03-20 05:30:27 +0100", - "message": "feat: rewrite server voice handlers for LiveKit (Phase 1)", - "type": "feat" - }, - { - "hash": "923d071a71342c771f3314744e81f5f16e8ce845", - "date": "2026-03-20 05:14:52 +0100", - "message": "feat: add LiveKit infrastructure (Phase 0)", - "type": "feat" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", - "date": "2026-03-14 21:59:58 +0100", - "message": "feat: add updater package with GitHub Release checking and checksum verification", - "type": "feat" - }, - { - "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", - "date": "2026-03-14 21:17:09 +0100", - "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", - "type": "feat" - }, - { - "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", - "date": "2026-03-14 20:52:11 +0100", - "message": "feat: implement Phase 2 auth & security with TDD", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:go.sum": { - "count": 9, - "lastCommit": "2026-03-29 19:39:46 +0200", - "commits": [ - { - "hash": "97f186041da8052d054cff73c93f23886bd374d5", - "date": "2026-03-29 19:39:46 +0200", - "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", - "type": "refactor" - }, - { - "hash": "3d37758baf1388f939b030d9f596fe88b4c6735b", - "date": "2026-03-20 05:34:38 +0100", - "message": "feat: add LiveKit webhook handler (Phase 1.5)", - "type": "feat" - }, - { - "hash": "923d071a71342c771f3314744e81f5f16e8ce845", - "date": "2026-03-20 05:14:52 +0100", - "message": "feat: add LiveKit infrastructure (Phase 0)", - "type": "feat" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", - "date": "2026-03-14 21:59:58 +0100", - "message": "feat: add updater package with GitHub Release checking and checksum verification", - "type": "feat" - }, - { - "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", - "date": "2026-03-14 21:17:09 +0100", - "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", - "type": "feat" - }, - { - "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", - "date": "2026-03-14 20:52:11 +0100", - "message": "feat: implement Phase 2 auth & security with TDD", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:main.go": { - "count": 12, - "lastCommit": "2026-03-29 19:39:46 +0200", - "commits": [ - { - "hash": "97f186041da8052d054cff73c93f23886bd374d5", - "date": "2026-03-29 19:39:46 +0200", - "message": "refactor: context propagation, LogAudit deadlock fix, ESLint v9, code quality", - "type": "refactor" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "b15738c4d80f4983f31d1076e8aaff07171a0a7c", - "date": "2026-03-19 05:32:40 +0100", - "message": "feat: redesign admin panel, add live server logs and audit log filters", - "type": "feat" - }, - { - "hash": "69564df29b525fbc0a37af95cb5392eb2c4d04f7", - "date": "2026-03-19 03:53:40 +0100", - "message": "fix: resolve 1 critical and 5 high security/reliability issues from go-review", - "type": "fix" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "aa194f45b39c17f9bf74868941f3e31dd5bf73e4", - "date": "2026-03-15 12:21:10 +0100", - "message": "fix: prevent stale \"online\" status for users not connected via WebSocket", - "type": "fix" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", - "date": "2026-03-14 22:05:13 +0100", - "message": "feat: implement server auto-update API endpoints with download, verify, and restart", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:storage": { - "count": 6, - "lastCommit": "2026-03-29 12:35:04 +0200", - "commits": [ - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "c3b44d1972c5d09a75d5316dd599d62201c02e63", - "date": "2026-03-21 10:08:44 +0100", - "message": "refactor: server hardening + client decomposition + protocol resilience", - "type": "refactor" - }, - { - "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", - "date": "2026-03-18 05:03:38 +0100", - "message": "test: boost server test coverage to 80%+ across all packages", - "type": "test" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:updater": { - "count": 10, - "lastCommit": "2026-03-29 12:35:04 +0200", - "commits": [ - { - "hash": "9ec875d450b53bebb4a18f6e7b1c27715d44e4c1", - "date": "2026-03-29 12:35:04 +0200", - "message": "fix: security hardening — 45 issues from full-project Copilot audit", - "type": "fix" - }, - { - "hash": "680f060dda8d1c82208c61f66f8d46f3e68e6228", - "date": "2026-03-19 09:49:21 +0100", - "message": "feat: add negative caching to updater to avoid API spam", - "type": "feat" - }, - { - "hash": "22706a795581a003059a9838ace4ac05d43fefb2", - "date": "2026-03-19 06:20:00 +0100", - "message": "fix: resolve CI failures in server lint and client tests", - "type": "fix" - }, - { - "hash": "ce6204c4635763b1fa0564e6ae44cf42ae37b965", - "date": "2026-03-19 04:19:03 +0100", - "message": "fix: resolve 5 remaining medium/low issues from third-pass go-review", - "type": "fix" - }, - { - "hash": "8272d7e2f97e9661b097cdd8686dc1cdbc739e7e", - "date": "2026-03-18 17:47:59 +0100", - "message": "feat: client auto-update with Ed25519 signing and dynamic server URL", - "type": "feat" - }, - { - "hash": "70a8b5f2b80b8f1daf8471d31b6b89a3b0dbf824", - "date": "2026-03-18 05:03:38 +0100", - "message": "test: boost server test coverage to 80%+ across all packages", - "type": "test" - }, - { - "hash": "8d47f4c2fa78267171ed52aed957639f1842978b", - "date": "2026-03-17 08:09:52 +0100", - "message": "fix: resolve all golangci-lint issues blocking CI server build", - "type": "fix" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", - "date": "2026-03-14 22:05:13 +0100", - "message": "feat: implement server auto-update API endpoints with download, verify, and restart", - "type": "feat" - }, - { - "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", - "date": "2026-03-14 21:59:58 +0100", - "message": "feat: add updater package with GitHub Release checking and checksum verification", - "type": "feat" - } - ] - }, - "go:.air.toml": { - "count": 1, - "lastCommit": "2026-03-29 12:19:08 +0200", - "commits": [ - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - } - ] - }, - "go:permissions": { - "count": 2, - "lastCommit": "2026-03-29 12:19:08 +0200", - "commits": [ - { - "hash": "d7a2e5b8f59808e451c4b0c89e680e76bdf182a5", - "date": "2026-03-29 12:19:08 +0200", - "message": "refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements", - "type": "refactor" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - } - ] - }, - "go:config": { - "count": 16, - "lastCommit": "2026-03-28 18:23:18 +0100", - "commits": [ - { - "hash": "297a3694217b29afb61b685af6a280933b1cdbb7", - "date": "2026-03-28 18:23:18 +0100", - "message": "fix: LiveKit voice connection for remote clients behind reverse proxy", - "type": "fix" - }, - { - "hash": "3d50b0570d0f314d727b954403e2d7ab8a059038", - "date": "2026-03-26 20:53:09 +0100", - "message": "fix: voice mute pipeline, security hardening, and video tile controls", - "type": "fix" - }, - { - "hash": "2794662a48d480ba06add8132cba2d6f9adfaf5f", - "date": "2026-03-24 21:30:23 +0100", - "message": "feat: LiveKit migration — permissions, auth hardening, voice improvements", - "type": "feat" - }, - { - "hash": "edf4d9e48b0eb417a5bd7003d18688b81bc8dbbb", - "date": "2026-03-24 20:23:40 +0100", - "message": "fix: address code review — security hardening, leak fixes, credential safety", - "type": "fix" - }, - { - "hash": "0b2106af886fe3e3719337ea9a299883ab12b102", - "date": "2026-03-20 12:30:12 +0100", - "message": "fix: camera button delay and video feed flickering + security hardening", - "type": "fix" - }, - { - "hash": "da760b34bf2c956e3597cd6aafafe7c22ba133e8", - "date": "2026-03-20 06:11:09 +0100", - "message": "fix: proxy LiveKit through HTTPS to fix mixed-content block", - "type": "fix" - }, - { - "hash": "923d071a71342c771f3314744e81f5f16e8ce845", - "date": "2026-03-20 05:14:52 +0100", - "message": "feat: add LiveKit infrastructure (Phase 0)", - "type": "feat" - }, - { - "hash": "e972d06765c6189ba524cdf7744db5c957ea0d2d", - "date": "2026-03-19 12:23:24 +0100", - "message": "feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes", - "type": "feat" - }, - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - }, - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "46b7efe5fcacb4df88c54ef22135afdbb515d368", - "date": "2026-03-15 07:07:59 +0100", - "message": "feat: add Let's Encrypt ACME support, fix security issues, improve server UX", - "type": "feat" - }, - { - "hash": "d8dbb081cbe4f0981285f27e25a78819b1c27cab", - "date": "2026-03-14 22:17:47 +0100", - "message": "fix: set default TLS cert/key paths to data/cert.pem and data/key.pem", - "type": "fix" - }, - { - "hash": "b4d5de6cb70f565b34ea7e4bf74b3db0380800f2", - "date": "2026-03-14 21:59:58 +0100", - "message": "feat: add updater package with GitHub Release checking and checksum verification", - "type": "feat" - }, - { - "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", - "date": "2026-03-14 21:31:03 +0100", - "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "go:migrations": { - "count": 10, - "lastCommit": "2026-03-27 13:46:06 +0100", - "commits": [ - { - "hash": "c8d547ce15643085eaed6e65c33044eef098f427", - "date": "2026-03-27 13:46:06 +0100", - "message": "feat(server): add DM schema migration and database query layer", - "type": "feat" - }, - { - "hash": "393d277f19a89a5987b0e0fc3993a360f3b62171", - "date": "2026-03-21 12:34:31 +0100", - "message": "fix: virtual scroll jumping with images/GIFs", - "type": "fix" - }, - { - "hash": "1f1432376acf6cda97b3c58fbaeddb62efabf5b0", - "date": "2026-03-21 11:59:14 +0100", - "message": "fix: security hardening, LiveKit class refactor, and eng review fixes", - "type": "fix" - }, - { - "hash": "d28321fe48d385dc277d5b0bca84adc49bb0295d", - "date": "2026-03-19 17:19:56 +0100", - "message": "fix: add USE_VIDEO permission to Member role and fix single-user video mode", - "type": "fix" - }, - { - "hash": "e92b8fe2df616937c26338fe56319977db194497", - "date": "2026-03-17 11:05:52 +0100", - "message": "feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage", - "type": "feat" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - }, - { - "hash": "19127598984b1f26e2099afae0c4e9c21675c46b", - "date": "2026-03-15 00:31:39 +0100", - "message": "feat: redesign login UI, add save-password, fix permissions, add audit logging, member_join broadcast", - "type": "feat" - }, - { - "hash": "dcae0b91b23c178e3b747cdf25176c8d09ffd103", - "date": "2026-03-14 21:37:47 +0100", - "message": "fix: correct embed path (static not admin/static) and simplify audit_log migration", - "type": "fix" - }, - { - "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", - "date": "2026-03-14 21:31:03 +0100", - "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - }, - "ts:types": { - "count": 1, - "lastCommit": "2026-03-18 23:02:06 +0100", - "commits": [ - { - "hash": "5327b2681a0e31e4342bac24af20dfbb6299df29", - "date": "2026-03-18 23:02:06 +0100", - "message": "feat: fix voice chat over NAT, audio pipeline, and add comprehensive debugging", - "type": "feat" - } - ] - }, - "go:.golangci.yml": { - "count": 1, - "lastCommit": "2026-03-17 04:11:04 +0100", - "commits": [ - { - "hash": "84f938a6db26d4fbe9db6da0acb20deea53110c3", - "date": "2026-03-17 04:11:04 +0100", - "message": "fix: address PR review findings (issues #9-#14)", - "type": "fix" - } - ] - }, - "go:ws_cov.out": { - "count": 2, - "lastCommit": "2026-03-15 16:54:55 +0100", - "commits": [ - { - "hash": "743a2d974738f76db7764bdd43e78fbf25889d4f", - "date": "2026-03-15 16:54:55 +0100", - "message": "chore: update .gitignore to exclude local tooling, build artifacts, and internal docs", - "type": "chore" - }, - { - "hash": "48a0db7224b708bcb7360c1897b78611f0d954f0", - "date": "2026-03-15 11:42:25 +0100", - "message": "feat: implement full client UI from mockup — 10 phases, 331 tests", - "type": "feat" - } - ] - }, - "go:chatserver.exe": { - "count": 6, - "lastCommit": "2026-03-14 22:38:08 +0100", - "commits": [ - { - "hash": "07636447ab328ced8a3561a1446df76561259010", - "date": "2026-03-14 22:38:08 +0100", - "message": "chore: gitignore server runtime artifacts (binary, config, data)", - "type": "chore" - }, - { - "hash": "bcf5f77a4de40e84d40aeedaaa5d7833235341d2", - "date": "2026-03-14 22:05:13 +0100", - "message": "feat: implement server auto-update API endpoints with download, verify, and restart", - "type": "feat" - }, - { - "hash": "dad5607721aba88da76b554e6b1af9cadcaa1e3b", - "date": "2026-03-14 21:31:03 +0100", - "message": "feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)", - "type": "feat" - }, - { - "hash": "7b4f6cda1c40d841d3cef62d119f3d1b423e83e9", - "date": "2026-03-14 21:17:09 +0100", - "message": "feat: implement Phase 4 real-time chat (WebSocket hub + message REST)", - "type": "feat" - }, - { - "hash": "7d03f59c2fb92f8a71bd680cf2303ce5a92e3860", - "date": "2026-03-14 20:52:11 +0100", - "message": "feat: implement Phase 2 auth & security with TDD", - "type": "feat" - }, - { - "hash": "5868fdd0b3b00f542ac6fabf4afdcab847dc4a98", - "date": "2026-03-14 20:34:37 +0100", - "message": "feat: implement Phase 1 server skeleton with TDD", - "type": "feat" - } - ] - } - }, - "fileChurn": [ - { - "file": "Client/tauri-client/src/styles/app.css", - "commits": 54, - "module": "ts:styles" - }, - { - "file": "Client/tauri-client/src/pages/MainPage.ts", - "commits": 44, - "module": "ts:pages" - }, - { - "file": "Server/api/router.go", - "commits": 35, - "module": "go:api" - }, - { - "file": "Client/tauri-client/src/lib/livekitSession.ts", - "commits": 30, - "module": "ts:lib" - }, - { - "file": "CLAUDE.md", - "commits": 28, - "module": "root" - }, - { - "file": "Server/ws/voice_handlers.go", - "commits": 24, - "module": "go:ws" - }, - { - "file": "Client/tauri-client/src/components/SettingsOverlay.ts", - "commits": 23, - "module": "ts:components" - }, - { - "file": "Server/ws/handlers.go", - "commits": 23, - "module": "go:ws" - }, - { - "file": "Server/ws/hub.go", - "commits": 23, - "module": "go:ws" - }, - { - "file": "Client/tauri-client/src/main.ts", - "commits": 22, - "module": "ts:main.ts" - }, - { - "file": "Client/tauri-client/src/components/MessageList.ts", - "commits": 21, - "module": "ts:components" - }, - { - "file": "Client/tauri-client/src/pages/main-page/SidebarArea.ts", - "commits": 21, - "module": "ts:pages" - }, - { - "file": "Server/ws/messages.go", - "commits": 20, - "module": "go:ws" - }, - { - "file": "Client/tauri-client/tests/unit/settings-overlay.test.ts", - "commits": 19, - "module": "ts:config" - }, - { - "file": "Client/tauri-client/src/lib/dispatcher.ts", - "commits": 19, - "module": "ts:lib" - }, - { - "file": "Client/tauri-client/src-tauri/tauri.conf.json", - "commits": 17, - "module": "ts:config" - }, - { - "file": "Client/tauri-client/src/components/message-list/media.ts", - "commits": 17, - "module": "ts:components" - }, - { - "file": "Client/tauri-client/src/components/settings/VoiceAudioTab.ts", - "commits": 17, - "module": "ts:components" - }, - { - "file": "Server/ws/serve.go", - "commits": 17, - "module": "go:ws" - }, - { - "file": ".gitignore", - "commits": 16, - "module": "root" - } - ], - "staleness": { - "root": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 22:36:38 +0200" - }, - "ts:config": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 21:54:24 +0200" - }, - "go:admin": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 21:54:24 +0200" - }, - "go:db": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 21:54:24 +0200" - }, - "go:scripts": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 21:54:24 +0200" - }, - "go:ws": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 21:54:24 +0200" - }, - "client:other": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 21:05:28 +0200" - }, - "ts:components": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 20:13:34 +0200" - }, - "ts:styles": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 20:13:34 +0200" - }, - "ts:lib": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 19:12:36 +0200" - }, - "ts:pages": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 19:12:36 +0200" - }, - "tauri-rust": { - "daysSinceLastCommit": 0, - "lastCommitDate": "2026-03-30 16:35:02 +0200" - }, - "go:api": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 21:31:18 +0200" - }, - "go:auth": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 21:31:18 +0200" - }, - "ts:main.ts": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 19:40:11 +0200" - }, - "ts:stores": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 19:40:11 +0200" - }, - "go:go.mod": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 19:39:46 +0200" - }, - "go:go.sum": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 19:39:46 +0200" - }, - "go:main.go": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 19:39:46 +0200" - }, - "go:storage": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 12:35:04 +0200" - }, - "go:updater": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 12:35:04 +0200" - }, - "go:.air.toml": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 12:19:08 +0200" - }, - "go:permissions": { - "daysSinceLastCommit": 1, - "lastCommitDate": "2026-03-29 12:19:08 +0200" - }, - "go:config": { - "daysSinceLastCommit": 2, - "lastCommitDate": "2026-03-28 18:23:18 +0100" - }, - "go:migrations": { - "daysSinceLastCommit": 3, - "lastCommitDate": "2026-03-27 13:46:06 +0100" - }, - "ts:types": { - "daysSinceLastCommit": 12, - "lastCommitDate": "2026-03-18 23:02:06 +0100" - }, - "go:.golangci.yml": { - "daysSinceLastCommit": 14, - "lastCommitDate": "2026-03-17 04:11:04 +0100" - }, - "go:ws_cov.out": { - "daysSinceLastCommit": 15, - "lastCommitDate": "2026-03-15 16:54:55 +0100" - }, - "go:chatserver.exe": { - "daysSinceLastCommit": 16, - "lastCommitDate": "2026-03-14 22:38:08 +0100" - } - }, - "velocity": { - "daily": [ - { - "date": "2026-03-02", - "count": 0 - }, - { - "date": "2026-03-03", - "count": 0 - }, - { - "date": "2026-03-04", - "count": 0 - }, - { - "date": "2026-03-05", - "count": 0 - }, - { - "date": "2026-03-06", - "count": 0 - }, - { - "date": "2026-03-07", - "count": 0 - }, - { - "date": "2026-03-08", - "count": 0 - }, - { - "date": "2026-03-09", - "count": 0 - }, - { - "date": "2026-03-10", - "count": 0 - }, - { - "date": "2026-03-11", - "count": 0 - }, - { - "date": "2026-03-12", - "count": 0 - }, - { - "date": "2026-03-13", - "count": 0 - }, - { - "date": "2026-03-14", - "count": 21 - }, - { - "date": "2026-03-15", - "count": 14 - }, - { - "date": "2026-03-16", - "count": 6 - }, - { - "date": "2026-03-17", - "count": 23 - }, - { - "date": "2026-03-18", - "count": 35 - }, - { - "date": "2026-03-19", - "count": 46 - }, - { - "date": "2026-03-20", - "count": 17 - }, - { - "date": "2026-03-21", - "count": 28 - }, - { - "date": "2026-03-22", - "count": 31 - }, - { - "date": "2026-03-23", - "count": 0 - }, - { - "date": "2026-03-24", - "count": 4 - }, - { - "date": "2026-03-25", - "count": 4 - }, - { - "date": "2026-03-26", - "count": 7 - }, - { - "date": "2026-03-27", - "count": 48 - }, - { - "date": "2026-03-28", - "count": 13 - }, - { - "date": "2026-03-29", - "count": 13 - }, - { - "date": "2026-03-30", - "count": 22 - }, - { - "date": "2026-03-31", - "count": 0 - } - ], - "weeklyAvg": 15.29, - "trend": "accelerating" - }, - "commitTypes": { - "feat": 107, - "fix": 112, - "test": 15, - "refactor": 10, - "docs": 28, - "chore": 24, - "other": 36 - }, - "recentCommits": [ - { - "hash": "f7372aec3219d36b76bc269bb7940ee914e660eb", - "date": "2026-03-30 22:36:38 +0200", - "message": "docs: add screenshots to README", - "type": "docs", - "modules": [ - "root" - ] - }, - { - "hash": "86541ac3aa4af84c4d787d73a1f0ffc1e4119d7f", - "date": "2026-03-30 22:32:31 +0200", - "message": "chore: gitignore internal docs subdirectories", - "type": "chore", - "modules": [ - "root" - ] - }, - { - "hash": "cec6ca6a97df645d41c781cc257dad24e5a407ca", - "date": "2026-03-30 22:31:06 +0200", - "message": "docs: add public documentation for contributors and users", - "type": "docs", - "modules": [ - "root" - ] - }, - { - "hash": "22daadacfa5199c29a3061743350838ae9b65b0c", - "date": "2026-03-30 22:17:14 +0200", - "message": "Merge pull request #84 from J3vb/dev", - "type": "other", - "modules": [] - }, - { - "hash": "7f31a6aa43b357b8f727f696bf92c7ee9328a0d3", - "date": "2026-03-30 21:54:24 +0200", - "message": "fix: resolve CI failures — eslint peer dep conflict and errcheck lint errors", - "type": "fix", - "modules": [ - "ts:config", - "go:admin", - "go:db", - "go:scripts", - "go:ws" - ] - }, - { - "hash": "d9aaeb5f4c8c018f5ffbd9c64ae87126e5b5348a", - "date": "2026-03-30 21:48:14 +0200", - "message": "fix: admin panel CSP blocking inline event handlers and boolean toggle display", - "type": "fix", - "modules": [ - "go:admin" - ] - }, - { - "hash": "bfe3404e14ab7143410a77c69bf7c28b4a161af1", - "date": "2026-03-30 21:07:50 +0200", - "message": "updated gitignore", - "type": "other", - "modules": [ - "root" - ] - }, - { - "hash": "44390ad886be392b55d1a15995eba0fb9c4a80d1", - "date": "2026-03-30 21:05:28 +0200", - "message": "chore: clean up tracked files for v1.0.0 public release", - "type": "chore", - "modules": [ - "root", - "client:other" - ] - }, - { - "hash": "ad7ac75c74d0351d37e0eded5e4594528041866d", - "date": "2026-03-30 20:50:44 +0200", - "message": "docs: v1.0.0 release prep — version bump, license, README overhaul", - "type": "docs", - "modules": [ - "root", - "ts:config" - ] - }, - { - "hash": "b059bd4a04aead5271422f74c1094760b14e8c38", - "date": "2026-03-30 20:13:34 +0200", - "message": "feat: Discord-style video grid with fixed 16:9 aspect ratio", - "type": "feat", - "modules": [ - "ts:components", - "ts:styles", - "ts:config" - ] - }, - { - "hash": "4f489a1a4d037e16e84d9f0f3e10d9fea7d47065", - "date": "2026-03-30 19:12:36 +0200", - "message": "feat: sidebar stream preview + screenshare focus fix", - "type": "feat", - "modules": [ - "ts:components", - "ts:lib", - "ts:pages", - "ts:styles", - "ts:config", - "root" - ] - }, - { - "hash": "8882cfa330e28f273cd1e53234e4e95b37a6c7eb", - "date": "2026-03-30 16:47:19 +0200", - "message": "docs: regenerate codemaps from current codebase", - "type": "docs", - "modules": [ - "root" - ] - }, - { - "hash": "ad76d4a4139460d24974d6c436ba9782ac5ddfb2", - "date": "2026-03-30 16:37:28 +0200", - "message": "docs: update session log with TS error fix details", - "type": "docs", - "modules": [ - "root" - ] - }, - { - "hash": "1eeaa4909489c565857cebfa24515181a05e8f5f", - "date": "2026-03-30 16:35:02 +0200", - "message": "test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", - "type": "test", - "modules": [ - "root", - "ts:config", - "tauri-rust", - "go:ws" - ] - }, - { - "hash": "3f0d5309374562116f211eab06e170ca2f31818c", - "date": "2026-03-30 14:24:12 +0200", - "message": "yu", - "type": "other", - "modules": [ - "root" - ] - } - ], - "totalCommits30d": 332 -} \ No newline at end of file diff --git a/.cache/project-map/session-data.json b/.cache/project-map/session-data.json deleted file mode 100644 index eb0d4a11..00000000 --- a/.cache/project-map/session-data.json +++ /dev/null @@ -1,619 +0,0 @@ -{ - "timestamp": "2026-03-31T09:23:48.747Z", - "sessions": [ - { - "date": "2025-03-24", - "summary": "Comprehensive file reference documentation review — 28 bugs fixed, all docs updated to match code reality", - "tasksCompleted": 28, - "modulesTouched": [ - "api", - "auth", - "db", - "ws", - "voice", - "permissions", - "config", - "stores", - "components", - "tauri-rust", - "protocol", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2025-03-24-file-reference-review.md" - }, - { - "date": "2026-03-17", - "summary": "Completed all 13 CEO review fixes and raised server test coverage to 80%+", - "tasksCompleted": 14, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "ws", - "config", - "stores", - "tauri-rust", - "tests", - "ci" - ], - "fileName": "2026-03-17-ceo-fixes-and-coverage.md" - }, - { - "date": "2026-03-17", - "summary": "CEO plan review sections 1-8 (HOLD SCOPE) for tauri-migration branch", - "tasksCompleted": 0, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "ws", - "storage", - "config", - "stores", - "pages", - "tauri-rust", - "tests", - "security", - "ci" - ], - "fileName": "2026-03-17-ceo-review-sections-1-8.md" - }, - { - "date": "2026-03-17", - "summary": "CEO plan review of tauri-migration branch — HOLD SCOPE", - "tasksCompleted": 0, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "ws", - "voice", - "permissions", - "storage", - "components", - "pages", - "tauri-rust", - "protocol", - "tests", - "security" - ], - "fileName": "2026-03-17-ceo-review.md" - }, - { - "date": "2026-03-17", - "summary": "Fixed all golangci-lint issues blocking CI, set up project brain vault", - "tasksCompleted": 2, - "modulesTouched": [ - "api", - "ws", - "voice", - "tauri-rust", - "tests", - "docs", - "ci" - ], - "fileName": "2026-03-17-lint-fixes-and-vault-setup.md" - }, - { - "date": "2026-03-17", - "summary": "Pre-landing review of tauri-migration, commit, push, and PR #15 to dev", - "tasksCompleted": 0, - "modulesTouched": [ - "auth", - "db", - "config", - "tauri-rust", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2026-03-17-merge-review-and-pr.md" - }, - { - "date": "2026-03-18", - "summary": "Channel management, file uploads, URL previews, voice fixes, UX polish", - "tasksCompleted": 15, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "voice", - "storage", - "components", - "tauri-rust", - "ci" - ], - "fileName": "2026-03-18-features.md" - }, - { - "date": "2026-03-18", - "summary": "Added native E2E testing via WebView2 CDP", - "tasksCompleted": 1, - "modulesTouched": [ - "api", - "auth", - "voice", - "storage", - "config", - "lib", - "tauri-rust", - "e2e", - "tests", - "docs", - "ci" - ], - "fileName": "2026-03-18-native-e2e.md" - }, - { - "date": "2026-03-18", - "summary": "Voice chat NAT fix, audio pipeline overhaul, debugging infrastructure", - "tasksCompleted": 12, - "modulesTouched": [ - "api", - "auth", - "voice", - "stores", - "components", - "tauri-rust", - "tests", - "ci" - ], - "fileName": "2026-03-18-voice-audio-fixes.md" - }, - { - "date": "2026-03-19", - "summary": "Go code review (4 passes, 19 fixes), admin panel redesign, live server logs, audit log filters, console output cleanup", - "tasksCompleted": 7, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "ws", - "voice", - "permissions", - "storage", - "config", - "lib", - "components", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2026-03-19-admin-panel-and-server-review.md" - }, - { - "date": "2026-03-20", - "summary": "Camera button delay fix, video feed flicker fix, security hardening, documentation sync", - "tasksCompleted": 4, - "modulesTouched": [ - "auth", - "db", - "voice", - "permissions", - "config", - "stores", - "pages", - "tauri-rust", - "protocol", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2026-03-20-livekit-camera-fixes.md" - }, - { - "date": "2026-03-20", - "summary": "", - "tasksCompleted": 0, - "modulesTouched": [ - "admin", - "auth", - "db", - "ws", - "stores", - "protocol", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2026-03-20-research-report.md" - }, - { - "date": "2026-03-21", - "summary": "Implemented 30 actionable fixes from code review audit across server and client", - "tasksCompleted": 30, - "modulesTouched": [ - "api", - "auth", - "db", - "ws", - "voice", - "storage", - "stores", - "components", - "pages", - "tauri-rust", - "e2e", - "tests", - "security", - "ci" - ], - "fileName": "2026-03-21-code-review-fixes.md" - }, - { - "date": "2026-03-22", - "summary": "Competitive research, feature roadmap, GitHub issues, vault build-out", - "tasksCompleted": 10, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "ws", - "voice", - "permissions", - "config", - "lib", - "stores", - "components", - "tauri-rust", - "protocol", - "e2e", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2026-03-22-competitive-research.md" - }, - { - "date": "2026-03-26", - "summary": "Implemented client-side fixes from Codex/code review findings and added targeted regression coverage.", - "tasksCompleted": 1, - "modulesTouched": [ - "api", - "auth", - "ws", - "voice", - "config", - "stores", - "components", - "tests", - "ci" - ], - "fileName": "2026-03-26-client-review-fixes.md" - }, - { - "date": "2026-03-27", - "summary": "Code review (27 fixes), connection quality indicator, remember-password fix, login redesign with OC branding, new app icon.", - "tasksCompleted": 38, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "ws", - "voice", - "storage", - "config", - "lib", - "stores", - "components", - "tauri-rust", - "protocol", - "tests", - "security", - "ci" - ], - "fileName": "2026-03-27-dual-model-code-review.md" - }, - { - "date": "2026-03-28", - "summary": "Spec audit (18 files, 50 fixes), 143 unit tests, E2E overhaul, CSS injection fix", - "tasksCompleted": 8, - "modulesTouched": [ - "api", - "auth", - "db", - "ws", - "voice", - "storage", - "lib", - "stores", - "components", - "tauri-rust", - "e2e", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2026-03-28-specs-tests-e2e.md" - }, - { - "date": "2026-03-29", - "summary": "Client-side 2FA integration — TOTP enrollment/disable UI, api.ts fixes, documentation sync", - "tasksCompleted": 5, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "voice", - "permissions", - "config", - "stores", - "components", - "e2e", - "tests", - "docs", - "security", - "ci" - ], - "fileName": "2026-03-29-2fa-client-integration.md" - }, - { - "date": "2026-03-29", - "summary": "Full-project security + code quality audit, 30 issues fixed", - "tasksCompleted": 27, - "modulesTouched": [ - "admin", - "api", - "auth", - "db", - "ws", - "voice", - "permissions", - "config", - "lib", - "tests", - "security", - "ci" - ], - "fileName": "2026-03-29-security-audit-fixes.md" - }, - { - "date": "2026-03-30", - "summary": "Fixed 10 test quality bugs (BUG-058 through BUG-067)", - "tasksCompleted": 10, - "modulesTouched": [ - "auth", - "voice", - "permissions", - "config", - "tauri-rust", - "e2e", - "tests", - "docs", - "ci" - ], - "fileName": "2026-03-30-bug-remediation.md" - }, - { - "date": "2026-03-30", - "summary": "Full documentation update — README, Changelog, Dashboard, task tracking", - "tasksCompleted": 5, - "modulesTouched": [ - "api", - "auth", - "db", - "voice", - "permissions", - "config", - "components", - "e2e", - "tests", - "docs", - "ci" - ], - "fileName": "2026-03-30-documentation-update.md" - }, - { - "date": "2026-03-30", - "summary": "Sidebar stream preview + screenshare focus fix", - "tasksCompleted": 0, - "modulesTouched": [ - "api", - "auth", - "voice", - "components", - "ci" - ], - "fileName": "2026-03-30-stream-preview.md" - } - ], - "progressOverTime": [ - { - "date": "2025-03-24", - "cumulativeDone": 28, - "sessionDone": 28 - }, - { - "date": "2026-03-17", - "cumulativeDone": 42, - "sessionDone": 14 - }, - { - "date": "2026-03-17", - "cumulativeDone": 42, - "sessionDone": 0 - }, - { - "date": "2026-03-17", - "cumulativeDone": 42, - "sessionDone": 0 - }, - { - "date": "2026-03-17", - "cumulativeDone": 44, - "sessionDone": 2 - }, - { - "date": "2026-03-17", - "cumulativeDone": 44, - "sessionDone": 0 - }, - { - "date": "2026-03-18", - "cumulativeDone": 59, - "sessionDone": 15 - }, - { - "date": "2026-03-18", - "cumulativeDone": 60, - "sessionDone": 1 - }, - { - "date": "2026-03-18", - "cumulativeDone": 72, - "sessionDone": 12 - }, - { - "date": "2026-03-19", - "cumulativeDone": 79, - "sessionDone": 7 - }, - { - "date": "2026-03-20", - "cumulativeDone": 83, - "sessionDone": 4 - }, - { - "date": "2026-03-20", - "cumulativeDone": 83, - "sessionDone": 0 - }, - { - "date": "2026-03-21", - "cumulativeDone": 113, - "sessionDone": 30 - }, - { - "date": "2026-03-22", - "cumulativeDone": 123, - "sessionDone": 10 - }, - { - "date": "2026-03-26", - "cumulativeDone": 124, - "sessionDone": 1 - }, - { - "date": "2026-03-27", - "cumulativeDone": 162, - "sessionDone": 38 - }, - { - "date": "2026-03-28", - "cumulativeDone": 170, - "sessionDone": 8 - }, - { - "date": "2026-03-29", - "cumulativeDone": 175, - "sessionDone": 5 - }, - { - "date": "2026-03-29", - "cumulativeDone": 202, - "sessionDone": 27 - }, - { - "date": "2026-03-30", - "cumulativeDone": 212, - "sessionDone": 10 - }, - { - "date": "2026-03-30", - "cumulativeDone": 217, - "sessionDone": 5 - }, - { - "date": "2026-03-30", - "cumulativeDone": 217, - "sessionDone": 0 - } - ], - "streaks": { - "current": 5, - "longest": 6, - "totalSessions": 22 - }, - "lastSession": { - "date": "2026-03-30", - "summary": "Sidebar stream preview + screenshare focus fix", - "tasksCompleted": 0, - "modulesTouched": [ - "api", - "auth", - "voice", - "components", - "ci" - ] - }, - "inProgress": [], - "recentlyDone": [ - { - "id": "T-197", - "description": "Discord-style video grid with fixed 16:9 aspect ratio", - "date": "2026-03-30" - }, - { - "id": "T-198", - "description": "Sidebar stream preview + screenshare focus fix", - "date": "2026-03-30" - }, - { - "id": "T-199", - "description": "Fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors", - "date": "2026-03-30" - }, - { - "id": "T-200", - "description": "Regenerate codemaps from current codebase", - "date": "2026-03-30" - }, - { - "id": "T-201", - "description": "Full documentation update (README, Changelog, Dashboard, all docs)", - "date": "2026-03-30" - }, - { - "id": "T-192", - "description": "Client 2FA enrollment/disable settings UI", - "date": "2026-03-29" - }, - { - "id": "T-193", - "description": "Client 2FA test coverage — 27 new tests", - "date": "2026-03-29" - }, - { - "id": "T-194", - "description": "Full regression validation pass — all green", - "date": "2026-03-29" - }, - { - "id": "T-023", - "description": "Add TOTP 2FA support — Login challenge: DONE; Server endpoints: DONE; Client enrollment UI: DONE; Client tests: DONE", - "date": "2026-03-29" - }, - { - "id": "T-190", - "description": "Propagate `context.Context` from WS upgrade through all handlers — added `ctx context.Context` field to Client struct (set from `r.Context()` on WS upgrade), updated `MessageHandler` type signature, threaded ctx through all 17 WS handlers across 9 files (chat, presence, reaction, voice, ping). Added `ExecContext`/`QueryRowContext`/`QueryContext`/`BeginTx` context-accepting methods to DB wrapper. Go build + all ws/api/auth/db tests pass", - "date": "2026-03-29" - } - ] -} \ No newline at end of file diff --git a/Server/admin/static/admin-mockup.html b/Server/admin/static/admin-mockup.html deleted file mode 100644 index 583534ee..00000000 --- a/Server/admin/static/admin-mockup.html +++ /dev/null @@ -1,1299 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="UTF-8"> -<meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>OwnCord — Admin Panel - - - - -
- - - - -
-
- - - - - -
- - - - diff --git a/Server/permissions/permissions.go b/Server/permissions/permissions.go index 502bf7c6..5d75d575 100644 --- a/Server/permissions/permissions.go +++ b/Server/permissions/permissions.go @@ -47,13 +47,6 @@ const ( // operations reserved for the owner. const OwnerRolePosition = 100 -// IsOwnerRole reports whether the given role ID is the built-in owner role. -// Use this as an explicit guard in role-modification handlers to prevent -// non-owners from escalating to owner privileges. -func IsOwnerRole(roleID int64) bool { - return roleID == OwnerRoleID -} - // ─── Permission helper functions ───────────────────────────────────────────── // HasPerm reports whether rolePerms contains all bits in requiredPerm. diff --git a/Server/service/service.go b/Server/service/service.go index 2799ec41..081e9689 100644 --- a/Server/service/service.go +++ b/Server/service/service.go @@ -20,7 +20,6 @@ type Services struct { Invites *InviteService Blocks *BlockService Moderation *ModerationService - Voice *VoiceService } // New creates all domain services wired together. @@ -36,6 +35,5 @@ func New(st Store, limiter *auth.RateLimiter) *Services { Invites: NewInviteService(st), Blocks: NewBlockService(st), Moderation: NewModerationService(st, permSvc), - Voice: NewVoiceService(st, permSvc), } } diff --git a/Server/service/voice.go b/Server/service/voice.go deleted file mode 100644 index c0a42866..00000000 --- a/Server/service/voice.go +++ /dev/null @@ -1,150 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "log/slog" - "time" - - "github.com/owncord/server/db" - "github.com/owncord/server/permissions" - "github.com/owncord/server/telemetry" -) - -// VoiceService handles voice state business logic. -type VoiceService struct { - st Store - perm *PermissionService -} - -// NewVoiceService creates a VoiceService. -func NewVoiceService(st Store, perm *PermissionService) *VoiceService { - return &VoiceService{st: st, perm: perm} -} - -// JoinChannel validates the channel, checks ConnectVoice permission, and -// joins the user to the voice channel respecting capacity limits. -// Returns the channel on success so callers can access voice config fields. -func (s *VoiceService) JoinChannel(userID, channelID int64) (*db.Channel, error) { - ctx, span := telemetry.GlobalTracer("service/voice").Start(context.Background(), "VoiceService.JoinChannel", - telemetry.Int64("user_id", userID), - telemetry.Int64("channel_id", channelID), - ) - start := time.Now() - defer func() { - telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start, - telemetry.String("method", "JoinChannel")) - span.End() - }() - - if channelID <= 0 { - return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest) - } - - ch, err := s.st.GetChannel(channelID) - if err != nil || ch == nil { - return nil, fmt.Errorf("%w: channel not found", ErrNotFound) - } - - if !s.perm.HasChannelPerm(userID, channelID, permissions.ConnectVoice) { - return nil, fmt.Errorf("%w: missing CONNECT_VOICE permission", ErrForbidden) - } - - maxUsers := ch.VoiceMaxUsers - if maxUsers > 0 { - if err := s.st.JoinVoiceChannelIfCapacity(userID, channelID, maxUsers); err != nil { - if errors.Is(err, db.ErrChannelFull) { - return nil, fmt.Errorf("%w: voice channel is full", ErrForbidden) - } - slog.Error("VoiceService.JoinChannel JoinVoiceChannelIfCapacity", "err", err, "user_id", userID) - return nil, fmt.Errorf("%w: failed to join voice channel", ErrInternal) - } - } else { - if err := s.st.JoinVoiceChannel(userID, channelID); err != nil { - slog.Error("VoiceService.JoinChannel JoinVoiceChannel", "err", err, "user_id", userID) - return nil, fmt.Errorf("%w: failed to join voice channel", ErrInternal) - } - } - - slog.Info("voice join", "user_id", userID, "channel_id", channelID) - return ch, nil -} - -// LeaveChannel removes the user from their current voice channel. -func (s *VoiceService) LeaveChannel(userID int64) error { - if err := s.st.LeaveVoiceChannel(userID); err != nil { - slog.Error("VoiceService.LeaveChannel", "err", err, "user_id", userID) - return fmt.Errorf("%w: failed to leave voice channel", ErrInternal) - } - - slog.Info("voice leave", "user_id", userID) - return nil -} - -// UpdateMute toggles the mute state for the given user. -func (s *VoiceService) UpdateMute(userID int64, muted bool) error { - if err := s.st.UpdateVoiceMute(userID, muted); err != nil { - slog.Error("VoiceService.UpdateMute", "err", err, "user_id", userID) - return fmt.Errorf("%w: failed to update mute state", ErrInternal) - } - - slog.Debug("voice mute changed", "user_id", userID, "muted", muted) - return nil -} - -// UpdateDeafen toggles the deafen state for the given user. -func (s *VoiceService) UpdateDeafen(userID int64, deafened bool) error { - if err := s.st.UpdateVoiceDeafen(userID, deafened); err != nil { - slog.Error("VoiceService.UpdateDeafen", "err", err, "user_id", userID) - return fmt.Errorf("%w: failed to update deafen state", ErrInternal) - } - - slog.Debug("voice deafen changed", "user_id", userID, "deafened", deafened) - return nil -} - -// ToggleCamera enables or disables the user's camera. When enabling, it -// enforces the maxVideo limit via an atomic check-and-update. Returns true -// if the camera was successfully enabled (or disabled), false if the video -// limit was reached. -func (s *VoiceService) ToggleCamera(userID, channelID int64, enable bool, maxVideo int) (bool, error) { - if !s.perm.HasChannelPerm(userID, channelID, permissions.UseVideo) { - return false, fmt.Errorf("%w: missing USE_VIDEO permission", ErrForbidden) - } - - if enable && maxVideo > 0 { - ok, err := s.st.EnableCameraIfUnderLimit(userID, channelID, maxVideo) - if err != nil { - slog.Error("VoiceService.ToggleCamera EnableCameraIfUnderLimit", "err", err, "user_id", userID) - return false, fmt.Errorf("%w: failed to check video limit", ErrInternal) - } - if !ok { - return false, nil - } - } else { - if err := s.st.UpdateVoiceCamera(userID, enable); err != nil { - slog.Error("VoiceService.ToggleCamera UpdateVoiceCamera", "err", err, "user_id", userID) - return false, fmt.Errorf("%w: failed to update camera state", ErrInternal) - } - } - - slog.Debug("voice camera changed", "user_id", userID, "enabled", enable, "channel_id", channelID) - return true, nil -} - -// ToggleScreenshare enables or disables the user's screen share after -// checking the ShareScreen permission. -func (s *VoiceService) ToggleScreenshare(userID, channelID int64, enable bool) error { - if !s.perm.HasChannelPerm(userID, channelID, permissions.ShareScreen) { - return fmt.Errorf("%w: missing SHARE_SCREEN permission", ErrForbidden) - } - - if err := s.st.UpdateVoiceScreenshare(userID, enable); err != nil { - slog.Error("VoiceService.ToggleScreenshare", "err", err, "user_id", userID) - return fmt.Errorf("%w: failed to update screenshare state", ErrInternal) - } - - slog.Debug("voice screenshare changed", "user_id", userID, "enabled", enable, "channel_id", channelID) - return nil -} From 07b59ca48555f84afff1de01eebbac5a09f9d4d5 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:55:08 +0200 Subject: [PATCH 11/15] docs(plans): amend security plan trackers; close A-2026-07-15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security-hardening-remediation.md: status header now records that only W2-4 and W3-3 remain open (verified by the 2026-07-23 deletion audit), with a staleness note scoping the deleted store/-and-Postgres references as historical. Closes audit finding A-2026-07-15. - security-scan-2026-07-22-remediation.md: F6 recorded as committed (ef58c04); resume checklist trimmed — F3 (voice E2EE identity TOFU) is the only remaining finding. - audit-2026-07-19.md: A-2026-07-15 closure row flipped to RESOLVED. Co-Authored-By: Claude Fable 5 --- docs/audit-2026-07-19.md | 2 +- docs/plans/security-hardening-remediation.md | 11 ++++++++++- docs/plans/security-scan-2026-07-22-remediation.md | 11 +++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index 7b0f100a..37febbf4 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -28,7 +28,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog | A-2026-07-12 | MEDIUM | Abandoned SolidJS beachhead still in-tree; `docs/client-architecture.md` describes the abandoned architecture | CLOSED 2026-07-19 — beachhead, adapters, build plugin, and Solid deps removed; client-architecture.md retired in favor of architecture/client.md | | A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal (correction 2026-07-19: `audit_log_v6` is only a transient rename inside migration 003, not a coexisting table) | OPEN | | A-2026-07-14 | LOW | Scattered client constants (`#5865F2` ×18, `localhost:8443` ×3); 64 timer call sites with manual lifecycle | OPEN | -| A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` partly stale (references deleted `store/postgres.go`) | OPEN | +| A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` partly stale (references deleted `store/postgres.go`) | RESOLVED 2026-07-23 — staleness note added scoping the dead `store/`/Postgres references as historical; status header now records that only W2-4 and W3-3 remain open, with the doc as their tracker of record | | A-2026-07-16 | HIGH | Server-wide permission rule hand-rolled at 2 sites (`RequirePermission` raw any-of bit test; `ModerationService`); channel-level `deny` silently dropped — and cached for 30s — when the override fetch errors, at 2 of 5 sites | RESOLVED 2026-07-23 (D13) — `permissions.HasServerPerm` now owns the server-scoped rule (both sites collapse onto it; multi-bit masks are all-of); both override-fetch sites fail closed (`getOrPopulate` skips the fetch for admins, denies and caches nothing on error; `ListVisibleChannels` returns `ErrInternal`); the fifth D9 site (`GetAccessibleChannelIDs`) routes through `VisibleChannelIDs`. Locked by failing-first tests. See [plans/permission-middleware-consolidation.md](plans/permission-middleware-consolidation.md) | --- diff --git a/docs/plans/security-hardening-remediation.md b/docs/plans/security-hardening-remediation.md index 405290f0..323c56e6 100644 --- a/docs/plans/security-hardening-remediation.md +++ b/docs/plans/security-hardening-remediation.md @@ -1,10 +1,19 @@ # Plan: Remediate security-hardening review regressions -**Status:** design only, not implemented +**Status:** mostly landed — verified 2026-07-23 (deletion audit): every item +except **W2-4** and **W3-3** has been implemented or superseded. This doc is +the tracker of record for those two; close it when they land. **Owner:** TBD **Tracks:** code review of branch `fix/security-hardening-review` (2026-07-17) **Estimated effort:** 2–4 focused days +> **Staleness note (2026-07-23, closes audit A-2026-07-15):** item bodies below +> predate two structural changes — the Postgres backend was deleted outright +> (P1, 2026-07-20) and the `Server/store/` seam was removed in favor of direct +> narrow interfaces on `db` (D3, 2026-07-19). Read `Server/store/postgres.go` +> / `sqlite.go` references as historical; the Postgres halves of W1-3 are moot, +> and its atomic-link half is what W2-4 still needs. + ## Why The `fix/security-hardening-review` branch lands a broad, well-intentioned diff --git a/docs/plans/security-scan-2026-07-22-remediation.md b/docs/plans/security-scan-2026-07-22-remediation.md index f6615be9..e771c683 100644 --- a/docs/plans/security-scan-2026-07-22-remediation.md +++ b/docs/plans/security-scan-2026-07-22-remediation.md @@ -15,7 +15,7 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum | F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | ⏳ **TODO — designed, not started** | | F4 | MED | HTTP TOFU proxy accepts any cert on first use (credential exposure) | ✅ done, committed `f22985a` | | F5 | LOW | Voice perms use stale connect-time role snapshot | ✅ done, committed `260d038` | -| F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, **uncommitted** (see note) | +| F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, committed `e6a0d87` | | F7 | LOW | ReDoS regex on link-preview HTML | ✅ done, committed `6952202` | | F8 | LOW | WS TOFU verifier accepts any cert on first use | ✅ done, committed `f22985a` (with F4) | @@ -26,12 +26,11 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum `cd Client/tauri-client/src-tauri && cargo clippy -- -D warnings` (or push and let CI do it). Pure `tofu` logic has `#[cfg(test)]` unit tests; the frontend is covered by the 3311-green unit suite. -2. **F6 commit:** F6 lives in `Server/service/permission.go`, entangled with the - uncommitted permission-consolidation edits. It rides with that work (per - decision) — commit it when the consolidation branch lands, or cherry-pick. -3. **Then F3** — the only remaining finding (below). +2. **Then F3** — the only remaining finding (below). (F6 landed 2026-07-23 as + `e6a0d87`, split out from the D13 permission-consolidation commits that + followed it on this branch.) -## F6 detail (done, pending commit) +## F6 detail (done, committed `e6a0d87`) `getOrPopulate` read the DB then cached the snapshot with no version guard, so a concurrent `InvalidateUser` racing the populate was silently overwritten (stale From f2966c2527602118f3dcef97206a80c84267586c Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:28:41 +0200 Subject: [PATCH 12/15] chore(server): delete production-dead code; move test helpers to export_test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied from a deadcode (RTA from mains, all build tags) sweep with per-symbol adversarial verification: Deleted (nothing but their own self-tests used them): - admin.Handler (deprecated since Phase 6; production mounts NewHandler) plus its two self-tests - ws.Hub.broadcastVoiceStateUpdate + wrapper + two self-tests (pre-V2 leftover; the live voice_state path is the hub voice routines) - ws.VoiceLeaveEvent + methods ('retained as scaffolding', never constructed in production; MsgTypeVoiceLeaveBC stays — live via the leave routine) - ws.parseIdentity (production calls parseParticipantIdentity directly; ParseIdentityForTest now exercises the real parser) - telemetry.Float64 (String/Int64 are used; the float case is covered by the otel-tagged internal test, re-addable when a caller appears) Moved into export_test.go so they leave the production binary (all callers are same-package tests): the eight ws test-client constructors and voice/E2EE setters from ws/client.go, admin.SetBackupBaseDir (new admin/export_test.go), api.SecurityHeaders (test-only wrapper; production uses SecurityHeadersWithTLS — docs/api.md updated to the real name). Client.getVoiceJoinToken/setVoiceChID inlined into their existing ForTest wrappers; TestSetVoiceChID_* self-tests deleted. Kept after verification: updater.SetBaseURL (11 cross-package test call sites) and telemetry.resetAppMetricsForInit (live under -tags otel — untagged deadcode false positive). Full gate green: gofmt/vet, 4 build-tag variants, full suite, deadlock, race. Co-Authored-By: Claude Fable 5 --- Server/admin/admin.go | 8 --- Server/admin/admin_handler_test.go | 27 -------- Server/admin/export_test.go | 5 ++ Server/admin/handlers_backup.go | 3 - Server/api/export_test.go | 6 ++ Server/api/middleware.go | 6 -- Server/telemetry/telemetry.go | 3 - Server/ws/client.go | 102 --------------------------- Server/ws/coverage_boost2_test.go | 69 ------------------- Server/ws/coverage_boost_test.go | 37 ---------- Server/ws/event.go | 11 --- Server/ws/event_test.go | 2 - Server/ws/export_test.go | 107 ++++++++++++++++++++++++++--- Server/ws/livekit_webhook.go | 6 -- Server/ws/voice_broadcast.go | 16 ----- docs/api.md | 2 +- 16 files changed, 108 insertions(+), 302 deletions(-) create mode 100644 Server/admin/export_test.go diff --git a/Server/admin/admin.go b/Server/admin/admin.go index b9ecad2b..be61d913 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -56,11 +56,3 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. return r } - -// Handler returns the admin panel http.Handler using a nil database. -// -// Deprecated: use NewHandler instead. Kept for backwards-compat with any -// caller that already imported this symbol before Phase 6. -func Handler() http.Handler { - return http.FileServer(http.FS(staticFiles)) -} diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 37b68e14..2772100d 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -115,33 +115,6 @@ func TestNewHandler_WithUpdater(t *testing.T) { } } -// ─── Handler (deprecated) ──────────────────────────────────────────────────── - -// TestHandler_ReturnsNonNil verifies the deprecated Handler() function returns -// a non-nil http.Handler (it serves the embedded static files). -func TestHandler_ReturnsNonNil(t *testing.T) { - h := admin.Handler() - if h == nil { - t.Fatal("Handler() returned nil") - } -} - -// TestHandler_ServesEmbeddedFiles verifies that the deprecated Handler() serves -// a response (the embedded static FS) without panicking. -func TestHandler_ServesEmbeddedFiles(t *testing.T) { - h := admin.Handler() - - req := httptest.NewRequest(http.MethodGet, "/index.html", nil) - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - - // http.FileServer returns 200 for a found file or 301/404 for others; - // the important thing is it doesn't panic and returns a valid HTTP status. - if w.Code == 0 { - t.Error("Handler() response has zero status code") - } -} - // ─── ownerOnlyMiddleware (tested via API endpoints that use it) ─────────────── // TestOwnerOnlyMiddleware_OwnerAllowed verifies that a user with Owner role diff --git a/Server/admin/export_test.go b/Server/admin/export_test.go new file mode 100644 index 00000000..a067703c --- /dev/null +++ b/Server/admin/export_test.go @@ -0,0 +1,5 @@ +package admin + +// SetBackupBaseDir overrides backupBaseDir so tests can point backup handlers +// at a temp dir. Lives here so it stays out of the production binary. +func SetBackupBaseDir(dir string) { backupBaseDir = dir } diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index fbf11256..888286df 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -28,9 +28,6 @@ func init() { } } -// SetBackupBaseDir overrides backupBaseDir. Intended for tests only. -func SetBackupBaseDir(dir string) { backupBaseDir = dir } - // ─── Backup Handlers ───────────────────────────────────────────────────────── func handleBackup(database *db.DB) http.Handler { diff --git a/Server/api/export_test.go b/Server/api/export_test.go index 37c6db1d..ba9cb746 100644 --- a/Server/api/export_test.go +++ b/Server/api/export_test.go @@ -44,3 +44,9 @@ func SetGIFUpstreamForTest(baseURL string, client *http.Client) func() { gifAPIBase, gifClient = baseURL, client return func() { gifAPIBase, gifClient = prevBase, prevClient } } + +// SecurityHeaders is SecurityHeadersWithTLS with TLS disabled (no HSTS). +// Test-only convenience — production always goes through SecurityHeadersWithTLS. +func SecurityHeaders(next http.Handler) http.Handler { + return SecurityHeadersWithTLS("")(next) +} diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 1e218112..38d1fbc6 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -375,12 +375,6 @@ func SecurityHeadersWithTLS(tlsMode string) func(http.Handler) http.Handler { } } -// SecurityHeaders is a convenience wrapper for SecurityHeadersWithTLS with TLS -// disabled (no HSTS header). Kept for backwards compatibility with tests. -func SecurityHeaders(next http.Handler) http.Handler { - return SecurityHeadersWithTLS("")(next) -} - // MaxBodySize wraps r.Body with http.MaxBytesReader so that reads beyond // maxBytes return an error. This prevents clients from exhausting server memory // by sending arbitrarily large request bodies. diff --git a/Server/telemetry/telemetry.go b/Server/telemetry/telemetry.go index ea8d363b..6db5da3e 100644 --- a/Server/telemetry/telemetry.go +++ b/Server/telemetry/telemetry.go @@ -58,9 +58,6 @@ func String(k, v string) Attr { return Attr{Key: k, Value: v} } // Int64 constructs an int64 attribute. func Int64(k string, v int64) Attr { return Attr{Key: k, Value: v} } -// Float64 constructs a float64 attribute. -func Float64(k string, v float64) Attr { return Attr{Key: k, Value: v} } - // Span is a single tracing span. type Span interface { End() diff --git a/Server/ws/client.go b/Server/ws/client.go index 24aab94a..9b8a2bae 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -83,92 +83,6 @@ func (c *Client) GetTokenHash() string { return c.tokenHash } -// NewTestClient creates a client with a caller-supplied send channel. -// Intended for unit tests only — conn is nil. -func NewTestClient(hub *Hub, userID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: userID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - -// NewTestClientWithChannel creates a test client subscribed to a specific channel. -func NewTestClientWithChannel(hub *Hub, userID, channelID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: userID, - channelID: channelID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - -// NewTestClientWithUser creates a test client with an authenticated user record set. -// Use this when tests need the client to pass permission checks. -func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: user.ID, - user: user, - channelID: channelID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - -// SetClientVoiceChID sets the voiceChID field on a client. For test use only. -func SetClientVoiceChID(c *Client, channelID int64) { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - c.voiceChID = channelID - if channelID == 0 { - c.voiceJoinToken = "" - } -} - -// SetClientVoiceStateForTest sets both the voice channel and join token. -// For test use only. -func SetClientVoiceStateForTest(c *Client, channelID int64, joinToken string) { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - c.voiceChID = channelID - c.voiceJoinToken = joinToken -} - -// SetClientE2EEPubKeyForTest sets the E2EE public key on a client. For test use only. -func SetClientE2EEPubKeyForTest(c *Client, key string) { - c.setE2EEPubKey(key) -} - -// GetClientE2EEPubKeyForTest returns the E2EE public key from a client. For test use only. -func GetClientE2EEPubKeyForTest(c *Client) string { - return c.getE2EEPubKey() -} - -// NewTestClientWithTokenHash creates a test client that carries a session token -// hash. Use this when tests need to exercise the periodic session-expiry check. -func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client { - return &Client{ - hub: hub, - ctx: context.Background(), - userID: user.ID, - user: user, - tokenHash: tokenHash, - channelID: channelID, - send: send, - sendHigh: send, // unified for test observability - sendLow: send, - } -} - // touch updates the last activity timestamp and increments the received counter. func (c *Client) touch() { c.mu.Lock() @@ -198,28 +112,12 @@ func (c *Client) getVoiceChID() int64 { return c.voiceChID } -func (c *Client) getVoiceJoinToken() string { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - return c.voiceJoinToken -} - func (c *Client) getVoiceState() (int64, string) { c.voiceMu.Lock() defer c.voiceMu.Unlock() return c.voiceChID, c.voiceJoinToken } -// setVoiceChID sets the voice channel ID atomically. -func (c *Client) setVoiceChID(chID int64) { - c.voiceMu.Lock() - defer c.voiceMu.Unlock() - c.voiceChID = chID - if chID == 0 { - c.voiceJoinToken = "" - } -} - func (c *Client) setVoiceState(chID int64, joinToken string) { c.voiceMu.Lock() defer c.voiceMu.Unlock() diff --git a/Server/ws/coverage_boost2_test.go b/Server/ws/coverage_boost2_test.go index 267d0047..ebbea0cc 100644 --- a/Server/ws/coverage_boost2_test.go +++ b/Server/ws/coverage_boost2_test.go @@ -162,75 +162,6 @@ func TestBuildDMChannelOpen_NilAvatar(t *testing.T) { } } -// ─── broadcastVoiceStateUpdate ────────────────────────────────────────────── - -func TestBroadcastVoiceStateUpdate_NotInVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "bvsu-noop") - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // User not in voice — should be a no-op. - hub.BroadcastVoiceStateUpdateForTest(c) - time.Sleep(20 * time.Millisecond) - - // No voice_state message should have been sent since user is not in voice. - for len(send) > 0 { - msg := <-send - var m struct { - Type string `json:"type"` - } - _ = json.Unmarshal(msg, &m) - if m.Type == "voice_state" { - t.Error("expected no voice_state broadcast when user is not in voice") - } - } -} - -func TestBroadcastVoiceStateUpdate_InVoice(t *testing.T) { - hub, database := newCoverageHub(t) - user := seedCoverageOwner(t, database, "bvsu-voice") - - // Create a voice channel. - chanID, err := database.CreateChannel("bvsu-ch", "voice", "", "", 0) - if err != nil { - t.Fatalf("CreateChannel: %v", err) - } - - // Join the voice channel in DB. - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { - t.Fatalf("JoinVoiceChannel: %v", err) - } - - send := make(chan []byte, 16) - c := ws.NewTestClientWithUser(hub, user, 0, send) - ws.SetClientVoiceChID(c, chanID) - hub.Register(c) - time.Sleep(20 * time.Millisecond) - - // Should broadcast a voice_state message. - hub.BroadcastVoiceStateUpdateForTest(c) - - // Drain the channel and check for voice_state message. - time.Sleep(20 * time.Millisecond) - found := false - for len(send) > 0 { - msg := <-send - var m struct { - Type string `json:"type"` - } - _ = json.Unmarshal(msg, &m) - if m.Type == "voice_state" { - found = true - } - } - if !found { - t.Error("expected voice_state broadcast") - } -} - // ─── handleVoiceMute via HandleMessageForTest ─────────────────────────────── func TestHandleVoiceMute_NotInVoice2(t *testing.T) { diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index 12060914..691aa621 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -2300,43 +2300,6 @@ func TestGetLastActivity_MultipleTouch(t *testing.T) { } } -// ─── setVoiceChID (client.go:186) ─────────────────────────────────────────── - -func TestSetVoiceChID_SetsAndGetsValue(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 77) - if got := ws.GetClientVoiceChIDForTest(c); got != 77 { - t.Fatalf("voiceChID = %d, want 77", got) - } -} - -func TestSetVoiceChID_OverwritesPreviousValue(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 10) - ws.SetVoiceChIDForTest(c, 20) - if got := ws.GetClientVoiceChIDForTest(c); got != 20 { - t.Fatalf("voiceChID = %d, want 20", got) - } -} - -func TestSetVoiceChID_ZeroMeansNotInVoice(t *testing.T) { - hub, _ := newCoverageHub(t) - send := make(chan []byte, 4) - c := ws.NewTestClient(hub, 1, send) - - ws.SetVoiceChIDForTest(c, 50) - ws.SetVoiceChIDForTest(c, 0) - if got := ws.GetClientVoiceChIDForTest(c); got != 0 { - t.Fatalf("voiceChID = %d, want 0", got) - } -} - // ─── clearVoiceChID (client.go:203) ───────────────────────────────────────── func TestClearVoiceChID_ReturnsOldValueAndClearsToZero(t *testing.T) { diff --git a/Server/ws/event.go b/Server/ws/event.go index 8d26d3ae..bbcbc57d 100644 --- a/Server/ws/event.go +++ b/Server/ws/event.go @@ -259,17 +259,6 @@ type VoiceStateEvent struct { func (e VoiceStateEvent) EventType() string { return MsgTypeVoiceState } func (e VoiceStateEvent) Payload() []byte { return e.payload } -// VoiceLeaveEvent is a voice_leave broadcast to all connected clients. -// NOTE: Currently unused — the voice_leave V2 handler triggers the hub's -// handleVoiceLeave routine (via Result.LeaveVoice), which broadcasts the leave -// directly. Retained as forward-compatible scaffolding. -type VoiceLeaveEvent struct { - payload []byte -} - -func (e VoiceLeaveEvent) EventType() string { return MsgTypeVoiceLeaveBC } -func (e VoiceLeaveEvent) Payload() []byte { return e.payload } - // PluginBroadcastEvent is a plugin slash-command result broadcast to a channel // (sequenced, replayable). Emitted by the chat_command handler after the // invoking user's post permission is verified. diff --git a/Server/ws/event_test.go b/Server/ws/event_test.go index 62d59245..4b72defe 100644 --- a/Server/ws/event_test.go +++ b/Server/ws/event_test.go @@ -83,7 +83,6 @@ func TestEventTypes(t *testing.T) { {"ReactionChannelEvent", ReactionChannelEvent{}, MsgTypeReactionUpdate}, {"ReactionDMEvent", ReactionDMEvent{}, MsgTypeReactionUpdate}, {"VoiceStateEvent", VoiceStateEvent{}, MsgTypeVoiceState}, - {"VoiceLeaveEvent", VoiceLeaveEvent{}, MsgTypeVoiceLeaveBC}, {"VoiceE2EEAnnounceEvent", VoiceE2EEAnnounceEvent{}, MsgTypeVoiceE2EEAnnounceBC}, {"VoiceE2EEOfferGuardedEvent", VoiceE2EEOfferGuardedEvent{}, MsgTypeVoiceE2EEOfferRelay}, {"DMChannelOpenEvent", DMChannelOpenEvent{}, MsgTypeDMChannelOpen}, @@ -207,7 +206,6 @@ func TestBroadcastAllEventInterface(t *testing.T) { }{ {"PresenceEvent", PresenceEvent{payload: []byte("p")}}, {"VoiceStateEvent", VoiceStateEvent{payload: []byte("vs")}}, - {"VoiceLeaveEvent", VoiceLeaveEvent{payload: []byte("vl")}}, } for _, tt := range events { t.Run(tt.name, func(t *testing.T) { diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 18f459e0..9a115869 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -49,9 +49,95 @@ func ClearVoiceChIDForTest(c *Client) int64 { return c.clearVoiceChID() } -// SetVoiceChIDForTest exposes Client.setVoiceChID for external tests. +// SetVoiceChIDForTest sets the voice channel ID atomically, clearing the join +// token when leaving (chID 0) — the same contract production keeps via +// setVoiceState. Test-only: production has no set-channel-without-token path. func SetVoiceChIDForTest(c *Client, chID int64) { - c.setVoiceChID(chID) + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = chID + if chID == 0 { + c.voiceJoinToken = "" + } +} + +// SetClientVoiceChID is an alias kept for existing tests. +func SetClientVoiceChID(c *Client, channelID int64) { + SetVoiceChIDForTest(c, channelID) +} + +// SetClientVoiceStateForTest sets both the voice channel and join token. +func SetClientVoiceStateForTest(c *Client, channelID int64, joinToken string) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = channelID + c.voiceJoinToken = joinToken +} + +// SetClientE2EEPubKeyForTest sets the E2EE public key on a client. +func SetClientE2EEPubKeyForTest(c *Client, key string) { + c.setE2EEPubKey(key) +} + +// GetClientE2EEPubKeyForTest returns the E2EE public key from a client. +func GetClientE2EEPubKeyForTest(c *Client) string { + return c.getE2EEPubKey() +} + +// NewTestClient creates a client with a caller-supplied send channel; conn is nil. +func NewTestClient(hub *Hub, userID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: userID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } +} + +// NewTestClientWithChannel creates a test client subscribed to a specific channel. +func NewTestClientWithChannel(hub *Hub, userID, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: userID, + channelID: channelID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } +} + +// NewTestClientWithUser creates a test client with an authenticated user record +// set. Use this when tests need the client to pass permission checks. +func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: user.ID, + user: user, + channelID: channelID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } +} + +// NewTestClientWithTokenHash creates a test client that carries a session token +// hash. Use this when tests need to exercise the periodic session-expiry check. +func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + ctx: context.Background(), + userID: user.ID, + user: user, + tokenHash: tokenHash, + channelID: channelID, + send: send, + sendHigh: send, // unified for test observability + sendLow: send, + } } // TouchForTest exposes Client.touch for external tests. @@ -138,9 +224,11 @@ func GetClientVoiceChIDForTest(c *Client) int64 { return c.getVoiceChID() } -// GetClientVoiceJoinTokenForTest exposes Client.getVoiceJoinToken. +// GetClientVoiceJoinTokenForTest reads the join token under voiceMu. func GetClientVoiceJoinTokenForTest(c *Client) string { - return c.getVoiceJoinToken() + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.voiceJoinToken } // ExpireSettingsCacheForTest forces the settings cache to appear stale so that @@ -161,9 +249,11 @@ func BuildJSONForTest(v any) []byte { return buildJSON(v) } -// ParseIdentityForTest exposes parseIdentity for external tests. +// ParseIdentityForTest parses a LiveKit participant identity and discards the +// join token, exercising the production parseParticipantIdentity. func ParseIdentityForTest(identity string) (int64, error) { - return parseIdentity(identity) + userID, _, err := parseParticipantIdentity(identity) + return userID, err } // ParseParticipantIdentityForTest exposes parseParticipantIdentity for tests. @@ -202,11 +292,6 @@ func BuildDMChannelOpenForTest(channelID int64, recipient *db.User) []byte { return buildDMChannelOpen(channelID, recipient) } -// BroadcastVoiceStateUpdateForTest exposes broadcastVoiceStateUpdate for external tests. -func (h *Hub) BroadcastVoiceStateUpdateForTest(c *Client) { - h.broadcastVoiceStateUpdate(c) -} - // HandleWebhookParticipantLeftForTest exposes handleWebhookParticipantLeft for // external tests so they can simulate LiveKit webhook events without HTTP. func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, joinToken string) { diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index ac846a87..51898cf9 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -89,12 +89,6 @@ func parseParticipantIdentity(identity string) (int64, string, error) { return userID, joinToken, nil } -// parseIdentity extracts a user ID from a LiveKit participant identity. -func parseIdentity(identity string) (int64, error) { - userID, _, err := parseParticipantIdentity(identity) - return userID, err -} - // parseRoomChannelID extracts a channel ID from a LiveKit room name // formatted as "channel-{id}". func parseRoomChannelID(roomName string) (int64, error) { diff --git a/Server/ws/voice_broadcast.go b/Server/ws/voice_broadcast.go index 8df6ba27..3bfe4761 100644 --- a/Server/ws/voice_broadcast.go +++ b/Server/ws/voice_broadcast.go @@ -1,7 +1,6 @@ package ws import ( - "log/slog" "time" ) @@ -33,18 +32,3 @@ func qualityBitrate(quality string) int { } return voiceQualities["medium"] } - -// broadcastVoiceStateUpdate fetches the current voice state for the client -// and broadcasts it to all members of the voice channel they are in. -func (h *Hub) broadcastVoiceStateUpdate(c *Client) { - state, err := h.db.GetVoiceState(c.userID) - if err != nil { - slog.Error("ws broadcastVoiceStateUpdate GetVoiceState", "err", err, "user_id", c.userID) - c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to broadcast voice state update")) - return - } - if state == nil { - return // user not in a voice channel — nothing to broadcast - } - h.BroadcastToAll(buildVoiceState(*state)) -} diff --git a/docs/api.md b/docs/api.md index 76c811d0..f0a85fd3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,7 +19,7 @@ All authenticated endpoints require a session token delivered via the `Authoriza 1. **RequestID** -- assigns a unique `X-Request-Id` response header. 2. **Recoverer** -- catches panics and returns 500. 3. **Request Logger** -- structured logging of method, path, status, duration. -4. **SecurityHeaders** -- sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`. +4. **SecurityHeadersWithTLS** -- (adds `Strict-Transport-Security` when TLS is on) sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`. 5. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit). --- From 9d8bbec37581b1c22cf40e05b211c8c5d39af3b9 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:29:01 +0200 Subject: [PATCH 13/15] chore(db): drop 18 sqlc queries with zero callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each verified: the generated dbgen method's only references were the .sql definition and dbgen output itself (no wrapper in db/*.go, no test, no script). ArchiveChannel, DeleteAttachment, FindExistingDMChannel, GetDefaultRole, GetMessagesByChannel, GetMessagesByChannelBeforeCursor, GetMessagesForAPIBeforeCursor, GetPinnedMessageRows, GetPlugin, GetPluginByName, InsertDMChannel, InsertDMOpenState, InsertDMParticipants, LinkAttachmentToMessage, SetChannelMixingThreshold, SetChannelVoiceMaxVideo, SetChannelVoiceQuality, UpdateVoiceSpeaking. dbgen regenerated with the pinned sqlc v1.30.0 (132 → 114 queries); sqlc-verify clean; db/service/ws suites green including -race. Co-Authored-By: Claude Fable 5 --- Server/db/dbgen/attachments.sql.go | 23 --- Server/db/dbgen/channels.sql.go | 56 ----- Server/db/dbgen/dm.sql.go | 72 ------- Server/db/dbgen/messages.sql.go | 247 ----------------------- Server/db/dbgen/plugins.sql.go | 36 ---- Server/db/dbgen/querier.go | 18 -- Server/db/dbgen/roles.sql.go | 19 -- Server/db/dbgen/voice.sql.go | 14 -- Server/db/queries/sqlite/attachments.sql | 5 - Server/db/queries/sqlite/channels.sql | 12 -- Server/db/queries/sqlite/dm.sql | 17 -- Server/db/queries/sqlite/messages.sql | 30 --- Server/db/queries/sqlite/plugins.sql | 6 - Server/db/queries/sqlite/roles.sql | 3 - Server/db/queries/sqlite/voice.sql | 3 - 15 files changed, 561 deletions(-) diff --git a/Server/db/dbgen/attachments.sql.go b/Server/db/dbgen/attachments.sql.go index 2cffad1b..757062a8 100644 --- a/Server/db/dbgen/attachments.sql.go +++ b/Server/db/dbgen/attachments.sql.go @@ -7,7 +7,6 @@ package dbgen import ( "context" - "database/sql" ) const createAttachment = `-- name: CreateAttachment :exec @@ -40,15 +39,6 @@ func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentPara return err } -const deleteAttachment = `-- name: DeleteAttachment :exec -DELETE FROM attachments WHERE id = ? -` - -func (q *Queries) DeleteAttachment(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteAttachment, id) - return err -} - const deleteOrphanedAttachments = `-- name: DeleteOrphanedAttachments :many DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? RETURNING stored_as ` @@ -147,16 +137,3 @@ func (q *Queries) GetAttachmentWithChannel(ctx context.Context, id string) (GetA ) return i, err } - -const linkAttachmentToMessage = `-- name: LinkAttachmentToMessage :execresult -UPDATE attachments SET message_id = ? WHERE id = ? AND message_id IS NULL -` - -type LinkAttachmentToMessageParams struct { - MessageID *int64 `json:"messageId"` - ID string `json:"id"` -} - -func (q *Queries) LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (sql.Result, error) { - return q.db.ExecContext(ctx, linkAttachmentToMessage, arg.MessageID, arg.ID) -} diff --git a/Server/db/dbgen/channels.sql.go b/Server/db/dbgen/channels.sql.go index 32a9541f..7b1e83d1 100644 --- a/Server/db/dbgen/channels.sql.go +++ b/Server/db/dbgen/channels.sql.go @@ -37,20 +37,6 @@ func (q *Queries) AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannel return err } -const archiveChannel = `-- name: ArchiveChannel :exec -UPDATE channels SET archived = ? WHERE id = ? -` - -type ArchiveChannelParams struct { - Archived int64 `json:"archived"` - ID int64 `json:"id"` -} - -func (q *Queries) ArchiveChannel(ctx context.Context, arg ArchiveChannelParams) error { - _, err := q.db.ExecContext(ctx, archiveChannel, arg.Archived, arg.ID) - return err -} - const createChannel = `-- name: CreateChannel :execresult INSERT INTO channels (name, type, category, topic, position) VALUES (?, ?, ?, ?, ?) ` @@ -260,20 +246,6 @@ func (q *Queries) ListChannels(ctx context.Context) ([]ListChannelsRow, error) { return items, nil } -const setChannelMixingThreshold = `-- name: SetChannelMixingThreshold :exec -UPDATE channels SET mixing_threshold = ? WHERE id = ? -` - -type SetChannelMixingThresholdParams struct { - MixingThreshold *int64 `json:"mixingThreshold"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelMixingThreshold(ctx context.Context, arg SetChannelMixingThresholdParams) error { - _, err := q.db.ExecContext(ctx, setChannelMixingThreshold, arg.MixingThreshold, arg.ID) - return err -} - const setChannelSlowMode = `-- name: SetChannelSlowMode :exec UPDATE channels SET slow_mode = ? WHERE id = ? ` @@ -302,34 +274,6 @@ func (q *Queries) SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoi return err } -const setChannelVoiceMaxVideo = `-- name: SetChannelVoiceMaxVideo :exec -UPDATE channels SET voice_max_video = ? WHERE id = ? -` - -type SetChannelVoiceMaxVideoParams struct { - VoiceMaxVideo int64 `json:"voiceMaxVideo"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceMaxVideo(ctx context.Context, arg SetChannelVoiceMaxVideoParams) error { - _, err := q.db.ExecContext(ctx, setChannelVoiceMaxVideo, arg.VoiceMaxVideo, arg.ID) - return err -} - -const setChannelVoiceQuality = `-- name: SetChannelVoiceQuality :exec -UPDATE channels SET voice_quality = ? WHERE id = ? -` - -type SetChannelVoiceQualityParams struct { - VoiceQuality *string `json:"voiceQuality"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceQuality(ctx context.Context, arg SetChannelVoiceQualityParams) error { - _, err := q.db.ExecContext(ctx, setChannelVoiceQuality, arg.VoiceQuality, arg.ID) - return err -} - const updateChannel = `-- name: UpdateChannel :exec UPDATE channels SET name = ?, topic = ?, slow_mode = ? WHERE id = ? ` diff --git a/Server/db/dbgen/dm.sql.go b/Server/db/dbgen/dm.sql.go index ccf48696..88e4ab24 100644 --- a/Server/db/dbgen/dm.sql.go +++ b/Server/db/dbgen/dm.sql.go @@ -7,7 +7,6 @@ package dbgen import ( "context" - "database/sql" ) const closeDM = `-- name: CloseDM :exec @@ -24,27 +23,6 @@ func (q *Queries) CloseDM(ctx context.Context, arg CloseDMParams) error { return err } -const findExistingDMChannel = `-- name: FindExistingDMChannel :one -SELECT dp1.channel_id -FROM dm_participants dp1 -JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id -JOIN channels c ON c.id = dp1.channel_id -WHERE dp1.user_id = ? AND dp2.user_id = ? AND c.type = 'dm' -LIMIT 1 -` - -type FindExistingDMChannelParams struct { - UserID int64 `json:"userId"` - UserID_2 int64 `json:"userId2"` -} - -func (q *Queries) FindExistingDMChannel(ctx context.Context, arg FindExistingDMChannelParams) (int64, error) { - row := q.db.QueryRowContext(ctx, findExistingDMChannel, arg.UserID, arg.UserID_2) - var channel_id int64 - err := row.Scan(&channel_id) - return channel_id, err -} - const getDMParticipantIDs = `-- name: GetDMParticipantIDs :many SELECT user_id FROM dm_participants WHERE channel_id = ? ` @@ -149,56 +127,6 @@ func (q *Queries) GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsPa return items, nil } -const insertDMChannel = `-- name: InsertDMChannel :execresult -INSERT INTO channels (name, type) VALUES ('', 'dm') -` - -func (q *Queries) InsertDMChannel(ctx context.Context) (sql.Result, error) { - return q.db.ExecContext(ctx, insertDMChannel) -} - -const insertDMOpenState = `-- name: InsertDMOpenState :exec -INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?), (?, ?) -` - -type InsertDMOpenStateParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - UserID_2 int64 `json:"userId2"` - ChannelID_2 int64 `json:"channelId2"` -} - -func (q *Queries) InsertDMOpenState(ctx context.Context, arg InsertDMOpenStateParams) error { - _, err := q.db.ExecContext(ctx, insertDMOpenState, - arg.UserID, - arg.ChannelID, - arg.UserID_2, - arg.ChannelID_2, - ) - return err -} - -const insertDMParticipants = `-- name: InsertDMParticipants :exec -INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?), (?, ?) -` - -type InsertDMParticipantsParams struct { - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - ChannelID_2 int64 `json:"channelId2"` - UserID_2 int64 `json:"userId2"` -} - -func (q *Queries) InsertDMParticipants(ctx context.Context, arg InsertDMParticipantsParams) error { - _, err := q.db.ExecContext(ctx, insertDMParticipants, - arg.ChannelID, - arg.UserID, - arg.ChannelID_2, - arg.UserID_2, - ) - return err -} - const isDMParticipant = `-- name: IsDMParticipant :one SELECT user_id FROM dm_participants WHERE user_id = ? AND channel_id = ? ` diff --git a/Server/db/dbgen/messages.sql.go b/Server/db/dbgen/messages.sql.go index 97aa6772..a4c1153e 100644 --- a/Server/db/dbgen/messages.sql.go +++ b/Server/db/dbgen/messages.sql.go @@ -117,133 +117,6 @@ func (q *Queries) GetMessage(ctx context.Context, id int64) (Message, error) { return i, err } -const getMessagesByChannel = `-- name: GetMessagesByChannel :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = ? AND m.deleted = 0 -ORDER BY m.id DESC LIMIT ? -` - -type GetMessagesByChannelParams struct { - ChannelID int64 `json:"channelId"` - Limit int64 `json:"limit"` -} - -type GetMessagesByChannelRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt *string `json:"editedAt"` - Deleted int64 `json:"deleted"` - Pinned int64 `json:"pinned"` - Timestamp string `json:"timestamp"` - Username string `json:"username"` - Avatar *string `json:"avatar"` -} - -func (q *Queries) GetMessagesByChannel(ctx context.Context, arg GetMessagesByChannelParams) ([]GetMessagesByChannelRow, error) { - rows, err := q.db.QueryContext(ctx, getMessagesByChannel, arg.ChannelID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesByChannelRow{} - for rows.Next() { - var i GetMessagesByChannelRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - &i.Username, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMessagesByChannelBeforeCursor = `-- name: GetMessagesByChannelBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = ? AND m.id < ? AND m.deleted = 0 -ORDER BY m.id DESC LIMIT ? -` - -type GetMessagesByChannelBeforeCursorParams struct { - ChannelID int64 `json:"channelId"` - ID int64 `json:"id"` - Limit int64 `json:"limit"` -} - -type GetMessagesByChannelBeforeCursorRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt *string `json:"editedAt"` - Deleted int64 `json:"deleted"` - Pinned int64 `json:"pinned"` - Timestamp string `json:"timestamp"` - Username string `json:"username"` - Avatar *string `json:"avatar"` -} - -func (q *Queries) GetMessagesByChannelBeforeCursor(ctx context.Context, arg GetMessagesByChannelBeforeCursorParams) ([]GetMessagesByChannelBeforeCursorRow, error) { - rows, err := q.db.QueryContext(ctx, getMessagesByChannelBeforeCursor, arg.ChannelID, arg.ID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesByChannelBeforeCursorRow{} - for rows.Next() { - var i GetMessagesByChannelBeforeCursorRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - &i.Username, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getMessagesForAPI = `-- name: GetMessagesForAPI :many SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp @@ -306,126 +179,6 @@ func (q *Queries) GetMessagesForAPI(ctx context.Context, arg GetMessagesForAPIPa return items, nil } -const getMessagesForAPIBeforeCursor = `-- name: GetMessagesForAPIBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = ? AND m.id < ? AND m.deleted = 0 -ORDER BY m.id DESC LIMIT ? -` - -type GetMessagesForAPIBeforeCursorParams struct { - ChannelID int64 `json:"channelId"` - ID int64 `json:"id"` - Limit int64 `json:"limit"` -} - -type GetMessagesForAPIBeforeCursorRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt *string `json:"editedAt"` - Deleted int64 `json:"deleted"` - Pinned int64 `json:"pinned"` - Timestamp string `json:"timestamp"` -} - -func (q *Queries) GetMessagesForAPIBeforeCursor(ctx context.Context, arg GetMessagesForAPIBeforeCursorParams) ([]GetMessagesForAPIBeforeCursorRow, error) { - rows, err := q.db.QueryContext(ctx, getMessagesForAPIBeforeCursor, arg.ChannelID, arg.ID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesForAPIBeforeCursorRow{} - for rows.Next() { - var i GetMessagesForAPIBeforeCursorRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getPinnedMessageRows = `-- name: GetPinnedMessageRows :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -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 -` - -type GetPinnedMessageRowsRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt *string `json:"editedAt"` - Deleted int64 `json:"deleted"` - Pinned int64 `json:"pinned"` - Timestamp string `json:"timestamp"` -} - -func (q *Queries) GetPinnedMessageRows(ctx context.Context, channelID int64) ([]GetPinnedMessageRowsRow, error) { - rows, err := q.db.QueryContext(ctx, getPinnedMessageRows, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetPinnedMessageRowsRow{} - for rows.Next() { - var i GetPinnedMessageRowsRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const setMessagePinned = `-- name: SetMessagePinned :execresult UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0 ` diff --git a/Server/db/dbgen/plugins.sql.go b/Server/db/dbgen/plugins.sql.go index b6053c17..66ca958a 100644 --- a/Server/db/dbgen/plugins.sql.go +++ b/Server/db/dbgen/plugins.sql.go @@ -28,42 +28,6 @@ func (q *Queries) EnablePlugin(ctx context.Context, id int64) error { return err } -const getPlugin = `-- name: GetPlugin :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ? -` - -func (q *Queries) GetPlugin(ctx context.Context, id int64) (Plugin, error) { - row := q.db.QueryRowContext(ctx, getPlugin, id) - var i Plugin - err := row.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ) - return i, err -} - -const getPluginByName = `-- name: GetPluginByName :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ? -` - -func (q *Queries) GetPluginByName(ctx context.Context, name string) (Plugin, error) { - row := q.db.QueryRowContext(ctx, getPluginByName, name) - var i Plugin - err := row.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ) - return i, err -} - const installPlugin = `-- name: InstallPlugin :execresult INSERT INTO plugins (name, version, manifest_json) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index 295a84ad..07be9890 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -13,7 +13,6 @@ import ( type Querier interface { AddReaction(ctx context.Context, arg AddReactionParams) error AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannelParams) error - ArchiveChannel(ctx context.Context, arg ArchiveChannelParams) error BanUser(ctx context.Context, arg BanUserParams) error BlockUser(ctx context.Context, arg BlockUserParams) error CleanupExpiredLockouts(ctx context.Context, expiresAt string) error @@ -31,7 +30,6 @@ type Querier interface { CreateInvite(ctx context.Context, arg CreateInviteParams) error CreateMessage(ctx context.Context, arg CreateMessageParams) (sql.Result, error) CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error) - DeleteAttachment(ctx context.Context, id string) error DeleteChannel(ctx context.Context, id int64) error DeleteChannelPermission(ctx context.Context, arg DeleteChannelPermissionParams) error DeleteExpiredSessions(ctx context.Context) error @@ -45,7 +43,6 @@ type Querier interface { EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (sql.Result, error) EnablePlugin(ctx context.Context, id int64) error EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error - FindExistingDMChannel(ctx context.Context, arg FindExistingDMChannelParams) (int64, error) ForceLogoutUser(ctx context.Context, userID int64) error GetAllSettings(ctx context.Context) ([]Setting, error) GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow, error) @@ -57,19 +54,12 @@ type Querier interface { GetChannelUnreadCounts(ctx context.Context, userID int64) ([]GetChannelUnreadCountsRow, error) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]GetChannelVoiceStatesRow, error) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) - GetDefaultRole(ctx context.Context) (Role, error) GetEventsSince(ctx context.Context, arg GetEventsSinceParams) ([]GetEventsSinceRow, error) GetInvite(ctx context.Context, code string) (GetInviteRow, error) GetLatestMessageID(ctx context.Context, channelID int64) (interface{}, error) GetMaxEventSeq(ctx context.Context) (int64, error) GetMessage(ctx context.Context, id int64) (Message, error) - GetMessagesByChannel(ctx context.Context, arg GetMessagesByChannelParams) ([]GetMessagesByChannelRow, error) - GetMessagesByChannelBeforeCursor(ctx context.Context, arg GetMessagesByChannelBeforeCursorParams) ([]GetMessagesByChannelBeforeCursorRow, error) GetMessagesForAPI(ctx context.Context, arg GetMessagesForAPIParams) ([]GetMessagesForAPIRow, error) - GetMessagesForAPIBeforeCursor(ctx context.Context, arg GetMessagesForAPIBeforeCursorParams) ([]GetMessagesForAPIBeforeCursorRow, error) - GetPinnedMessageRows(ctx context.Context, channelID int64) ([]GetPinnedMessageRowsRow, error) - GetPlugin(ctx context.Context, id int64) (Plugin, error) - GetPluginByName(ctx context.Context, name string) (Plugin, error) GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) GetRoleByID(ctx context.Context, id int64) (Role, error) GetRoleChannelPermissions(ctx context.Context, roleID int64) ([]GetRoleChannelPermissionsRow, error) @@ -83,9 +73,6 @@ type Querier interface { GetUserSessions(ctx context.Context, userID int64) ([]Session, error) GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) GetUserWithRole(ctx context.Context, id int64) (GetUserWithRoleRow, error) - InsertDMChannel(ctx context.Context) (sql.Result, error) - InsertDMOpenState(ctx context.Context, arg InsertDMOpenStateParams) error - InsertDMParticipants(ctx context.Context, arg InsertDMParticipantsParams) error InsertSession(ctx context.Context, arg InsertSessionParams) (sql.Result, error) InstallPlugin(ctx context.Context, arg InstallPluginParams) (sql.Result, error) IsBlocked(ctx context.Context, arg IsBlockedParams) (int64, error) @@ -95,7 +82,6 @@ type Querier interface { JoinVoiceChannelIfCapacity(ctx context.Context, arg JoinVoiceChannelIfCapacityParams) (sql.Result, error) LeaveVoiceChannel(ctx context.Context, userID int64) error LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (sql.Result, error) - LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (sql.Result, error) ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) ListChannels(ctx context.Context) ([]ListChannelsRow, error) @@ -116,11 +102,8 @@ type Querier interface { RemoveReaction(ctx context.Context, arg RemoveReactionParams) (sql.Result, error) ResetAllUserStatuses(ctx context.Context) error RevokeInvite(ctx context.Context, code string) error - SetChannelMixingThreshold(ctx context.Context, arg SetChannelMixingThresholdParams) error SetChannelSlowMode(ctx context.Context, arg SetChannelSlowModeParams) error SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoiceMaxUsersParams) error - SetChannelVoiceMaxVideo(ctx context.Context, arg SetChannelVoiceMaxVideoParams) error - SetChannelVoiceQuality(ctx context.Context, arg SetChannelVoiceQualityParams) error SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (sql.Result, error) SetSetting(ctx context.Context, arg SetSettingParams) error SoftDeleteMessage(ctx context.Context, id int64) error @@ -139,7 +122,6 @@ type Querier interface { UpdateVoiceDeafen(ctx context.Context, arg UpdateVoiceDeafenParams) error UpdateVoiceMute(ctx context.Context, arg UpdateVoiceMuteParams) error UpdateVoiceScreenshare(ctx context.Context, arg UpdateVoiceScreenshareParams) error - UpdateVoiceSpeaking(ctx context.Context, arg UpdateVoiceSpeakingParams) error UpsertChannelPermission(ctx context.Context, arg UpsertChannelPermissionParams) error UpsertLockout(ctx context.Context, arg UpsertLockoutParams) error UseInviteAtomic(ctx context.Context, code string) (sql.Result, error) diff --git a/Server/db/dbgen/roles.sql.go b/Server/db/dbgen/roles.sql.go index eea131dd..f615c782 100644 --- a/Server/db/dbgen/roles.sql.go +++ b/Server/db/dbgen/roles.sql.go @@ -9,25 +9,6 @@ import ( "context" ) -const getDefaultRole = `-- name: GetDefaultRole :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE is_default = 1 LIMIT 1 -` - -func (q *Queries) GetDefaultRole(ctx context.Context) (Role, error) { - row := q.db.QueryRowContext(ctx, getDefaultRole) - var i Role - err := row.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - const getRoleByID = `-- name: GetRoleByID :one SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ? diff --git a/Server/db/dbgen/voice.sql.go b/Server/db/dbgen/voice.sql.go index a263626b..4db305ab 100644 --- a/Server/db/dbgen/voice.sql.go +++ b/Server/db/dbgen/voice.sql.go @@ -342,17 +342,3 @@ func (q *Queries) UpdateVoiceScreenshare(ctx context.Context, arg UpdateVoiceScr _, err := q.db.ExecContext(ctx, updateVoiceScreenshare, arg.Screenshare, arg.UserID) return err } - -const updateVoiceSpeaking = `-- name: UpdateVoiceSpeaking :exec -UPDATE voice_states SET speaking = ? WHERE user_id = ? -` - -type UpdateVoiceSpeakingParams struct { - Speaking int64 `json:"speaking"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceSpeaking(ctx context.Context, arg UpdateVoiceSpeakingParams) error { - _, err := q.db.ExecContext(ctx, updateVoiceSpeaking, arg.Speaking, arg.UserID) - return err -} diff --git a/Server/db/queries/sqlite/attachments.sql b/Server/db/queries/sqlite/attachments.sql index c32c1ed7..0a1e401c 100644 --- a/Server/db/queries/sqlite/attachments.sql +++ b/Server/db/queries/sqlite/attachments.sql @@ -14,11 +14,6 @@ LEFT JOIN messages m ON m.id = a.message_id LEFT JOIN channels c ON c.id = m.channel_id WHERE a.id = ?; --- name: LinkAttachmentToMessage :execresult -UPDATE attachments SET message_id = ? WHERE id = ? AND message_id IS NULL; - -- name: DeleteOrphanedAttachments :many DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? RETURNING stored_as; --- name: DeleteAttachment :exec -DELETE FROM attachments WHERE id = ?; diff --git a/Server/db/queries/sqlite/channels.sql b/Server/db/queries/sqlite/channels.sql index 5eac890f..67f3ed12 100644 --- a/Server/db/queries/sqlite/channels.sql +++ b/Server/db/queries/sqlite/channels.sql @@ -28,18 +28,6 @@ UPDATE channels SET slow_mode = ? WHERE id = ?; -- name: SetChannelVoiceMaxUsers :exec UPDATE channels SET voice_max_users = ? WHERE id = ?; --- name: SetChannelVoiceMaxVideo :exec -UPDATE channels SET voice_max_video = ? WHERE id = ?; - --- name: SetChannelVoiceQuality :exec -UPDATE channels SET voice_quality = ? WHERE id = ?; - --- name: SetChannelMixingThreshold :exec -UPDATE channels SET mixing_threshold = ? WHERE id = ?; - --- name: ArchiveChannel :exec -UPDATE channels SET archived = ? WHERE id = ?; - -- name: DeleteChannel :exec DELETE FROM channels WHERE id = ?; diff --git a/Server/db/queries/sqlite/dm.sql b/Server/db/queries/sqlite/dm.sql index 65256759..4385c44e 100644 --- a/Server/db/queries/sqlite/dm.sql +++ b/Server/db/queries/sqlite/dm.sql @@ -1,20 +1,3 @@ --- name: InsertDMChannel :execresult -INSERT INTO channels (name, type) VALUES ('', 'dm'); - --- name: InsertDMParticipants :exec -INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?), (?, ?); - --- name: InsertDMOpenState :exec -INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?), (?, ?); - --- name: FindExistingDMChannel :one -SELECT dp1.channel_id -FROM dm_participants dp1 -JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id -JOIN channels c ON c.id = dp1.channel_id -WHERE dp1.user_id = ? AND dp2.user_id = ? AND c.type = 'dm' -LIMIT 1; - -- name: OpenDM :exec INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?); diff --git a/Server/db/queries/sqlite/messages.sql b/Server/db/queries/sqlite/messages.sql index 45a892c0..fdf8f2c7 100644 --- a/Server/db/queries/sqlite/messages.sql +++ b/Server/db/queries/sqlite/messages.sql @@ -5,29 +5,6 @@ INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ? SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp FROM messages WHERE id = ?; --- name: GetMessagesByChannelBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = ? AND m.id < ? AND m.deleted = 0 -ORDER BY m.id DESC LIMIT ?; - --- name: GetMessagesByChannel :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = ? AND m.deleted = 0 -ORDER BY m.id DESC LIMIT ?; - --- name: GetMessagesForAPIBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = ? AND m.id < ? AND m.deleted = 0 -ORDER BY m.id DESC LIMIT ?; - -- name: GetMessagesForAPI :many SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp @@ -35,13 +12,6 @@ FROM messages m JOIN users u ON m.user_id = u.id WHERE m.channel_id = ? AND m.deleted = 0 ORDER BY m.id DESC LIMIT ?; --- name: GetPinnedMessageRows :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -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; - -- name: EditMessageContent :exec UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ?; diff --git a/Server/db/queries/sqlite/plugins.sql b/Server/db/queries/sqlite/plugins.sql index b3825d69..1ee79ac0 100644 --- a/Server/db/queries/sqlite/plugins.sql +++ b/Server/db/queries/sqlite/plugins.sql @@ -11,12 +11,6 @@ UPDATE plugins SET enabled = 0 WHERE id = ?; -- name: UninstallPlugin :exec DELETE FROM plugins WHERE id = ?; --- name: GetPlugin :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?; - --- name: GetPluginByName :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?; - -- name: ListPlugins :many SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name; diff --git a/Server/db/queries/sqlite/roles.sql b/Server/db/queries/sqlite/roles.sql index 00260f45..194f42bf 100644 --- a/Server/db/queries/sqlite/roles.sql +++ b/Server/db/queries/sqlite/roles.sql @@ -21,6 +21,3 @@ FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?; --- name: GetDefaultRole :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE is_default = 1 LIMIT 1; diff --git a/Server/db/queries/sqlite/voice.sql b/Server/db/queries/sqlite/voice.sql index 6cc8d13c..60fdff85 100644 --- a/Server/db/queries/sqlite/voice.sql +++ b/Server/db/queries/sqlite/voice.sql @@ -60,9 +60,6 @@ UPDATE voice_states SET muted = ? WHERE user_id = ?; -- name: UpdateVoiceDeafen :exec UPDATE voice_states SET deafened = ? WHERE user_id = ?; --- name: UpdateVoiceSpeaking :exec -UPDATE voice_states SET speaking = ? WHERE user_id = ?; - -- name: UpdateVoiceCamera :exec UPDATE voice_states SET camera = ? WHERE user_id = ?; From f4b20726ff03508069dff8d231c9a3580795fabd Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:29:01 +0200 Subject: [PATCH 14/15] chore(client): remove dead files, exports, and unused dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit knip findings (repo CI config), each verified including dynamic imports, HTML refs, and the Rust side: Files deleted: pluginBridge.ts (its documented PluginContainer.tsx collaborator never existed in the repo; the server plugin host stays per D11 — reinstate from git if client plugin UI work ever starts), message-input/file-upload.ts, message-input/picker-toggle.ts (dir now empty, removed), message-list/virtual-scroll.ts (MessageList does its own virtualization via FenwickTree). Dependencies removed: zod (zero imports; typegen uses validation_library none — stale CLAUDE.md claim fixed), @tauri-apps/plugin-store and plugin-updater npm halves (both features are Rust-driven via StoreExt/UpdaterExt — Rust halves stay), and tauri-plugin-global-shortcut on BOTH sides (PTT polls via device_query; zero GlobalShortcutExt use): Cargo.toml dep, lib.rs registration, and the 5 capability permission lines. Inert webview capability entries store:default/updater:default also dropped. @stryker-mutator/api added to devDependencies (stryker.config.mjs imports its types; core pins the same version, zero install delta). Exports removed: livekitSession clearOnError bound-const, ConnectPage/ MainPage ReturnType aliases, readAllPersistedLogs (never wired to any UI) with its test blocks. getLogDir kept as the suite's observability point, tagged @public for knip. protocolTypes.ts *Value types are generated surface — knip.json now ignores that file instead. Rust compile is CI-verified only (no MSVC toolchain here, same as the F4/F8 TOFU work); Cargo.lock resolution pruned cleanly. Client gate green: tsc, oxlint/eslint 0 errors, prettier, 3304/3304 vitest, knip clean. Co-Authored-By: Claude Fable 5 --- Client/tauri-client/knip.json | 3 +- Client/tauri-client/package-lock.json | 35 +--- Client/tauri-client/package.json | 7 +- Client/tauri-client/src-tauri/Cargo.lock | 67 ------- Client/tauri-client/src-tauri/Cargo.toml | 1 - .../src-tauri/capabilities/default.json | 7 - Client/tauri-client/src-tauri/src/lib.rs | 1 - .../components/message-input/file-upload.ts | 101 ---------- .../components/message-input/picker-toggle.ts | 77 -------- .../components/message-list/virtual-scroll.ts | 139 -------------- Client/tauri-client/src/lib/livekitSession.ts | 1 - Client/tauri-client/src/lib/logPersistence.ts | 33 +--- Client/tauri-client/src/lib/pluginBridge.ts | 172 ------------------ Client/tauri-client/src/pages/ConnectPage.ts | 2 - Client/tauri-client/src/pages/MainPage.ts | 2 - .../tests/unit/log-persistence.test.ts | 108 ----------- docs/plans/tauri-capability-narrowing.md | 5 +- 17 files changed, 14 insertions(+), 747 deletions(-) delete mode 100644 Client/tauri-client/src/components/message-input/file-upload.ts delete mode 100644 Client/tauri-client/src/components/message-input/picker-toggle.ts delete mode 100644 Client/tauri-client/src/components/message-list/virtual-scroll.ts delete mode 100644 Client/tauri-client/src/lib/pluginBridge.ts diff --git a/Client/tauri-client/knip.json b/Client/tauri-client/knip.json index 2f32b406..a7d527e4 100644 --- a/Client/tauri-client/knip.json +++ b/Client/tauri-client/knip.json @@ -4,7 +4,8 @@ "project": ["src/**/*.ts"], "ignore": [ "public/**", - "src-tauri/**" + "src-tauri/**", + "src/lib/protocolTypes.ts" ], "ignoreDependencies": [ "@tauri-apps/cli" diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index 70f45976..9492f48c 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -12,19 +12,16 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", - "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-store": "^2", - "@tauri-apps/plugin-updater": "^2.10.0", - "livekit-client": "^2.18.0", - "zod": "^4.3.6" + "livekit-client": "^2.18.0" }, "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1", + "@stryker-mutator/api": "^9.6.0", "@stryker-mutator/core": "^9.6.0", "@stryker-mutator/typescript-checker": "^9.6.0", "@stryker-mutator/vitest-runner": "^9.6.0", @@ -3873,15 +3870,6 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-global-shortcut": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz", - "integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, "node_modules/@tauri-apps/plugin-http": { "version": "2.5.7", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz", @@ -3918,24 +3906,6 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-store": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz", - "integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz", - "integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -8571,6 +8541,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index d92acbef..b84ff78f 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1", + "@stryker-mutator/api": "^9.6.0", "@stryker-mutator/core": "^9.6.0", "@stryker-mutator/typescript-checker": "^9.6.0", "@stryker-mutator/vitest-runner": "^9.6.0", @@ -62,14 +63,10 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", - "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-store": "^2", - "@tauri-apps/plugin-updater": "^2.10.0", - "livekit-client": "^2.18.0", - "zod": "^4.3.6" + "livekit-client": "^2.18.0" } } diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index 2c13cde0..06ef1f66 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -1578,16 +1578,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link 0.2.1", -] - [[package]] name = "getrandom" version = "0.1.16" @@ -1725,24 +1715,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "global-hotkey" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2", - "objc2-app-kit", - "once_cell", - "serde", - "thiserror 2.0.18", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - [[package]] name = "globset" version = "0.4.18" @@ -2979,7 +2951,6 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", - "tauri-plugin-global-shortcut", "tauri-plugin-http", "tauri-plugin-notification", "tauri-plugin-opener", @@ -4931,21 +4902,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" -dependencies = [ - "global-hotkey", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", -] - [[package]] name = "tauri-plugin-http" version = "2.5.7" @@ -6887,23 +6843,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "gethostname", - "rustix", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - [[package]] name = "xattr" version = "1.6.1" @@ -6914,12 +6853,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - [[package]] name = "yoke" version = "0.8.1" diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 819370fd..b6dc29c3 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -19,7 +19,6 @@ devtools = ["tauri/devtools"] [dependencies] tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-store = "2" -tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index 6ad6e789..429c83a3 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -20,12 +20,6 @@ "core:window:allow-outer-position", "core:window:allow-outer-size", "core:window:allow-available-monitors", - "store:default", - "global-shortcut:default", - "global-shortcut:allow-register", - "global-shortcut:allow-unregister", - "global-shortcut:allow-unregister-all", - "global-shortcut:allow-is-registered", "notification:default", "notification:allow-notify", "notification:allow-request-permission", @@ -63,7 +57,6 @@ "http:allow-fetch-cancel", "opener:default", "dialog:default", - "updater:default", "process:allow-restart", "fs:default", { diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 2e91b665..f6bb7435 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -13,7 +13,6 @@ mod ws_proxy; pub fn run() { match tauri::Builder::default() .plugin(tauri_plugin_store::Builder::new().build()) - .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_opener::init()) diff --git a/Client/tauri-client/src/components/message-input/file-upload.ts b/Client/tauri-client/src/components/message-input/file-upload.ts deleted file mode 100644 index 3069bdc3..00000000 --- a/Client/tauri-client/src/components/message-input/file-upload.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * File upload validation and preview rendering for message input. - */ - -import { createElement, appendChildren } from "@lib/dom"; -import { createIcon } from "@lib/icons"; - -export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB matches server limit -export const ALLOWED_TYPES = [ - "image/", - "video/", - "audio/", - "application/pdf", - "text/", - "application/zip", - "application/x-zip-compressed", - "application/json", -]; - -/** Read a File as a data: URL (more reliable than createObjectURL in WebView2). */ -export function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => resolve(reader.result as string)); - reader.addEventListener("error", () => reject(new Error("Failed to read file"))); - reader.readAsDataURL(file); - }); -} - -/** Validate file size and type. Returns an error message or null. */ -export function validateFile(file: File): string | null { - if (file.size > MAX_FILE_SIZE) { - return `File too large: ${file.name} exceeds 100 MB limit`; - } - if (file.type === "" || !ALLOWED_TYPES.some((t) => file.type.startsWith(t))) { - return `${file.name} is not a supported file type`; - } - return null; -} - -/** Build a preview item element for a file being uploaded. */ -export function buildPreviewItem( - file: File, - signal: AbortSignal, - onRemove: () => void, -): HTMLDivElement { - const isImage = file.type.startsWith("image/"); - const item = createElement("div", { class: "attachment-preview-item uploading" }); - - if (isImage) { - const img = createElement("img", { - class: "attachment-preview-img", - alt: file.name, - }); - item.appendChild(img); - readFileAsDataUrl(file) - .then((dataUrl) => { - if (signal.aborted) return; - img.src = dataUrl; - }) - .catch(() => { - if (signal.aborted) return; - const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name); - img.replaceWith(nameEl); - }); - } else { - const icon = createElement("div", { class: "attachment-preview-file" }); - icon.appendChild(createIcon("file-text", 16)); - const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name); - appendChildren(item, icon, nameEl); - } - - // Loading spinner overlay - const spinner = createElement("div", { class: "attachment-preview-spinner" }); - spinner.appendChild(createIcon("loader", 16)); - item.appendChild(spinner); - - const removeBtn = createElement("button", { - class: "attachment-preview-remove", - "data-testid": "attachment-remove", - }); - removeBtn.appendChild(createIcon("x", 14)); - removeBtn.addEventListener( - "click", - (e) => { - e.stopPropagation(); - onRemove(); - }, - { signal }, - ); - item.appendChild(removeBtn); - - return item; -} - -/** Mark a preview item as uploaded (removes loading state). */ -export function markPreviewUploaded(item: HTMLDivElement): void { - item.classList.remove("uploading"); - const spinner = item.querySelector(".attachment-preview-spinner"); - spinner?.remove(); -} diff --git a/Client/tauri-client/src/components/message-input/picker-toggle.ts b/Client/tauri-client/src/components/message-input/picker-toggle.ts deleted file mode 100644 index bee5f7ca..00000000 --- a/Client/tauri-client/src/components/message-input/picker-toggle.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Reusable picker toggle — manages open/close/click-outside lifecycle - * for floating panels (emoji picker, GIF picker, etc.). - */ - -export interface PickerInstance { - readonly element: HTMLDivElement; - destroy(): void; -} - -export interface PickerToggleOptions { - /** Creates and returns a new picker instance. */ - readonly create: () => PickerInstance; - /** The trigger button element — clicks on it won't close the picker. */ - readonly triggerEl: HTMLElement; - /** Parent element to append the picker to. */ - readonly parentEl: HTMLElement | null; - /** Called before opening — use to close other pickers first. */ - readonly onBeforeOpen?: () => void; - /** Timer set for deferred cleanup. */ - readonly activeTimers: Set>; -} - -export interface PickerToggleHandle { - toggle(): void; - close(): void; -} - -export function createPickerToggle(opts: PickerToggleOptions): PickerToggleHandle { - let instance: PickerInstance | null = null; - let pendingTimer: ReturnType | null = null; - - function handleClickOutside(e: MouseEvent): void { - if (instance === null) return; - const target = e.target as Node; - if ( - !instance.element.contains(target) && - target !== opts.triggerEl && - !opts.triggerEl.contains(target) - ) { - close(); - } - } - - function close(): void { - if (pendingTimer !== null) { - clearTimeout(pendingTimer); - opts.activeTimers.delete(pendingTimer); - pendingTimer = null; - } - if (instance !== null) { - instance.element.remove(); - instance.destroy(); - instance = null; - document.removeEventListener("mousedown", handleClickOutside); - } - } - - function toggle(): void { - opts.onBeforeOpen?.(); - if (instance !== null) { - close(); - return; - } - instance = opts.create(); - opts.parentEl?.appendChild(instance.element); - // Defer so this click doesn't immediately close it - pendingTimer = setTimeout(() => { - opts.activeTimers.delete(pendingTimer!); - pendingTimer = null; - document.addEventListener("mousedown", handleClickOutside); - }, 0); - opts.activeTimers.add(pendingTimer); - } - - return { toggle, close }; -} diff --git a/Client/tauri-client/src/components/message-list/virtual-scroll.ts b/Client/tauri-client/src/components/message-list/virtual-scroll.ts deleted file mode 100644 index c81768b6..00000000 --- a/Client/tauri-client/src/components/message-list/virtual-scroll.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Virtual scroll manager — manages height estimation, Fenwick-tree-backed - * offset calculations, and spacer management for DOM windowing. - */ - -import { FenwickTree } from "./fenwick"; - -export interface VirtualScrollItem { - readonly kind: string; -} - -export interface VirtualScrollOptions { - /** Number of items to render beyond visible viewport in each direction. */ - readonly overscan: number; - /** Estimate height for an item at given index. */ - readonly estimateHeight: (index: number) => number; - /** Generate a stable cache key for an item at given index. */ - readonly itemKey: (index: number) => string; -} - -export interface VisibleRange { - readonly start: number; - readonly end: number; -} - -export class VirtualScrollManager { - private readonly heightCache = new Map(); - private tree: FenwickTree | null = null; - private itemCount = 0; - private readonly opts: VirtualScrollOptions; - - constructor(opts: VirtualScrollOptions) { - this.opts = opts; - } - - /** Rebuild the Fenwick tree for a new item count, preserving cached heights. */ - rebuild(count: number): void { - this.itemCount = count; - this.tree = new FenwickTree(count); - for (let i = 0; i < count; i++) { - const key = this.opts.itemKey(i); - const cached = this.heightCache.get(key); - const h = cached !== undefined ? cached : this.opts.estimateHeight(i); - this.tree.set(i, h); - } - } - - /** Get height for item at index (cached or estimated). */ - getHeight(index: number): number { - const cached = this.heightCache.get(this.opts.itemKey(index)); - if (cached !== undefined) return cached; - return this.opts.estimateHeight(index); - } - - /** Cache a measured height for an item. */ - setMeasured(index: number, height: number): void { - if (height <= 0) return; - const key = this.opts.itemKey(index); - this.heightCache.set(key, height); - if (this.tree !== null && index < this.tree.size) { - this.tree.set(index, height); - } - } - - /** Total estimated height of all items. */ - totalHeight(): number { - if (this.tree !== null) return this.tree.total(); - let h = 0; - for (let i = 0; i < this.itemCount; i++) { - h += this.getHeight(i); - } - return h; - } - - /** Sum of heights for items [0, index). */ - offsetBefore(index: number): number { - if (this.tree !== null && index > 0) return this.tree.prefixSum(index - 1); - if (this.tree !== null && index <= 0) return 0; - let offset = 0; - for (let i = 0; i < index && i < this.itemCount; i++) { - offset += this.getHeight(i); - } - return offset; - } - - /** Find the item index at a given scroll offset. */ - offsetToIndex(scrollTop: number): number { - if (this.tree !== null) return this.tree.findIndex(scrollTop); - let offset = 0; - for (let i = 0; i < this.itemCount; i++) { - const h = this.getHeight(i); - if (offset + h > scrollTop) return i; - offset += h; - } - return Math.max(0, this.itemCount - 1); - } - - /** Compute the visible range with overscan. */ - visibleRange(scrollTop: number, clientHeight: number): VisibleRange { - const firstVisible = this.offsetToIndex(scrollTop); - const lastVisible = this.offsetToIndex(scrollTop + clientHeight); - return { - start: Math.max(0, firstVisible - this.opts.overscan), - end: Math.min(this.itemCount, lastVisible + this.opts.overscan + 1), - }; - } - - /** Compute spacer heights for a rendered range. */ - spacerHeights(start: number, end: number): { top: number; bottom: number } { - const top = this.offsetBefore(start); - let bottom: number; - if (this.tree !== null) { - const totalH = this.tree.total(); - const endOffset = end > 0 ? this.tree.prefixSum(end - 1) : 0; - bottom = totalH - endOffset; - } else { - bottom = 0; - for (let i = end; i < this.itemCount; i++) { - bottom += this.getHeight(i); - } - } - return { top, bottom }; - } - - /** Clear all cached heights. */ - clear(): void { - this.heightCache.clear(); - this.tree = null; - this.itemCount = 0; - } - - get size(): number { - return this.itemCount; - } - - get treeSize(): number { - return this.tree?.size ?? 0; - } -} diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 3af6682d..533f4dcb 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -1687,7 +1687,6 @@ owncordNs.lkDebug = session.getSessionDebugInfo.bind(session); export const setWsClient = session.setWsClient.bind(session); export const setServerHost = session.setServerHost.bind(session); export const setOnError = session.setOnError.bind(session); -export const clearOnError = session.clearOnError.bind(session); export const setOnRemoteVideo = session.setOnRemoteVideo.bind(session); export const setOnRemoteVideoRemoved = session.setOnRemoteVideoRemoved.bind(session); export const clearOnRemoteVideo = session.clearOnRemoteVideo.bind(session); diff --git a/Client/tauri-client/src/lib/logPersistence.ts b/Client/tauri-client/src/lib/logPersistence.ts index a6b3d45e..54508e11 100644 --- a/Client/tauri-client/src/lib/logPersistence.ts +++ b/Client/tauri-client/src/lib/logPersistence.ts @@ -5,7 +5,7 @@ // Rotation: keeps the most recent MAX_LOG_FILES days of logs. import { appLogDir, join } from "@tauri-apps/api/path"; -import { mkdir, writeTextFile, readDir, remove, exists, readTextFile } from "@tauri-apps/plugin-fs"; +import { mkdir, writeTextFile, readDir, remove, exists } from "@tauri-apps/plugin-fs"; import { type LogEntry, addLogListener, createLogger } from "./logger"; const log = createLogger("logPersistence"); @@ -165,35 +165,10 @@ export async function flushLogs(): Promise { } /** - * Get the log directory path (for use in debug bundle export). - * Returns null if persistence hasn't been initialized. + * Get the log directory path. Production-unused but exported as the test + * suite's observability point for persistence state. + * @public */ export function getLogDir(): string | null { return logDir; } - -/** - * Read all persisted log files and return their combined content. - * Intended for on-demand export only (reads all files into memory). - */ -export async function readAllPersistedLogs(): Promise { - if (!logDir) return ""; - try { - const entries = await readDir(logDir); - const jsonlFiles = entries - .filter((e) => e.name?.endsWith(".jsonl") && !e.isDirectory) - .map((e) => e.name) - .toSorted((a, b) => a.localeCompare(b)); - - const parts: string[] = []; - for (const file of jsonlFiles) { - // oxlint-disable-next-line no-await-in-loop -- files must be read in sorted order for correct log concatenation - const content = await readTextFile(`${logDir}/${file}`); - parts.push(content); - } - return parts.join(""); - } catch (err) { - log.warn("readAllPersistedLogs failed", err); - return ""; - } -} diff --git a/Client/tauri-client/src/lib/pluginBridge.ts b/Client/tauri-client/src/lib/pluginBridge.ts deleted file mode 100644 index c9e73809..00000000 --- a/Client/tauri-client/src/lib/pluginBridge.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Phase C Step 9 — client-side plugin bridge. - * - * Mounts plugin UI tabs in sandboxed iframes and forwards postMessage traffic - * between the host client and each plugin. The host injects theme CSS - * variables on every load so plugin UIs match OwnCord's look and feel - * without each plugin re-implementing them. - * - * The bridge is intentionally tiny: it owns iframe lifecycles and message - * routing; everything else (rendering tabs, fetching the plugin list) lives - * in PluginContainer.tsx. - */ - -export interface PluginTabBinding { - pluginId: number; - pluginName: string; - tabId: string; - label: string; - asset: string; -} - -export interface PluginMessageEnvelope { - pluginId: number; - type: string; - payload?: unknown; -} - -type Listener = (env: PluginMessageEnvelope) => void; - -const HOST_ORIGIN_PREFIX = "owncord-plugin-host"; - -class PluginBridge { - private frames = new Map(); - private listeners = new Set(); - private themeVars: Record = {}; - private hostOrigin: string; - - constructor() { - // Plugin iframes are served from /api/v1/plugins/... on the same origin - // as the host page, so postMessage targets that origin explicitly. Using - // "*" as the target origin is unsafe — any frame the user navigates to - // would receive host messages. window.location.origin is undefined in - // some test runners (jsdom prior to 16); fall back to "/" which still - // restricts to same-origin under the strict postMessage matching rules. - this.hostOrigin = - typeof window !== "undefined" && window.location && window.location.origin - ? window.location.origin - : "/"; - if (typeof window !== "undefined") { - window.addEventListener("message", this.onMessage); - } - } - - /** - * destroy unhooks the global message listener and clears all mounted - * frames. Intended for tests that create disposable bridge instances; the - * exported `pluginBridge` singleton lives for the lifetime of the page and - * does not need explicit teardown. - */ - destroy(): void { - if (typeof window !== "undefined") { - window.removeEventListener("message", this.onMessage); - } - for (const frame of this.frames.values()) { - frame.remove(); - } - this.frames.clear(); - this.listeners.clear(); - } - - /** Replace the theme variables broadcast to plugin iframes. */ - setTheme(vars: Record): void { - this.themeVars = { ...vars }; - for (const [pid, frame] of this.frames) { - this.postToFrame(pid, frame, { type: "theme", payload: this.themeVars }); - } - } - - /** Mount an iframe for binding into parent. Returns a destroy function. */ - mount(binding: PluginTabBinding, parent: HTMLElement): () => void { - const iframe = document.createElement("iframe"); - iframe.className = "plugin-iframe"; - iframe.sandbox.add("allow-scripts"); - iframe.title = `${binding.pluginName}: ${binding.label}`; - iframe.src = `/api/v1/plugins/${encodeURIComponent(binding.pluginName)}/ui/${binding.asset}`; - iframe.dataset.pluginId = String(binding.pluginId); - iframe.addEventListener("load", () => { - this.postToFrame(binding.pluginId, iframe, { type: "theme", payload: this.themeVars }); - this.postToFrame(binding.pluginId, iframe, { type: "ready", payload: null }); - }); - parent.appendChild(iframe); - this.frames.set(binding.pluginId, iframe); - return () => { - iframe.remove(); - this.frames.delete(binding.pluginId); - }; - } - - /** Listen for messages emitted by any mounted plugin iframe. */ - onMessageEnvelope(listener: Listener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - /** Send a host → plugin message. */ - send(pluginId: number, type: string, payload?: unknown): void { - const frame = this.frames.get(pluginId); - if (!frame) return; - this.postToFrame(pluginId, frame, { type, payload }); - } - - private postToFrame( - pluginId: number, - frame: HTMLIFrameElement, - msg: { type: string; payload: unknown }, - ): void { - // Restrict the postMessage target origin to the host page origin so a - // navigated-away iframe (or one whose contentWindow has been swapped) - // cannot receive host messages intended for a sandboxed plugin. The - // plugin asset endpoint is same-origin with the host page, so this - // matches every legitimate plugin iframe. - frame.contentWindow?.postMessage( - { source: HOST_ORIGIN_PREFIX, pluginId, ...msg }, - this.hostOrigin, - ); - } - - /** - * Look up the pluginId of an iframe by its contentWindow. Returns null if - * the source is not one of our managed plugin frames. This is the key - * defense against postMessage spoofing: we never trust the pluginId field - * inside the message body, only the e.source pointer. - */ - private pluginIdForSource(source: MessageEventSource | null): number | null { - if (!source) return null; - for (const [pid, frame] of this.frames) { - if (frame.contentWindow === source) return pid; - } - return null; - } - - private onMessage = (e: MessageEvent): void => { - const data = e.data; - if (!data || typeof data !== "object") return; - if ((data as { source?: unknown }).source === HOST_ORIGIN_PREFIX) return; // own echo - // SECURITY: validate the message originated from one of our managed - // plugin iframes by matching e.source against frame.contentWindow. - // Without this check, any arbitrary frame (including a malicious parent - // frame in an embedding scenario, or any same-origin script that - // obtained a window reference) could spoof messages from any plugin by - // claiming an arbitrary pluginId in the body. The pluginId from the - // message body is intentionally ignored — we use the trusted lookup. - const trustedPluginId = this.pluginIdForSource(e.source); - if (trustedPluginId === null) return; - const env = data as { type?: unknown; payload?: unknown }; - if (typeof env.type !== "string") return; - const envelope: PluginMessageEnvelope = { - pluginId: trustedPluginId, - type: env.type, - payload: env.payload, - }; - for (const l of this.listeners) { - try { - l(envelope); - } catch (err) { - console.error("plugin bridge listener threw", err); - } - } - }; -} - -export const pluginBridge = new PluginBridge(); diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts index e8605270..1d62b119 100644 --- a/Client/tauri-client/src/pages/ConnectPage.ts +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -286,5 +286,3 @@ export function createConnectPage( }, }; } - -export type ConnectPage = ReturnType; diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index b7482123..9d5f8c82 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -559,5 +559,3 @@ export function createMainPage(options: MainPageOptions): MountableComponent { return { mount, destroy }; } - -export type MainPage = ReturnType; diff --git a/Client/tauri-client/tests/unit/log-persistence.test.ts b/Client/tauri-client/tests/unit/log-persistence.test.ts index cfaf0b86..db1f752c 100644 --- a/Client/tauri-client/tests/unit/log-persistence.test.ts +++ b/Client/tauri-client/tests/unit/log-persistence.test.ts @@ -576,99 +576,6 @@ describe("log persistence", () => { }); }); - // ----------------------------------------------------------------------- - // readAllPersistedLogs - // ----------------------------------------------------------------------- - describe("readAllPersistedLogs", () => { - it("returns empty string when not initialized (logDir is null)", async () => { - const { readAllPersistedLogs } = await freshImport(); - const result = await readAllPersistedLogs(); - expect(result).toBe(""); - }); - - it("reads and concatenates all jsonl files in sorted order", async () => { - captureListener(); - const { initLogPersistence, readAllPersistedLogs } = await freshImport(); - await initLogPersistence(); - - mockReadDir.mockResolvedValueOnce([ - { name: "2025-06-14.jsonl", isDirectory: false }, - { name: "2025-06-15.jsonl", isDirectory: false }, - { name: "2025-06-13.jsonl", isDirectory: false }, - ]); - - mockReadTextFile - .mockResolvedValueOnce('{"day":"13"}\n') - .mockResolvedValueOnce('{"day":"14"}\n') - .mockResolvedValueOnce('{"day":"15"}\n'); - - const result = await readAllPersistedLogs(); - - // Files should be read in sorted order: 13, 14, 15 - expect(mockReadTextFile).toHaveBeenCalledTimes(3); - expect(mockReadTextFile.mock.calls[0]![0]).toContain("2025-06-13"); - expect(mockReadTextFile.mock.calls[1]![0]).toContain("2025-06-14"); - expect(mockReadTextFile.mock.calls[2]![0]).toContain("2025-06-15"); - - expect(result).toBe('{"day":"13"}\n{"day":"14"}\n{"day":"15"}\n'); - }); - - it("filters out directories and non-jsonl entries", async () => { - captureListener(); - const { initLogPersistence, readAllPersistedLogs } = await freshImport(); - await initLogPersistence(); - - mockReadDir.mockResolvedValueOnce([ - { name: "2025-06-15.jsonl", isDirectory: false }, - { name: "subdir", isDirectory: true }, - { name: "readme.txt", isDirectory: false }, - ]); - - mockReadTextFile.mockResolvedValueOnce('{"msg":"only"}\n'); - - const result = await readAllPersistedLogs(); - - expect(mockReadTextFile).toHaveBeenCalledTimes(1); - expect(result).toBe('{"msg":"only"}\n'); - }); - - it("returns empty string when directory has no jsonl files", async () => { - captureListener(); - const { initLogPersistence, readAllPersistedLogs } = await freshImport(); - await initLogPersistence(); - - mockReadDir.mockResolvedValueOnce([{ name: "notes.txt", isDirectory: false }]); - - const result = await readAllPersistedLogs(); - expect(result).toBe(""); - expect(mockReadTextFile).not.toHaveBeenCalled(); - }); - - it("returns empty string on readDir failure", async () => { - captureListener(); - const { initLogPersistence, readAllPersistedLogs } = await freshImport(); - await initLogPersistence(); - - mockReadDir.mockRejectedValueOnce(new Error("no access")); - - const result = await readAllPersistedLogs(); - expect(result).toBe(""); - }); - - it("returns empty string on readTextFile failure", async () => { - captureListener(); - const { initLogPersistence, readAllPersistedLogs } = await freshImport(); - await initLogPersistence(); - - mockReadDir.mockResolvedValueOnce([{ name: "2025-06-15.jsonl", isDirectory: false }]); - mockReadTextFile.mockRejectedValueOnce(new Error("corrupt file")); - - const result = await readAllPersistedLogs(); - // The entire function returns "" on any error - expect(result).toBe(""); - }); - }); - // ----------------------------------------------------------------------- // JSONL format // ----------------------------------------------------------------------- @@ -780,21 +687,6 @@ describe("log persistence", () => { expect(mockRemove).not.toHaveBeenCalled(); }); - it("handles entries with undefined name in readAllPersistedLogs", async () => { - captureListener(); - const { initLogPersistence, readAllPersistedLogs } = await freshImport(); - await initLogPersistence(); - - mockReadDir.mockResolvedValueOnce([ - { name: undefined, isDirectory: false }, - { name: "2025-06-15.jsonl", isDirectory: false }, - ]); - mockReadTextFile.mockResolvedValueOnce('{"msg":"ok"}\n'); - - const result = await readAllPersistedLogs(); - expect(result).toBe('{"msg":"ok"}\n'); - }); - it("multiple rapid entries reuse the same debounce timer", async () => { const { getListener } = captureListener(); const { initLogPersistence } = await freshImport(); diff --git a/docs/plans/tauri-capability-narrowing.md b/docs/plans/tauri-capability-narrowing.md index d7cc3b9c..91f92412 100644 --- a/docs/plans/tauri-capability-narrowing.md +++ b/docs/plans/tauri-capability-narrowing.md @@ -54,8 +54,9 @@ work changes that; only moving the fetch out of the renderer does. Not consumers, checked and excluded: `src/lib/gifProvider.ts` hits `https://api.klipy.com` with the **webview's** `fetch`, not the plugin (so it is -governed by CSP `connect-src`, not by this capability); `updater.ts` and -`pluginBridge.ts` make no HTTP calls; the Rust side (`http_proxy.rs`, +governed by CSP `connect-src`, not by this capability); `updater.ts` makes no +HTTP calls (`pluginBridge.ts`, also checked then, was deleted as dead code +2026-07-23); the Rust side (`http_proxy.rs`, `ws_proxy.rs`, `livekit_proxy.rs`, `update_commands.rs`) uses reqwest/rustls directly and is not subject to plugin capabilities at all. From 6afa9e974c3aebe9d05b93f2491ae2781123d0b7 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:03:52 +0200 Subject: [PATCH 15/15] refactor(server): thread context.Context through the db layer and all callers Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic, 2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx) under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint comments added; every finding fixed by genuinely threading context. - db: all 138 hand-written db.DB methods take ctx first; the dbCtx() Background shim is deleted; raw Query/QueryRow/Exec/Begin use their Context variants; the four redundant ctx-less passthroughs removed. db.Auditor/WriteAudit gain ctx. - Seams: permissions.Checker (DB iface, HasChannelPerm, RequireChannelAccess) and the service.Store interface mirror the new signatures (ws.EventStore and plugin.PluginStore already did). - Callers: api/admin handlers use r.Context(); ws per-message paths use the connection ctx via DispatchV2; hub loops and startup wiring use context.Background(); service methods thread ctx where they have one and Background where no ctx exists. Public service surface reached by ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/ RequireChannelAccess, message/dm/block/invite/profile methods) is now ctx-first. - Detached (context.WithoutCancel) where cancellation would break an invariant, found by a 3-lens adversarial review of the diff: * voice-leave background retries (a dead webhook/connection ctx killed retry 2 before it ran, leaving ghost capacity-holding voice rows) * rollbackVoiceJoin's compensating delete (its trigger IS the cancel) * post-2FA-change DeleteOtherSessions and logout DeleteSession (the security tail of a committed change must not die with the request) * all api/ws audit writes (a banned user could suppress their own login_blocked_banned row by aborting the request mid-bcrypt) * admin backup VACUUM INTO (an interrupt left a truncated .db that the backup list presented as restorable) * post-commit message/edit refetches (a committed message must still fan out when the sender disconnects) * hub settings-cache refresh (one dead connection could pin stale values for the 30s TTL) - gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in config with justification (generated source must stay world-readable) instead of flipping genprotocol output to 0o600. Verified: gofmt/vet, all four build-tag variants, full suite, deadlock pass, full -race pass, golangci-lint 0 issues uncapped. Co-Authored-By: Claude Fable 5 --- Server/.golangci.yml | 1 + Server/admin/admin_handler_test.go | 5 +- Server/admin/api_edge_cases_test.go | 36 +-- Server/admin/api_test.go | 85 +++---- Server/admin/handlers_backup.go | 19 +- Server/admin/handlers_backup_test.go | 13 +- Server/admin/handlers_channel_perms.go | 15 +- Server/admin/handlers_channel_perms_test.go | 25 +- Server/admin/handlers_channels.go | 25 +- Server/admin/handlers_settings.go | 25 +- Server/admin/handlers_users.go | 21 +- Server/admin/logstream.go | 12 +- Server/admin/logstream_test.go | 6 +- Server/admin/middleware.go | 8 +- Server/admin/middleware_and_spawn_test.go | 24 +- Server/admin/middleware_coverage_test.go | 7 +- Server/admin/setup_handler.go | 15 +- Server/admin/setup_handler_test.go | 5 +- Server/admin/types.go | 10 +- Server/admin/update_handlers_test.go | 5 +- Server/api/auth_handler.go | 59 ++--- Server/api/auth_handler_test.go | 177 +++++++------- Server/api/channel_authz_test.go | 45 ++-- Server/api/channel_handler.go | 8 +- Server/api/channel_handler_test.go | 135 ++++++----- Server/api/contract_test.go | 21 +- Server/api/coverage_push_test.go | 94 +++---- Server/api/diagnostics_handler_test.go | 9 +- Server/api/dm_handler.go | 8 +- Server/api/dm_handler_test.go | 21 +- Server/api/invite_handler.go | 4 +- Server/api/invite_handler_test.go | 19 +- Server/api/middleware.go | 13 +- Server/api/middleware_test.go | 50 ++-- Server/api/profile_handler.go | 10 +- Server/api/profile_handler_test.go | 25 +- Server/api/totp_handler.go | 39 +-- Server/api/totp_handler_test.go | 21 +- Server/api/upload_handler.go | 8 +- Server/api/upload_handler_test.go | 59 ++--- Server/auth/ratelimit.go | 30 ++- Server/auth/ratelimit_cleanup_test.go | 13 +- Server/auth/ratelimit_test.go | 17 +- Server/db/account_test.go | 20 +- Server/db/admin_queries.go | 79 +++--- Server/db/admin_queries_test.go | 153 ++++++------ Server/db/attachment_queries.go | 25 +- Server/db/attachment_queries_test.go | 51 ++-- Server/db/audit.go | 11 +- Server/db/audit_test.go | 7 +- Server/db/auth_queries.go | 91 +++---- Server/db/auth_queries_test.go | 227 ++++++++--------- Server/db/backup_test.go | 15 +- Server/db/block_queries.go | 21 +- Server/db/channel_queries.go | 57 ++--- Server/db/channel_queries_test.go | 105 ++++---- Server/db/coverage_boost_test.go | 229 +++++++++--------- Server/db/db.go | 25 -- Server/db/db_test.go | 33 +-- Server/db/dm_queries.go | 34 +-- Server/db/dm_queries_test.go | 113 ++++----- Server/db/invite_queries.go | 9 +- Server/db/lockout_queries.go | 17 +- Server/db/message_queries.go | 89 +++---- Server/db/message_queries_test.go | 201 +++++++-------- Server/db/migrate_test.go | 31 +-- Server/db/profile_queries.go | 17 +- Server/db/profile_queries_test.go | 69 +++--- Server/db/role_invite_queries_test.go | 47 ++-- Server/db/role_queries.go | 17 +- Server/db/voice_queries.go | 65 ++--- Server/db/voice_queries_test.go | 183 +++++++------- Server/main.go | 17 +- Server/permissions/checker.go | 15 +- Server/permissions/checker_test.go | 9 +- Server/plugin/registry.go | 6 +- Server/plugin/sandbox_default.go | 2 +- Server/plugin/sandbox_wazero.go | 7 +- Server/scripts/seed.go | 17 +- Server/service/block.go | 12 +- Server/service/channel.go | 34 +-- Server/service/datastore.go | 216 ++++++++--------- Server/service/dm.go | 16 +- Server/service/invite.go | 14 +- Server/service/message.go | 169 ++++++------- Server/service/message_test.go | 38 +-- Server/service/moderation.go | 34 +-- Server/service/moderation_test.go | 6 +- Server/service/permission.go | 27 ++- Server/service/permission_test.go | 57 ++--- Server/service/seed_test.go | 15 +- Server/service/user.go | 33 +-- Server/service/user_test.go | 11 +- Server/telemetry/telemetry.go | 3 + Server/ws/authz_test.go | 3 +- Server/ws/can_send_ready_test.go | 5 +- .../ws/channel_visibility_agreement_test.go | 18 +- Server/ws/coverage_boost2_test.go | 17 +- Server/ws/coverage_boost_test.go | 97 ++++---- Server/ws/deps.go | 12 +- Server/ws/dm_handlers_test.go | 21 +- Server/ws/export_test.go | 14 +- Server/ws/handler_v2_channel_focus_test.go | 10 +- Server/ws/handler_v2_migration_test.go | 2 +- Server/ws/handlers.go | 12 +- Server/ws/handlers_chat.go | 8 +- Server/ws/handlers_command.go | 6 +- Server/ws/handlers_presence.go | 14 +- Server/ws/handlers_reaction.go | 6 +- Server/ws/handlers_test.go | 37 +-- Server/ws/hub.go | 46 ++-- Server/ws/hub_test.go | 32 +-- Server/ws/livekit.go | 4 +- Server/ws/livekit_test.go | 16 +- Server/ws/livekit_webhook.go | 12 +- Server/ws/reconnect_db_test.go | 4 +- Server/ws/serve.go | 80 +++--- Server/ws/serve_test.go | 41 ++-- Server/ws/voice_controls.go | 38 +-- Server/ws/voice_handlers_test.go | 33 +-- Server/ws/voice_join.go | 40 +-- Server/ws/voice_leave.go | 17 +- Server/ws/voice_perm_stale_test.go | 5 +- Server/ws/ws_integration_test.go | 72 +++--- 124 files changed, 2442 insertions(+), 2326 deletions(-) diff --git a/Server/.golangci.yml b/Server/.golangci.yml index f4b5bfe0..21c5df82 100644 --- a/Server/.golangci.yml +++ b/Server/.golangci.yml @@ -29,6 +29,7 @@ linters: excludes: - G104 # unhandled errors — errcheck covers this better - G304 # file path from variable — expected in file storage code + - G306 # WriteFile perms ≤0600 — our only hits are generated source files (genprotocol), which must stay world-readable or multi-stage container builds break - G706 # log injection — false positive with slog structured logging (values are typed key-value pairs, not interpolated) exclusions: diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 2772100d..5c6e884d 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "net/http" "net/http/httptest" "os" @@ -159,9 +160,9 @@ func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Create admin user (role_id=2, position=80) - adminUID, _ := database.CreateUser("middlewareadmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "middlewareadmin", "hash", 2) token := "mw-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index 2060eac4..d606e1fc 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -41,8 +41,8 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { token := createAdminUser(t, database) // Create and ban a target user first. - targetUID, _ := database.CreateUser("unbanme", "hash", 3) - _ = database.BanUser(targetUID, "test ban", nil) + targetUID, _ := database.CreateUser(context.Background(), "unbanme", "hash", 3) + _ = database.BanUser(context.Background(), targetUID, "test ban", nil) body := map[string]any{"banned": false} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -52,7 +52,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { } // Verify the user is now unbanned. - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if user.Banned { t.Error("user is still banned after unban request") } @@ -64,7 +64,7 @@ func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("invalidbody", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "invalidbody", "hash", 3) req := httptest.NewRequest(http.MethodPatch, "/users/"+itoa(targetUID), bytes.NewReader([]byte("not-json"))) req.Header.Set("Authorization", "Bearer "+token) @@ -147,7 +147,7 @@ func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("malformed", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "malformed", "text", "", "", 0) req := httptest.NewRequest(http.MethodPatch, "/channels/"+itoa(chID), bytes.NewReader([]byte("not-json"))) req.Header.Set("Authorization", "Bearer "+token) @@ -235,9 +235,9 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) { token := createAdminUser(t, database) // Create several audit entries. - uid, _ := database.CreateUser("auditpager", "hash", 1) + uid, _ := database.CreateUser(context.Background(), "auditpager", "hash", 1) for i := 0; i < 5; i++ { - _ = database.LogAudit(uid, "TEST", "test", int64(i), "") + _ = database.LogAudit(context.Background(), uid, "TEST", "test", int64(i), "") } // Fetch page 2 with limit=2, offset=2 — should return 2 entries. @@ -324,7 +324,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("ban-nohub", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "ban-nohub", "hash", 3) body := map[string]any{"banned": true, "ban_reason": "nil hub test"} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -334,7 +334,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { } // Verify ban was still applied despite nil hub. - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if !user.Banned { t.Error("user should be banned even with nil hub") } @@ -363,7 +363,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { if payload.Ticket == "" { t.Fatal("expected non-empty log stream ticket") } - if err := database.DeleteSession(auth.HashToken(token)); err != nil { + if err := database.DeleteSession(context.Background(), auth.HashToken(token)); err != nil { t.Fatalf("DeleteSession: %v", err) } @@ -412,7 +412,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { t.Fatalf("legacy token stream status = %d, want 401; body: %s", legacyResp.StatusCode, string(body)) } - if _, err := database.CreateSession(1, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), 1, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } ticketResp = doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil) @@ -422,7 +422,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { if err := json.Unmarshal(ticketResp.Body.Bytes(), &payload); err != nil { t.Fatalf("unmarshal restored ticket response: %v", err) } - if err := database.UpdateUserRole(1, 3); err != nil { + if err := database.UpdateUserRole(context.Background(), 1, 3); err != nil { t.Fatalf("UpdateUserRole: %v", err) } demotedResp, err := http.Get(srv.URL + "/logs/stream?ticket=" + payload.Ticket) @@ -444,7 +444,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("role-nohub", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "role-nohub", "hash", 3) body := map[string]any{"role_id": float64(2)} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -454,7 +454,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { } // Verify role was still changed despite nil hub. - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if user.RoleID != 2 { t.Errorf("RoleID = %d, want 2", user.RoleID) } @@ -469,7 +469,7 @@ func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("banwithout", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "banwithout", "hash", 3) // No ban_reason in body — the nil check in handlePatchUser uses empty string. body := map[string]any{"banned": true} @@ -490,7 +490,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("rolebroadcast", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "rolebroadcast", "hash", 3) body := map[string]any{"role_id": float64(2)} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -531,7 +531,7 @@ func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) - _, _ = database.CreateUser("existing", "hash", 1) + _, _ = database.CreateUser(context.Background(), "existing", "hash", 1) w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil) @@ -583,7 +583,7 @@ func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) - _, _ = database.CreateUser("existing", "hash", 1) + _, _ = database.CreateUser(context.Background(), "existing", "hash", 1) body := map[string]string{ "username": "hacker", diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index a8293658..db67ef68 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -2,6 +2,7 @@ package admin_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -160,14 +161,14 @@ func openAdminTestDB(t *testing.T) *db.DB { func createAdminUser(t *testing.T, database *db.DB) string { t.Helper() // Owner role has permissions = 2147483647 (includes ADMINISTRATOR bit 0x40000000) - uid, err := database.CreateUser("adminuser", "$2a$12$placeholder", 1) + uid, err := database.CreateUser(context.Background(), "adminuser", "$2a$12$placeholder", 1) if err != nil { t.Fatalf("CreateUser admin: %v", err) } token := "test-admin-token-" + t.Name() tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } return token @@ -177,14 +178,14 @@ func createAdminUser(t *testing.T, database *db.DB) string { func createMemberUser(t *testing.T, database *db.DB) string { t.Helper() // Member role (id=3) has limited permissions, not ADMINISTRATOR - uid, err := database.CreateUser("memberuser", "$2a$12$placeholder", 3) + uid, err := database.CreateUser(context.Background(), "memberuser", "$2a$12$placeholder", 3) if err != nil { t.Fatalf("CreateUser member: %v", err) } token := "test-member-token-" + t.Name() tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } return token @@ -322,7 +323,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { ownerToken := createAdminUser(t, database) // Owner role (pos 100) // A second owner-rank user: equal position, cannot be banned. - peerUID, err := database.CreateUser("peerowner", "$2a$12$placeholder", 1) + peerUID, err := database.CreateUser(context.Background(), "peerowner", "$2a$12$placeholder", 1) if err != nil { t.Fatalf("CreateUser peerowner: %v", err) } @@ -331,27 +332,27 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("equal-rank ban: status = %d, want 403; body: %s", w.Code, w.Body.String()) } - if u, _ := database.GetUserByID(peerUID); u.Banned { + if u, _ := database.GetUserByID(context.Background(), peerUID); u.Banned { t.Fatal("equal-rank target must not be banned") } // A lower-positioned role that still holds ADMINISTRATOR (panel access): // its holder must not be able to ban the higher-ranked owner. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, permissions, position, is_default) VALUES (9, 'JuniorAdmin', ?, 50, 0)`, permissions.Administrator, ); err != nil { t.Fatalf("inserting junior admin role: %v", err) } - juniorUID, err := database.CreateUser("junioradmin", "$2a$12$placeholder", 9) + juniorUID, err := database.CreateUser(context.Background(), "junioradmin", "$2a$12$placeholder", 9) if err != nil { t.Fatalf("CreateUser junioradmin: %v", err) } juniorToken := "junior-token-" + t.Name() - if _, err := database.CreateSession(juniorUID, auth.HashToken(juniorToken), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), juniorUID, auth.HashToken(juniorToken), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession junior: %v", err) } - ownerUser, err := database.GetUserByUsername("adminuser") + ownerUser, err := database.GetUserByUsername(context.Background(), "adminuser") if err != nil || ownerUser == nil { t.Fatalf("GetUserByUsername adminuser: %v", err) } @@ -360,12 +361,12 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("junior bans owner: status = %d, want 403; body: %s", w.Code, w.Body.String()) } - if u, _ := database.GetUserByID(ownerUser.ID); u.Banned { + if u, _ := database.GetUserByID(context.Background(), ownerUser.ID); u.Banned { t.Fatal("owner must not be banned by a lower rank") } // Downward ban still works: junior admin (pos 50) bans a member (pos 40). - memberUID, err := database.CreateUser("banme", "$2a$12$placeholder", 3) + memberUID, err := database.CreateUser(context.Background(), "banme", "$2a$12$placeholder", 3) if err != nil { t.Fatalf("CreateUser banme: %v", err) } @@ -374,7 +375,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("junior bans member: status = %d, want 200; body: %s", w.Code, w.Body.String()) } - if u, _ := database.GetUserByID(memberUID); !u.Banned { + if u, _ := database.GetUserByID(context.Background(), memberUID); !u.Banned { t.Fatal("member should be banned by higher-ranked actor") } } @@ -385,7 +386,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { token := createAdminUser(t, database) // Create a target user - targetUID, _ := database.CreateUser("target", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "target", "hash", 3) body := map[string]any{ "banned": true, @@ -398,7 +399,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { } // Verify user is banned in DB - user, err := database.GetUserByID(targetUID) + user, err := database.GetUserByID(context.Background(), targetUID) if err != nil { t.Fatalf("GetUserByID: %v", err) } @@ -412,7 +413,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("rolechange", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "rolechange", "hash", 3) body := map[string]any{ "role_id": float64(2), @@ -423,7 +424,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if user.RoleID != 2 { t.Errorf("RoleID = %d, want 2", user.RoleID) } @@ -461,8 +462,8 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("logoutme", "hash", 3) - _, _ = database.CreateSession(targetUID, "victim-token-hash", "web", "1.2.3.4") + targetUID, _ := database.CreateUser(context.Background(), "logoutme", "hash", 3) + _, _ = database.CreateSession(context.Background(), targetUID, "victim-token-hash", "web", "1.2.3.4") w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil) @@ -470,7 +471,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) { t.Errorf("status = %d, want 204", w.Code) } - sessions, _ := database.GetUserSessions(targetUID) + sessions, _ := database.GetUserSessions(context.Background(), targetUID) if len(sessions) != 0 { t.Errorf("expected 0 sessions after force logout, got %d", len(sessions)) } @@ -494,7 +495,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - _, _ = database.AdminCreateChannel("general", "text", "", "", 0) + _, _ = database.AdminCreateChannel(context.Background(), "general", "text", "", "", 0) w := doRequest(t, handler, http.MethodGet, "/channels", token, nil) @@ -562,7 +563,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("old", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "old", "text", "", "", 0) body := map[string]any{ "name": "updated", @@ -598,7 +599,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "del-me", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) @@ -626,8 +627,8 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - uid, _ := database.CreateUser("actor", "hash", 1) - _ = database.LogAudit(uid, "TEST_ACTION", "user", uid, "detail") + uid, _ := database.CreateUser(context.Background(), "actor", "hash", 1) + _ = database.LogAudit(context.Background(), uid, "TEST_ACTION", "user", uid, "detail") w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=10&offset=0", token, nil) @@ -702,7 +703,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) { } // Verify the change was persisted - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting: %v", err) } @@ -733,9 +734,9 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Admin (role 2) can authenticate but is not Owner (role 1, position 100) - adminUID, _ := database.CreateUser("adminonly", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) token := "admin-only-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -768,7 +769,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { token := createAdminUser(t, database) // Create a target user to act on. - targetUID, _ := database.CreateUser("ctxtarget", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "ctxtarget", "hash", 3) body := map[string]any{"banned": true, "ban_reason": "context test"} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -779,7 +780,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { // The audit log should have a non-zero actor_id showing the actor was // resolved (not 0, which would indicate a failed context lookup). - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } @@ -801,8 +802,8 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("logoutctx", "hash", 3) - _, _ = database.CreateSession(targetUID, "victim-hash-ctx", "web", "1.2.3.4") + targetUID, _ := database.CreateUser(context.Background(), "logoutctx", "hash", 3) + _, _ = database.CreateSession(context.Background(), targetUID, "victim-hash-ctx", "web", "1.2.3.4") w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil) @@ -810,7 +811,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) } - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } @@ -870,7 +871,7 @@ func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) { } // The valid key must NOT have been written because the request was rejected. - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting: %v", err) } @@ -953,7 +954,7 @@ func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrat handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { t.Fatalf("enroll admin user: %v", err) } @@ -993,7 +994,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { token := createAdminUser(t, database) // Create a second user so the list is non-trivial. - _, _ = database.CreateUser("plainuser", "supersecretbcrypthash", 3) + _, _ = database.CreateUser(context.Background(), "plainuser", "supersecretbcrypthash", 3) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -1067,7 +1068,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3) + targetUID, _ := database.CreateUser(context.Background(), "patchvictim", "topsecretbcrypt", 3) body := map[string]any{ "banned": true, @@ -1095,7 +1096,7 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("patchtotp", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "patchtotp", "hash", 3) w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{ "banned": false, @@ -1210,7 +1211,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("before", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "before", "text", "", "", 0) body := map[string]any{"name": "after"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) @@ -1231,7 +1232,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("patchme", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "patchme", "text", "", "", 0) body := map[string]any{"name": "patched"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) @@ -1246,7 +1247,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("delete-me", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "delete-me", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) @@ -1266,7 +1267,7 @@ func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("del-no-hub", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "del-no-hub", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) if w.Code != http.StatusNoContent { diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index 888286df..2a287def 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -1,6 +1,7 @@ package admin import ( + "context" "fmt" "io" "log/slog" @@ -41,7 +42,10 @@ func handleBackup(database *db.DB) http.Handler { timestamp := time.Now().UTC().Format("20060102_150405") backupPath := filepath.Join(backupDir, "chatserver_"+timestamp+".db") - if err := database.BackupTo(backupPath); err != nil { + // Detached like the restore path's safety backup: an interrupted + // VACUUM INTO leaves a truncated .db that handleListBackups would + // present as restorable. + if err := database.BackupTo(context.WithoutCancel(r.Context()), backupPath); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed") return } @@ -49,7 +53,7 @@ func handleBackup(database *db.DB) http.Handler { actor := actorFromContext(r) backupName := filepath.Base(backupPath) slog.Info("database backup created", "actor_id", actor, "name", backupName) - db.WriteAudit(database, actor, "backup_create", "server", 0, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "backup_create", "server", 0, fmt.Sprintf("backup saved: %s", backupName)) writeJSON(w, http.StatusOK, map[string]string{ @@ -133,7 +137,7 @@ func handleDeleteBackup(database *db.DB) http.Handler { actor := actorFromContext(r) slog.Info("backup deleted", "actor_id", actor, "name", name) - db.WriteAudit(database, actor, "backup_delete", "server", 0, "deleted backup "+name) + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "backup_delete", "server", 0, "deleted backup "+name) w.WriteHeader(http.StatusNoContent) }) @@ -160,9 +164,12 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { dbPath := filepath.Join("data", "chatserver.db") - // Safety: create a pre-restore backup before overwriting. + // Safety: create a pre-restore backup before overwriting. WithoutCancel: + // the restore proceeds regardless of client disconnect (Close/copyFile + // below are not ctx-aware), so the safety backup must not be skippable + // by a canceled request ctx. preRestore := filepath.Join("data", "backups", "pre_restore_"+time.Now().UTC().Format("20060102_150405")+".db") - if err := database.BackupTo(preRestore); err != nil { + if err := database.BackupTo(context.WithoutCancel(r.Context()), preRestore); err != nil { slog.Warn("pre-restore backup failed", "err", err) } @@ -171,7 +178,7 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { // Checkpoint the WAL and close the database connection before overwriting // to prevent corruption from concurrent writes (BUG-096). - if _, checkpointErr := database.SQLDb().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); checkpointErr != nil { + if _, checkpointErr := database.SQLDb().ExecContext(context.WithoutCancel(r.Context()), "PRAGMA wal_checkpoint(TRUNCATE)"); checkpointErr != nil { slog.Warn("pre-restore WAL checkpoint failed", "err", checkpointErr) } diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 86299f41..4a2cf1c2 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "net/http" "os" @@ -77,9 +78,9 @@ func TestHandleBackup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - adminUID, _ := database.CreateUser("backupadmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "backupadmin", "hash", 2) token := "backup-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -226,9 +227,9 @@ func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - adminUID, _ := database.CreateUser("deladmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "deladmin", "hash", 2) token := "del-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") // Create the file so path validation doesn't return 404 before the 403. backupDir := filepath.Join(tmpDir, "data", "backups") @@ -354,9 +355,9 @@ func TestHandleRestoreBackup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - adminUID, _ := database.CreateUser("restoreadmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "restoreadmin", "hash", 2) token := "restore-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") // Create files so path checks pass before auth check. backupDir := filepath.Join(tmpDir, "data", "backups") diff --git a/Server/admin/handlers_channel_perms.go b/Server/admin/handlers_channel_perms.go index 9ae893db..83daf7ba 100644 --- a/Server/admin/handlers_channel_perms.go +++ b/Server/admin/handlers_channel_perms.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "fmt" "log/slog" @@ -27,7 +28,7 @@ func getPermChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id") return nil } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") return nil @@ -55,7 +56,7 @@ func handleGetChannelPermissions(database *db.DB) http.HandlerFunc { if ch == nil { return } - overrides, err := database.ListChannelRoleOverrides(ch.ID) + overrides, err := database.ListChannelRoleOverrides(r.Context(), ch.ID) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel permissions") return @@ -81,7 +82,7 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") return } - role, err := database.GetRoleByID(roleID) + role, err := database.GetRoleByID(r.Context(), roleID) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch role") return @@ -100,7 +101,7 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid allow := req.Allow & permissions.AllPerms deny := req.Deny & permissions.AllPerms - if err := database.UpsertChannelOverride(ch.ID, roleID, allow, deny); err != nil { + if err := database.UpsertChannelOverride(r.Context(), ch.ID, roleID, allow, deny); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel permission") return } @@ -108,7 +109,7 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid actor := actorFromContext(r) slog.Info("channel permissions updated", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID, "allow", allow, "deny", deny) - db.WriteAudit(database, actor, "channel_perms_update", "channel", ch.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_perms_update", "channel", ch.ID, fmt.Sprintf("set overrides for role %s on #%s (allow=%#x deny=%#x)", role.Name, ch.Name, allow, deny)) if permInvalidator != nil { @@ -140,14 +141,14 @@ func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInva return } - if err := database.DeleteChannelOverride(ch.ID, roleID); err != nil { + if err := database.DeleteChannelOverride(r.Context(), ch.ID, roleID); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel permission") return } actor := actorFromContext(r) slog.Info("channel permissions cleared", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID) - db.WriteAudit(database, actor, "channel_perms_clear", "channel", ch.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_perms_clear", "channel", ch.ID, fmt.Sprintf("cleared overrides for role %d on #%s", roleID, ch.Name)) if permInvalidator != nil { diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go index dc48095d..449ed264 100644 --- a/Server/admin/handlers_channel_perms_test.go +++ b/Server/admin/handlers_channel_perms_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "net/http" "testing" @@ -31,7 +32,7 @@ func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -80,7 +81,7 @@ func TestGetChannelPermissions_DMRejected(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("dm-chan", "dm", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0) if err != nil { t.Fatalf("CreateChannel dm: %v", err) } @@ -101,7 +102,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -114,7 +115,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - allow, deny, err := database.GetChannelPermissions(chID, 3) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 3) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -129,7 +130,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { t.Errorf("RefreshChannelVisibility not called for channel %d", chID) } - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } @@ -149,7 +150,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret2", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret2", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -162,7 +163,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - allow, deny, err := database.GetChannelPermissions(chID, 3) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 3) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -179,7 +180,7 @@ func TestPutChannelPermission_UnknownRole(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret3", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret3", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -197,7 +198,7 @@ func TestPutChannelPermission_NonAdminForbidden(t *testing.T) { _ = createAdminUser(t, database) memberToken := createMemberUser(t, database) - chID, err := database.CreateChannel("secret4", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret4", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -218,11 +219,11 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret5", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret5", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - if err := database.UpsertChannelOverride(chID, 3, 0, permissions.ReadMessages); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 3, 0, permissions.ReadMessages); err != nil { t.Fatalf("UpsertChannelOverride: %v", err) } @@ -232,7 +233,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) } - allow, deny, err := database.GetChannelPermissions(chID, 3) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 3) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } diff --git a/Server/admin/handlers_channels.go b/Server/admin/handlers_channels.go index 9b96978a..120634c5 100644 --- a/Server/admin/handlers_channels.go +++ b/Server/admin/handlers_channels.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "fmt" "log/slog" @@ -63,7 +64,7 @@ func validateCategoryType(channelType, category string) string { func handleListChannels(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - channels, err := database.ListChannels() + channels, err := database.ListChannels(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels") return @@ -102,20 +103,20 @@ func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - id, err := database.AdminCreateChannel(req.Name, req.Type, req.Category, req.Topic, req.Position) + id, err := database.AdminCreateChannel(r.Context(), req.Name, req.Type, req.Category, req.Topic, req.Position) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel") return } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(r.Context(), id) if err != nil || ch == nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel") return } actor := actorFromContext(r) slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type) - db.WriteAudit(database, actor, "channel_create", "channel", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_create", "channel", id, fmt.Sprintf("created #%s (%s)", req.Name, req.Type)) if hub != nil { hub.BroadcastChannelCreate(ch) @@ -141,7 +142,7 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - existing, err := database.GetChannel(id) + existing, err := database.GetChannel(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") return @@ -164,17 +165,17 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - if err := database.AdminUpdateChannel(id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil { + if err := database.AdminUpdateChannel(r.Context(), id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update channel") return } actor := actorFromContext(r) slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name) - db.WriteAudit(database, actor, "channel_update", "channel", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_update", "channel", id, fmt.Sprintf("updated #%s", req.Name)) - updated, err := database.GetChannel(id) + updated, err := database.GetChannel(r.Context(), id) if err != nil || updated == nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel") return @@ -194,7 +195,7 @@ func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - existing, err := database.GetChannel(id) + existing, err := database.GetChannel(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") return @@ -204,13 +205,13 @@ func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - if err := database.AdminDeleteChannel(id); err != nil { + if err := database.AdminDeleteChannel(r.Context(), id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel") return } actor := actorFromContext(r) slog.Warn("channel deleted", "actor_id", actor, "channel_id", id, "name", existing.Name) - db.WriteAudit(database, actor, "channel_delete", "channel", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_delete", "channel", id, fmt.Sprintf("deleted #%s", existing.Name)) if hub != nil { hub.BroadcastChannelDelete(id) @@ -224,7 +225,7 @@ func handleGetAuditLog(database *db.DB) http.HandlerFunc { limit := queryInt(r, "limit", 50, 1) offset := queryInt(r, "offset", 0, 0) - entries, err := database.GetAuditLog(limit, offset) + entries, err := database.GetAuditLog(r.Context(), limit, offset) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get audit log") return diff --git a/Server/admin/handlers_settings.go b/Server/admin/handlers_settings.go index 4dabe9be..46e611b1 100644 --- a/Server/admin/handlers_settings.go +++ b/Server/admin/handlers_settings.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "errors" "fmt" @@ -15,7 +16,7 @@ import ( func handleGetSettings(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings") return @@ -48,7 +49,7 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc { return } - if err := validateRequire2FAUpdate(database, normalizedUpdates); err != nil { + if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) return } @@ -57,13 +58,13 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc { // Apply all settings atomically so a mid-loop failure doesn't leave // partial updates. - tx, err := database.Begin() + tx, err := database.BeginTx(r.Context(), nil) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to start transaction") return } for key, value := range normalizedUpdates { - if _, txErr := tx.Exec( + if _, txErr := tx.ExecContext(r.Context(), `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value, @@ -79,11 +80,11 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc { } for key := range normalizedUpdates { slog.Info("setting changed", "actor_id", actor, "key", key) - db.WriteAudit(database, actor, "setting_change", "setting", 0, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "setting_change", "setting", 0, fmt.Sprintf("%s updated", key)) } - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings") return @@ -112,8 +113,8 @@ func normalizeSettingUpdates(updates map[string]string) (map[string]string, erro return normalized, nil } -func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error { - targetRequire2FA, err := targetBoolSetting(database, updates, "require_2fa") +func validateRequire2FAUpdate(ctx context.Context, database *db.DB, updates map[string]string) error { + targetRequire2FA, err := targetBoolSetting(ctx, database, updates, "require_2fa") if err != nil { return err } @@ -121,7 +122,7 @@ func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error return nil } - registrationOpen, err := targetBoolSetting(database, updates, "registration_open") + registrationOpen, err := targetBoolSetting(ctx, database, updates, "registration_open") if err != nil { return err } @@ -129,7 +130,7 @@ func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error return fmt.Errorf("require_2fa cannot be enabled while registration is open") } - count, err := database.CountUsersWithoutTOTP() + count, err := database.CountUsersWithoutTOTP(ctx) if err != nil { return fmt.Errorf("failed to validate 2FA enrollment") } @@ -139,11 +140,11 @@ func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error return nil } -func targetBoolSetting(database *db.DB, updates map[string]string, key string) (bool, error) { +func targetBoolSetting(ctx context.Context, database *db.DB, updates map[string]string, key string) (bool, error) { if value, ok := updates[key]; ok { return parseBooleanSettingValue(value) } - value, err := database.GetSetting(key) + value, err := database.GetSetting(ctx, key) if errors.Is(err, db.ErrNotFound) { return false, nil } diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index 2f2eb0da..af701fdd 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "errors" "fmt" @@ -15,7 +16,7 @@ import ( func handleGetStats(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - stats, err := database.GetServerStats() + stats, err := database.GetServerStats(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats") return @@ -32,7 +33,7 @@ func handleListUsers(database *db.DB) http.HandlerFunc { limit := queryInt(r, "limit", 50, 1) offset := queryInt(r, "offset", 0, 0) - users, err := database.ListAllUsers(limit, offset) + users, err := database.ListAllUsers(r.Context(), limit, offset) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users") return @@ -81,7 +82,7 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis return } - user, err := database.GetUserByID(id) + user, err := database.GetUserByID(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user") return @@ -131,7 +132,7 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis } if req.RoleID != nil { - if _, err := database.Exec(`UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { + if _, err := database.ExecContext(r.Context(), `UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role") return } @@ -139,21 +140,21 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis if permInvalidator != nil { permInvalidator.InvalidateUser(id) } - db.WriteAudit(database, actor, "role_change", "user", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "role_change", "user", id, fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID)) - if role, err := database.GetRoleByID(*req.RoleID); err == nil && role != nil { + if role, err := database.GetRoleByID(r.Context(), *req.RoleID); err == nil && role != nil { if hub != nil { hub.BroadcastMemberUpdate(id, role.Name) } } } - updated, err := database.GetUserByID(id) + updated, err := database.GetUserByID(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user") return } - writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(database, updated)) + writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(r.Context(), database, updated)) } } @@ -165,13 +166,13 @@ func handleForceLogout(database *db.DB) http.HandlerFunc { return } - if err := database.ForceLogoutUser(id); err != nil { + if err := database.ForceLogoutUser(r.Context(), id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user") return } actor := actorFromContext(r) slog.Info("force logout", "actor_id", actor, "target_user_id", id) - db.WriteAudit(database, actor, "force_logout", "user", id, "all sessions terminated") + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "force_logout", "user", id, "all sessions terminated") w.WriteHeader(http.StatusNoContent) } } diff --git a/Server/admin/logstream.go b/Server/admin/logstream.go index e92f5488..097fccbe 100644 --- a/Server/admin/logstream.go +++ b/Server/admin/logstream.go @@ -341,7 +341,10 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { http.Error(w, string(errResp), http.StatusUnauthorized) return } - sess, err := database.GetSessionByTokenHash(entry.tokenHash) + // Stream lifetime == request lifetime, so all session re-checks below + // use the stream request's context. + ctx := r.Context() + sess, err := database.GetSessionByTokenHash(ctx, entry.tokenHash) if err != nil || sess == nil || auth.IsSessionExpired(sess.ExpiresAt) { errResp, _ := json.Marshal(map[string]string{ "error": "UNAUTHORIZED", @@ -351,15 +354,15 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { return } sessionStillAuthorized := func() bool { - current, currentErr := database.GetSessionByTokenHash(entry.tokenHash) + current, currentErr := database.GetSessionByTokenHash(ctx, entry.tokenHash) if currentErr != nil || current == nil || auth.IsSessionExpired(current.ExpiresAt) { return false } - user, userErr := database.GetUserByID(current.UserID) + user, userErr := database.GetUserByID(ctx, current.UserID) if userErr != nil || user == nil { return false } - role, roleErr := database.GetRoleByID(user.RoleID) + role, roleErr := database.GetRoleByID(ctx, user.RoleID) if roleErr != nil || role == nil { return false } @@ -408,7 +411,6 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { keepalive := time.NewTicker(15 * time.Second) defer keepalive.Stop() - ctx := r.Context() for { select { case entry := <-ch: diff --git a/Server/admin/logstream_test.go b/Server/admin/logstream_test.go index 0a7b324d..f2bbb390 100644 --- a/Server/admin/logstream_test.go +++ b/Server/admin/logstream_test.go @@ -73,7 +73,7 @@ func TestHandleLogStream_BackfillStopsAfterSessionRevocation(t *testing.T) { logBuf.Write(LogEntry{Timestamp: "2026-03-29T10:00:00Z", Level: "info", Message: "first", Source: "test"}) logBuf.Write(LogEntry{Timestamp: "2026-03-29T10:00:01Z", Level: "info", Message: "second", Source: "test"}) - userID, err := database.CreateUser("owner", "hash", 1) + userID, err := database.CreateUser(context.Background(), "owner", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -83,7 +83,7 @@ func TestHandleLogStream_BackfillStopsAfterSessionRevocation(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -99,7 +99,7 @@ func TestHandleLogStream_BackfillStopsAfterSessionRevocation(t *testing.T) { writer := &revokingSSEWriter{ header: make(http.Header), revoke: func() { - _ = database.DeleteSession(tokenHash) + _ = database.DeleteSession(context.Background(), tokenHash) }, cancel: cancel, } diff --git a/Server/admin/middleware.go b/Server/admin/middleware.go index 1f8c4b9b..11a9b105 100644 --- a/Server/admin/middleware.go +++ b/Server/admin/middleware.go @@ -31,7 +31,7 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } hash := auth.HashToken(token) - sess, err := database.GetSessionByTokenHash(hash) + sess, err := database.GetSessionByTokenHash(r.Context(), hash) if err != nil || sess == nil { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") return @@ -42,13 +42,13 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { return } - user, err := database.GetUserByID(sess.UserID) + user, err := database.GetUserByID(r.Context(), sess.UserID) if err != nil || user == nil { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found") return } - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(r.Context(), user.RoleID) if err != nil || role == nil { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found") return @@ -77,7 +77,7 @@ func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler { return } - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(r.Context(), user.RoleID) if err != nil || role == nil { writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found") return diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 40d1c733..7ee7869a 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -157,23 +157,23 @@ func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) { // Create a user initially with a valid role, then mutate role_id to a // nonexistent value (disabling FK checks temporarily so SQLite allows it). - uid, err := database.CreateUser("orphanuser", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "orphanuser", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil || user == nil { t.Fatalf("GetUserByID: %v", err) } // Disable FK enforcement, update role_id, re-enable. - if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil { t.Fatalf("disable FK: %v", err) } - if _, err := database.Exec(`UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { t.Fatalf("UPDATE role_id: %v", err) } - if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil { t.Fatalf("re-enable FK: %v", err) } user.RoleID = 9999 // mirror the DB value in our in-memory struct @@ -213,11 +213,11 @@ func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) { func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) { database := openWhiteboxTestDB(t) - uid, err := database.CreateUser("ownerpass", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "ownerpass", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil || user == nil { t.Fatalf("GetUserByID: %v", err) } @@ -251,23 +251,23 @@ func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) { database := openWhiteboxTestDB(t) handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil) - uid, err := database.CreateUser("noroleuser", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "noroleuser", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "norole-token" - if _, err := database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Disable FK enforcement, assign a non-existent role_id, re-enable. - if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil { t.Fatalf("disable FK: %v", err) } - if _, err := database.Exec(`UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { t.Fatalf("UPDATE role_id: %v", err) } - if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil { t.Fatalf("re-enable FK: %v", err) } diff --git a/Server/admin/middleware_coverage_test.go b/Server/admin/middleware_coverage_test.go index 2c3a6f69..c76cbe8d 100644 --- a/Server/admin/middleware_coverage_test.go +++ b/Server/admin/middleware_coverage_test.go @@ -4,6 +4,7 @@ package admin_test // ownerOnlyMiddleware, and related helpers. import ( + "context" "net/http" "testing" "time" @@ -22,19 +23,19 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { // Create a user and session, then manually expire the session by setting // expires_at to a past timestamp via the exported Exec helper. - uid, err := database.CreateUser("expireduser", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "expireduser", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "expired-session-token" tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Set expires_at to yesterday so the session is treated as expired. pastTime := time.Now().Add(-24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z") - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `UPDATE sessions SET expires_at = ? WHERE token = ?`, pastTime, tokenHash, ); err != nil { diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go index 0edbfc44..db342cca 100644 --- a/Server/admin/setup_handler.go +++ b/Server/admin/setup_handler.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "errors" "log/slog" @@ -42,7 +43,7 @@ type setupResponse struct { // handleSetupStatus returns whether initial setup is needed (no users exist). func handleSetupStatus(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - count, err := database.UserCount() + count, err := database.UserCount(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check user count") return @@ -110,7 +111,7 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st // Atomically check no users exist and create the owner (BUG-119). // This closes the TOCTOU race between UserCount() and CreateUser(). - uid, err := database.CreateOwnerIfEmpty(req.Username, hash, ownerRoleID) + uid, err := database.CreateOwnerIfEmpty(r.Context(), req.Username, hash, ownerRoleID) if errors.Is(err, db.ErrConflict) { writeErr(w, http.StatusForbidden, "FORBIDDEN", "setup has already been completed") return @@ -132,27 +133,27 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st if len(device) > maxDeviceLen { device = device[:maxDeviceLen] } - if _, err := database.CreateSession(uid, auth.HashToken(token), device, host); err != nil { + if _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, host); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session") return } // Create default channels under canonical categories. - _, _ = database.CreateChannel("general", "text", "Text Channels", "Welcome to the server!", 0) - _, _ = database.CreateChannel("General", "voice", "Voice Channels", "", 0) + _, _ = database.CreateChannel(r.Context(), "general", "text", "Text Channels", "Welcome to the server!", 0) + _, _ = database.CreateChannel(r.Context(), "General", "voice", "Voice Channels", "", 0) // Generate a bootstrap invite code so the owner can invite others. // Bound it (5 uses / 24h) rather than minting an unlimited, non-expiring // invite — the owner can create fresh invites once logged in. bootstrapInviteExpiry := time.Now().Add(24 * time.Hour) - inviteCode, err := database.CreateInvite(uid, 5, &bootstrapInviteExpiry) + inviteCode, err := database.CreateInvite(r.Context(), uid, 5, &bootstrapInviteExpiry) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate invite code") return } slog.Info("server setup completed", "owner", req.Username, "user_id", uid) - db.WriteAudit(database, uid, "server_setup", "server", 0, + db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "server_setup", "server", 0, "initial setup: owner account created, default channel and invite generated") writeJSON(w, http.StatusCreated, setupResponse{ diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 8bbb57b2..54331625 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -86,7 +87,7 @@ func TestSetup_CreatesOwner(t *testing.T) { } // Verify user was created with Owner role. - user, err := database.GetUserByUsername("myadmin") + user, err := database.GetUserByUsername(context.Background(), "myadmin") if err != nil || user == nil { t.Fatal("user not found in database after setup") } @@ -185,7 +186,7 @@ func TestSetup_ConcurrentRace(t *testing.T) { } // Verify only one user exists in the database. - count, err := database.UserCount() + count, err := database.UserCount(context.Background()) if err != nil { t.Fatalf("UserCount: %v", err) } diff --git a/Server/admin/types.go b/Server/admin/types.go index 299b0a17..3ba462f5 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -1,6 +1,10 @@ package admin -import "github.com/owncord/server/db" +import ( + "context" + + "github.com/owncord/server/db" +) // ─── Context keys ───────────────────────────────────────────────────────────── @@ -93,9 +97,9 @@ func toAdminUserResponse(u db.UserWithRole) adminUserResponse { // toAdminUserResponseFromUser converts a plain db.User to the safe response // shape, resolving the role name via the database. -func toAdminUserResponseFromUser(database *db.DB, u *db.User) adminUserResponse { +func toAdminUserResponseFromUser(ctx context.Context, database *db.DB, u *db.User) adminUserResponse { roleName := "" - if role, err := database.GetRoleByID(u.RoleID); err == nil && role != nil { + if role, err := database.GetRoleByID(ctx, u.RoleID); err == nil && role != nil { roleName = role.Name } return adminUserResponse{ diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index d9d3b8b8..fa8b5710 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -136,9 +137,9 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // Create admin user (not owner - role 2) - adminUID, _ := database.CreateUser("adminonly2", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "adminonly2", "hash", 2) token := "admin-role-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) if w.Code != http.StatusForbidden { diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index b6e83fed..d69ca10d 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "fmt" @@ -107,7 +108,7 @@ 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 { return func(w http.ResponseWriter, r *http.Request) { - registrationOpen, err := isRegistrationOpen(database) + registrationOpen, err := isRegistrationOpen(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -123,7 +124,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { return } - require2FA, err := isRequire2FAEnabled(database) + require2FA, err := isRequire2FAEnabled(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -190,7 +191,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { // Atomically consume the invite and create the user so failed // registrations do not burn a valid invite code. - uid, err := database.CreateUserWithInvite(req.Username, hash, int(permissions.MemberRoleID), req.InviteCode) + uid, err := database.CreateUserWithInvite(r.Context(), req.Username, hash, int(permissions.MemberRoleID), req.InviteCode) if err != nil { // UNIQUE constraint violation → duplicate username → 400. // Any other DB error → 500. @@ -211,7 +212,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { ip := clientIP(r) slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip) - db.WriteAudit(database, uid, "user_register", "user", uid, + db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "user_register", "user", uid, "new account created via invite") // Issue session. @@ -225,7 +226,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { } device := truncateDevice(r.Header.Get("User-Agent")) - if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil { + if _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, ip); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to create session", @@ -233,7 +234,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { return } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(r.Context(), uid) if err != nil || user == nil { slog.Error("failed to fetch user after registration", "user_id", uid, "error", err) writeJSON(w, http.StatusInternalServerError, errorResponse{ @@ -302,7 +303,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. // Constant-time lookup: always attempt bcrypt compare even when user // does not exist to prevent timing-based username enumeration. - user, err := database.GetUserByUsername(req.Username) + user, err := database.GetUserByUsername(r.Context(), req.Username) // Distinguish DB errors from authentication failures. DB errors // should NOT increment the rate limiter — otherwise a transient @@ -334,11 +335,11 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. if !auth.CheckPassword(storedHash, req.Password) { // Track failures per-IP; lockout on threshold. if !limiter.Allow(failKey, loginFailureThreshold, loginFailureWindow) { - limiter.Lockout(lockKey, loginLockoutDuration) + limiter.Lockout(r.Context(), lockKey, loginLockoutDuration) } // BUG-110: Track failures per-username; lockout on threshold. if !limiter.Allow(userFailKey, loginUserFailureThreshold, loginUserFailureWindow) { - limiter.Lockout(userLockKey, loginUserLockoutDuration) + limiter.Lockout(r.Context(), userLockKey, loginUserLockoutDuration) } slog.Info("login failed", "ip", ip, "username_len", len(req.Username)) writeJSON(w, http.StatusUnauthorized, errorResponse{ @@ -349,12 +350,12 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } // Reset failure counters on success. - limiter.Reset(failKey) - limiter.Reset(userFailKey) + limiter.Reset(r.Context(), failKey) + limiter.Reset(r.Context(), userFailKey) if auth.IsEffectivelyBanned(user) { slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip) - db.WriteAudit(database, user.ID, "login_blocked_banned", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "login_blocked_banned", "user", user.ID, "banned user attempted login from "+ip) writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", @@ -363,7 +364,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. return } - require2FA, err := isRequire2FAEnabled(database) + require2FA, err := isRequire2FAEnabled(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -395,7 +396,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } // Issue session. - token, err := issueSession(database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip) + token, err := issueSession(r.Context(), database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -409,7 +410,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. // would leave the user permanently "online" if they never open a WS // connection or if the client crashes before connecting. slog.Info("user logged in", "username", user.Username, "user_id", user.ID, "ip", ip) - db.WriteAudit(database, user.ID, "user_login", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "user_login", "user", user.ID, "logged in from "+ip) writeJSON(w, http.StatusOK, authSuccessResponse{ Token: token, @@ -431,7 +432,9 @@ func handleLogout(database *db.DB) http.HandlerFunc { return } - if err := database.DeleteSession(sess.TokenHash); err != nil { + // The client clears its token optimistically — once logout reaches the + // server, the revocation must not die with a dropped connection. + if err := database.DeleteSession(context.WithoutCancel(r.Context()), sess.TokenHash); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to logout", @@ -440,7 +443,7 @@ func handleLogout(database *db.DB) http.HandlerFunc { } slog.Info("user logged out", "user_id", sess.UserID) - db.WriteAudit(database, sess.UserID, "user_logout", "user", sess.UserID, "") + db.WriteAudit(context.WithoutCancel(r.Context()), database, sess.UserID, "user_logout", "user", sess.UserID, "") w.WriteHeader(http.StatusNoContent) } @@ -511,7 +514,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle failKey := fmt.Sprintf("delete_fail:%d", user.ID) if !auth.CheckPassword(user.PasswordHash, req.Password) { if !limiter.Allow(failKey, deleteAccountFailureThreshold, deleteAccountFailureWindow) { - limiter.Lockout(lockKey, deleteAccountLockoutDuration) + limiter.Lockout(r.Context(), lockKey, deleteAccountLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -519,7 +522,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) if err := database.DeleteAccount(r.Context(), user.ID); err != nil { if errors.Is(err, db.ErrLastAdmin) { @@ -539,7 +542,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle ip := clientIP(r) slog.Info("account deleted", "username", user.Username, "user_id", user.ID, "ip", ip) - db.WriteAudit(database, user.ID, "account_deleted", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "account_deleted", "user", user.ID, "account self-deleted from "+ip) w.WriteHeader(http.StatusNoContent) @@ -574,27 +577,27 @@ func truncateDevice(ua string) string { return ua } -func issueSession(database *db.DB, userID int64, device, ip string) (string, error) { +func issueSession(ctx context.Context, database *db.DB, userID int64, device, ip string) (string, error) { token, err := auth.GenerateToken() if err != nil { return "", err } - if _, err := database.CreateSession(userID, auth.HashToken(token), device, ip); err != nil { + if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), device, ip); err != nil { return "", err } return token, nil } -func isRequire2FAEnabled(database *db.DB) (bool, error) { - return getBooleanSetting(database, "require_2fa", false) +func isRequire2FAEnabled(ctx context.Context, database *db.DB) (bool, error) { + return getBooleanSetting(ctx, database, "require_2fa", false) } -func isRegistrationOpen(database *db.DB) (bool, error) { - return getBooleanSetting(database, "registration_open", true) +func isRegistrationOpen(ctx context.Context, database *db.DB) (bool, error) { + return getBooleanSetting(ctx, database, "registration_open", true) } -func getBooleanSetting(database *db.DB, key string, defaultValue bool) (bool, error) { - value, err := database.GetSetting(key) +func getBooleanSetting(ctx context.Context, database *db.DB, key string, defaultValue bool) (bool, error) { + value, err := database.GetSetting(ctx, key) if err != nil { if errors.Is(err, db.ErrNotFound) { return defaultValue, nil diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index 6f1eeb67..9720e74f 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -104,8 +105,8 @@ func TestRegister_Success(t *testing.T) { router := buildAuthRouter(database, limiter) // Create an invite first. - ownerID, _ := database.CreateUser("owner", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "newuser", @@ -132,12 +133,12 @@ func TestRegister_RegistrationClosed(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - if _, err := database.Exec(`UPDATE settings SET value = '0' WHERE key = 'registration_open'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = '0' WHERE key = 'registration_open'`); err != nil { t.Fatalf("close registration: %v", err) } - ownerID, _ := database.CreateUser("owner", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "closeduser", @@ -171,8 +172,8 @@ func TestRegister_WeakPassword(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("owner2", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner2", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "newuser", @@ -190,8 +191,8 @@ func TestRegister_InviteUsedUp(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("owner3", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) // max 1 use + ownerID, _ := database.CreateUser(context.Background(), "owner3", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) // max 1 use // First registration should succeed. postJSON(t, router, "/api/v1/auth/register", map[string]string{ @@ -217,9 +218,9 @@ func TestRegister_DuplicateUsername_DoesNotConsumeInvite(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("owner4", "hash", 1) - _, _ = database.CreateUser("takenuser", "hash", 4) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner4", "hash", 1) + _, _ = database.CreateUser(context.Background(), "takenuser", "hash", 4) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) duplicate := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "takenuser", @@ -277,7 +278,7 @@ func TestLogin_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("loginuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "loginuser", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "loginuser", @@ -301,7 +302,7 @@ func TestLogin_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("loginuser2", hash, 4) + _, _ = database.CreateUser(context.Background(), "loginuser2", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "loginuser2", @@ -361,7 +362,7 @@ func TestLogin_UsernameLockoutAcrossDifferentIPs(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("lockoutuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "lockoutuser", hash, 4) for i := 0; i < 10; i++ { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ @@ -388,7 +389,7 @@ func TestLogin_UsernameLockoutBlocksCorrectPasswordFromFreshIP(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("lockoutcorrect", hash, 4) + _, _ = database.CreateUser(context.Background(), "lockoutcorrect", hash, 4) for i := 0; i < 10; i++ { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ @@ -419,7 +420,7 @@ func TestLogin_UsernameLockoutIgnoresCasing(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("casehunt", hash, 4) + _, _ = database.CreateUser(context.Background(), "casehunt", hash, 4) // Trip the per-username lockout using the lowercase spelling, from many IPs // so the per-IP limiter is never the binding cap. @@ -449,7 +450,7 @@ func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("resetuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "resetuser", hash, 4) for i := 0; i < 8; i++ { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ @@ -503,8 +504,8 @@ func TestLogin_RequiresTOTPChallenge(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totpuser", hash, 4) - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { + userID, _ := database.CreateUser(context.Background(), "totpuser", hash, 4) + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -538,8 +539,8 @@ func TestLogin_UsernameLockoutBlocksTOTPChallenge(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totplocked", hash, 4) - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { + userID, _ := database.CreateUser(context.Background(), "totplocked", hash, 4) + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -576,9 +577,9 @@ func TestVerifyTotp_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totpverify", hash, 4) + userID, _ := database.CreateUser(context.Background(), "totpverify", hash, 4) secret := "JBSWY3DPEHPK3PXP" - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -626,9 +627,9 @@ func TestEnableConfirmDisableTotp(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("enrolltotp", hash, 4) + userID, _ := database.CreateUser(context.Background(), "enrolltotp", hash, 4) token, _ := auth.GenerateToken() - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -646,7 +647,7 @@ func TestEnableConfirmDisableTotp(t *testing.T) { t.Fatal("expected qr_uri from enable response") } - userBeforeConfirm, err := database.GetUserByID(userID) + userBeforeConfirm, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID before confirm: %v", err) } @@ -672,7 +673,7 @@ func TestEnableConfirmDisableTotp(t *testing.T) { t.Fatalf("confirm status = %d, want 204; body = %s", confirm.Code, confirm.Body.String()) } - userAfterConfirm, err := database.GetUserByID(userID) + userAfterConfirm, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after confirm: %v", err) } @@ -694,7 +695,7 @@ func TestEnableConfirmDisableTotp(t *testing.T) { t.Fatalf("disable status = %d, want 204; body = %s", deleteRec.Code, deleteRec.Body.String()) } - userAfterDelete, err := database.GetUserByID(userID) + userAfterDelete, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after delete: %v", err) } @@ -709,9 +710,9 @@ func TestTOTPManagement_RequiresPasswordConfirmation(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totppassword", hash, 4) + userID, _ := database.CreateUser(context.Background(), "totppassword", hash, 4) token, _ := auth.GenerateToken() - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -720,7 +721,7 @@ func TestTOTPManagement_RequiresPasswordConfirmation(t *testing.T) { t.Fatalf("enable status = %d, want 400; body = %s", enable.Code, enable.Body.String()) } - userAfterEnable, err := database.GetUserByID(userID) + userAfterEnable, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after failed enable: %v", err) } @@ -749,9 +750,9 @@ func TestVerifyTotp_ConsumesChallengeAfterRepeatedFailures(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totplockout", hash, 4) + userID, _ := database.CreateUser(context.Background(), "totplockout", hash, 4) secret := "JBSWY3DPEHPK3PXP" - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -794,15 +795,15 @@ func TestLogin_Require2FASettingRejectsUsersWithoutEnrollment(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - if _, err := database.Exec(`UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { t.Fatalf("enable require_2fa: %v", err) } - if _, err := database.Exec(`UPDATE settings SET value = 'false' WHERE key = 'registration_open'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'false' WHERE key = 'registration_open'`); err != nil { t.Fatalf("disable registration_open: %v", err) } hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("needsenrollment", hash, 4) + _, _ = database.CreateUser(context.Background(), "needsenrollment", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "needsenrollment", @@ -820,8 +821,8 @@ func TestLogin_BannedUser(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - id, _ := database.CreateUser("banned", hash, 4) - _ = database.BanUser(id, "violated rules", nil) + id, _ := database.CreateUser(context.Background(), "banned", hash, 4) + _ = database.BanUser(context.Background(), id, "violated rules", nil) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "banned", @@ -852,10 +853,10 @@ func TestLogout_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("logoutuser", hash, 4) + uid, _ := database.CreateUser(context.Background(), "logoutuser", hash, 4) token, _ := auth.GenerateToken() tokenHash := auth.HashToken(token) - _, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1") rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil) @@ -864,7 +865,7 @@ func TestLogout_Success(t *testing.T) { } // Session should be gone. - sess, _ := database.GetSessionByTokenHash(tokenHash) + sess, _ := database.GetSessionByTokenHash(context.Background(), tokenHash) if sess != nil { t.Error("Session still exists after logout") } @@ -893,9 +894,9 @@ func TestMe_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("meuser", hash, 4) + uid, _ := database.CreateUser(context.Background(), "meuser", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := getWithToken(t, router, "/api/v1/auth/me", token) @@ -940,7 +941,7 @@ func TestLogin_PasswordWithLeadingSpaceIsPreserved(t *testing.T) { // Hash the password WITH the leading space — this is what was registered. hash, _ := auth.HashPassword(" securePass1") - _, _ = database.CreateUser("spacepassuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "spacepassuser", hash, 4) // Login with the exact same password (including space) must succeed. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -962,7 +963,7 @@ func TestLogin_PasswordWithLeadingSpaceTrimmedFails(t *testing.T) { // Register with password that has a leading space. hash, _ := auth.HashPassword(" securePass1") - _, _ = database.CreateUser("spacepassuser2", hash, 4) + _, _ = database.CreateUser(context.Background(), "spacepassuser2", hash, 4) // Login without the leading space must fail. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -983,7 +984,7 @@ func TestLogin_PasswordWithTrailingSpaceIsPreserved(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("securePass1 ") - _, _ = database.CreateUser("trailingspaceuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "trailingspaceuser", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "trailingspaceuser", @@ -1003,7 +1004,7 @@ func TestLogin_UsernameIsStillTrimmed(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("trimuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "trimuser", hash, 4) // Username with surrounding spaces should resolve to "trimuser". rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -1023,12 +1024,12 @@ func TestRegister_RateLimit(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("rl_owner", "hash", 1) + ownerID, _ := database.CreateUser(context.Background(), "rl_owner", "hash", 1) // Attempt register 4 times (limit=3) — 4th should be rate-limited. var lastCode int for i := range 4 { - code, _ := database.CreateInvite(ownerID, 1, nil) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "rl_user" + string(rune('0'+i)), "password": "securePass1", @@ -1064,10 +1065,10 @@ func TestDeleteAccount_Success(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") // Create as Member (role_id=4) so the last-admin check does not block deletion. - uid, _ := database.CreateUser("deleteuser", hash, 4) + uid, _ := database.CreateUser(context.Background(), "deleteuser", hash, 4) token, _ := auth.GenerateToken() tokenHash := auth.HashToken(token) - _, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{ "password": "correctPass1", @@ -1078,7 +1079,7 @@ func TestDeleteAccount_Success(t *testing.T) { } // User should be anonymised (banned, username changed). - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil { t.Fatalf("GetUserByID after delete: %v", err) } @@ -1093,7 +1094,7 @@ func TestDeleteAccount_Success(t *testing.T) { } // Session should be gone. - sess, _ := database.GetSessionByTokenHash(tokenHash) + sess, _ := database.GetSessionByTokenHash(context.Background(), tokenHash) if sess != nil { t.Error("session should be deleted after account deletion") } @@ -1105,9 +1106,9 @@ func TestDeleteAccount_MissingPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("delnopass", hash, 4) + uid, _ := database.CreateUser(context.Background(), "delnopass", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{}) @@ -1122,9 +1123,9 @@ func TestDeleteAccount_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("delwrong", hash, 4) + uid, _ := database.CreateUser(context.Background(), "delwrong", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{ "password": "wrongPassword1", @@ -1135,7 +1136,7 @@ func TestDeleteAccount_WrongPassword(t *testing.T) { } // Verify user is NOT deleted. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.Banned { t.Error("user should not be deleted after wrong password") } @@ -1148,9 +1149,9 @@ func TestDeleteAccount_LastAdmin(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") // Create as Owner (role_id=1) — the only admin-class user. - uid, _ := database.CreateUser("lastadmin", hash, 1) + uid, _ := database.CreateUser(context.Background(), "lastadmin", hash, 1) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{ "password": "correctPass1", @@ -1161,7 +1162,7 @@ func TestDeleteAccount_LastAdmin(t *testing.T) { } // User should still be intact. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.Banned { t.Error("last admin should not be deleted") } @@ -1189,9 +1190,9 @@ func TestDeleteAccount_LockoutAfterRepeatedFailures(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("dellockout", hash, 4) + uid, _ := database.CreateUser(context.Background(), "dellockout", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // 3 failures should trigger lockout on the 4th attempt. for i := 0; i < 4; i++ { @@ -1218,9 +1219,9 @@ func TestConfirmTOTP_InvalidCode(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpbadcode", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpbadcode", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Enable TOTP first to get a pending secret. enable := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"}) @@ -1239,7 +1240,7 @@ func TestConfirmTOTP_InvalidCode(t *testing.T) { } // Secret should NOT be persisted. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user.TOTPSecret != nil { t.Error("TOTP secret should not be persisted after invalid code") } @@ -1251,9 +1252,9 @@ func TestConfirmTOTP_NoPendingSecret(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpnopending", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpnopending", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Confirm without enabling first — no pending secret. confirm := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token, map[string]string{ @@ -1272,9 +1273,9 @@ func TestConfirmTOTP_MissingPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpnoconfirmpass", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpnoconfirmpass", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Enable TOTP first. postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"}) @@ -1295,9 +1296,9 @@ func TestConfirmTOTP_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpwrongconfirm", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpwrongconfirm", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Enable TOTP first. postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"}) @@ -1337,12 +1338,12 @@ func TestDisableTOTP_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("disabletotpwrong", hash, 4) + uid, _ := database.CreateUser(context.Background(), "disabletotpwrong", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Set TOTP secret directly. - if _, err := database.Exec(`UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -1359,7 +1360,7 @@ func TestDisableTOTP_WrongPassword(t *testing.T) { } // TOTP should still be enabled. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user.TOTPSecret == nil { t.Error("TOTP secret should still be set after wrong password") } @@ -1371,17 +1372,17 @@ func TestDisableTOTP_Require2FABlocksDisable(t *testing.T) { router := buildAuthRouter(database, limiter) // Enable require_2fa setting. - if _, err := database.Exec(`UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { t.Fatalf("enable require_2fa: %v", err) } hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("disabletotpreq", hash, 4) + uid, _ := database.CreateUser(context.Background(), "disabletotpreq", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Set TOTP secret directly. - if _, err := database.Exec(`UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -1398,7 +1399,7 @@ func TestDisableTOTP_Require2FABlocksDisable(t *testing.T) { } // TOTP should still be enabled. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user.TOTPSecret == nil { t.Error("TOTP secret should still be set when require_2fa is enabled") } @@ -1440,10 +1441,10 @@ func TestLogout_SessionGoneAfterLogout(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("logoutsess", hash, 4) + uid, _ := database.CreateUser(context.Background(), "logoutsess", hash, 4) token, _ := auth.GenerateToken() tokenHash := auth.HashToken(token) - _, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1") // First logout should succeed. rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil) @@ -1466,9 +1467,9 @@ func TestMe_ReturnsCorrectUserFields(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("medetailed", hash, 4) + uid, _ := database.CreateUser(context.Background(), "medetailed", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := getWithToken(t, router, "/api/v1/auth/me", token) @@ -1526,9 +1527,9 @@ func containsStr(s, sub string) bool { func expiredInviteDB(t *testing.T) (*db.DB, string) { t.Helper() database := newAuthTestDB(t) - ownerID, _ := database.CreateUser("expowner", "hash", 1) + ownerID, _ := database.CreateUser(context.Background(), "expowner", "hash", 1) past := time.Now().Add(-time.Hour) - code, _ := database.CreateInvite(ownerID, 0, &past) + code, _ := database.CreateInvite(context.Background(), ownerID, 0, &past) return database, code } diff --git a/Server/api/channel_authz_test.go b/Server/api/channel_authz_test.go index e339abb0..a6b1a8f9 100644 --- a/Server/api/channel_authz_test.go +++ b/Server/api/channel_authz_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -18,7 +19,7 @@ import ( // given role on the given channel. func denyReadMessages(t *testing.T, database *db.DB, channelID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, channelID, roleID, permissions.ReadMessages, ) @@ -36,8 +37,8 @@ func TestChannelList_FiltersOutDeniedChannels(t *testing.T) { // Create member user (roleID=4, has READ_MESSAGES by default). token := chTestCreateToken(t, database, "authz-member1", 4) - chVisible, _ := database.CreateChannel("visible", "text", "", "", 0) - chHidden, _ := database.CreateChannel("hidden", "text", "", "", 1) + chVisible, _ := database.CreateChannel(context.Background(), "visible", "text", "", "", 0) + chHidden, _ := database.CreateChannel(context.Background(), "hidden", "text", "", "", 1) _ = chVisible // used implicitly in response // Deny READ_MESSAGES on the hidden channel for the Member role. @@ -70,8 +71,8 @@ func TestChannelList_AdminSeesAllChannels(t *testing.T) { // Owner (roleID=1) has Administrator bit — bypasses all checks. token := chTestCreateToken(t, database, "authz-owner1", 1) - chA, _ := database.CreateChannel("a", "text", "", "", 0) - chB, _ := database.CreateChannel("b", "text", "", "", 1) + chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0) + chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1) // Deny READ_MESSAGES on both channels for all roles. denyReadMessages(t, database, chA, permissions.MemberRoleID) @@ -96,7 +97,7 @@ func TestChannelMessages_DeniedByPermission(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-member2", 4) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role on this channel. denyReadMessages(t, database, chID, permissions.MemberRoleID) @@ -112,7 +113,7 @@ func TestChannelMessages_AdminBypassesDeny(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-owner2", 1) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role — should not affect Owner. denyReadMessages(t, database, chID, permissions.MemberRoleID) @@ -131,17 +132,17 @@ func TestSearch_FiltersResultsByPermission(t *testing.T) { // Create an owner to insert messages (owner can write anywhere). _ = chTestCreateToken(t, database, "authz-owner3", 1) - owner, _ := database.GetUserByUsername("authz-owner3") + owner, _ := database.GetUserByUsername(context.Background(), "authz-owner3") // Member user for search. memberToken := chTestCreateToken(t, database, "authz-member3", 4) - chVisible, _ := database.CreateChannel("pub", "text", "", "", 0) - chHidden, _ := database.CreateChannel("priv", "text", "", "", 1) + chVisible, _ := database.CreateChannel(context.Background(), "pub", "text", "", "", 0) + chHidden, _ := database.CreateChannel(context.Background(), "priv", "text", "", "", 1) // Insert messages in both channels with a common keyword. - _, _ = database.CreateMessage(chVisible, owner.ID, "searchable keyword public", nil) - _, _ = database.CreateMessage(chHidden, owner.ID, "searchable keyword private", nil) + _, _ = database.CreateMessage(context.Background(), chVisible, owner.ID, "searchable keyword public", nil) + _, _ = database.CreateMessage(context.Background(), chHidden, owner.ID, "searchable keyword private", nil) // Deny READ_MESSAGES on the hidden channel for members. denyReadMessages(t, database, chHidden, permissions.MemberRoleID) @@ -167,13 +168,13 @@ func TestSearch_AdminSeesAllResults(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-owner4", 1) - owner, _ := database.GetUserByUsername("authz-owner4") + owner, _ := database.GetUserByUsername(context.Background(), "authz-owner4") - chA, _ := database.CreateChannel("a", "text", "", "", 0) - chB, _ := database.CreateChannel("b", "text", "", "", 1) + chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0) + chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1) - _, _ = database.CreateMessage(chA, owner.ID, "findme alpha", nil) - _, _ = database.CreateMessage(chB, owner.ID, "findme beta", nil) + _, _ = database.CreateMessage(context.Background(), chA, owner.ID, "findme alpha", nil) + _, _ = database.CreateMessage(context.Background(), chB, owner.ID, "findme beta", nil) // Deny READ_MESSAGES on both for member role — admin bypasses. denyReadMessages(t, database, chA, permissions.MemberRoleID) @@ -200,8 +201,8 @@ func TestChannelList_ExcludesDMChannels_Member(t *testing.T) { token := chTestCreateToken(t, database, "dm-excl-member", 4) // Create a normal text channel and a DM channel. - database.CreateChannel("general", "text", "", "", 0) - database.Exec(`INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) + database.CreateChannel(context.Background(), "general", "text", "", "", 0) + database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -227,9 +228,9 @@ func TestChannelList_ExcludesDMChannels_Admin(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "dm-excl-admin", 1) // Owner - database.CreateChannel("general", "text", "", "", 0) - database.CreateChannel("voice", "voice", "", "", 1) - database.Exec(`INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) + database.CreateChannel(context.Background(), "general", "text", "", "", 0) + database.CreateChannel(context.Background(), "voice", "voice", "", "", 1) + database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index 978790e6..7ab96263 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -131,7 +131,7 @@ func handleGetMessages(svc *service.Services) http.HandlerFunc { limit = v } - msgs, hasMore, err := svc.Messages.GetMessages(user.ID, channelID, before, limit) + msgs, hasMore, err := svc.Messages.GetMessages(r.Context(), user.ID, channelID, before, limit) if err != nil { writeServiceError(w, err) return @@ -191,7 +191,7 @@ func handleSearch(svc *service.Services) http.HandlerFunc { limit = v } - results, err := svc.Messages.SearchMessages(user.ID, q, channelID, limit) + results, err := svc.Messages.SearchMessages(r.Context(), user.ID, q, channelID, limit) if err != nil { if isInvalidSearchQueryError(err) { writeJSON(w, http.StatusBadRequest, errorResponse{ @@ -229,7 +229,7 @@ func handleGetPins(svc *service.Services) http.HandlerFunc { return } - msgs, err := svc.Messages.GetPinnedMessages(user.ID, channelID) + msgs, err := svc.Messages.GetPinnedMessages(r.Context(), user.ID, channelID) if err != nil { writeServiceError(w, err) return @@ -263,7 +263,7 @@ func handleSetPinned(svc *service.Services, pinned bool) http.HandlerFunc { return } - if err := svc.Messages.SetMessagePinned(user.ID, channelID, messageID, pinned); err != nil { + if err := svc.Messages.SetMessagePinned(r.Context(), user.ID, channelID, messageID, pinned); err != nil { writeServiceError(w, err) return } diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index 0c0b7a97..388b921e 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -197,13 +198,13 @@ func buildChannelRouter(database *db.DB) http.Handler { // chTestCreateToken creates a user+session and returns the plaintext token. func chTestCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - _, err := database.CreateUser(username, "$2a$12$fake", roleID) + _, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID) if err != nil { t.Fatalf("CreateUser %q: %v", username, err) } token := "chtest-token-" + username hash := auth.HashToken(token) - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`, hash, username, @@ -257,8 +258,8 @@ func TestChannelList_WithChannels(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "bob", 1) - _, _ = database.CreateChannel("general", "text", "", "", 0) - _, _ = database.CreateChannel("random", "text", "", "", 1) + _, _ = database.CreateChannel(context.Background(), "general", "text", "", "", 0) + _, _ = database.CreateChannel(context.Background(), "random", "text", "", "", 1) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -307,7 +308,7 @@ func TestChannelMessages_EmptyChannel(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "eve", 1) - chID, _ := database.CreateChannel("general", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusOK { @@ -325,11 +326,11 @@ func TestChannelMessages_ReturnsMessages(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "frank", 1) - user, _ := database.GetUserByUsername("frank") - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "frank") + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) for i := range 3 { - _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("msg%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) @@ -348,7 +349,7 @@ func TestChannelMessages_LimitCappedAt100(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "grace", 1) - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) // limit=200 should succeed (capped internally). rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=200", chID), token) @@ -361,11 +362,11 @@ func TestChannelMessages_HasMore(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "henry", 1) - user, _ := database.GetUserByUsername("henry") - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "henry") + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) for i := range 60 { - _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token) @@ -383,11 +384,11 @@ func TestChannelMessages_HasMoreFalse(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "ivan", 1) - user, _ := database.GetUserByUsername("ivan") - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "ivan") + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) for i := range 5 { - _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token) @@ -426,9 +427,9 @@ func TestSearch_ReturnsResults(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "kim", 1) - user, _ := database.GetUserByUsername("kim") - chID, _ := database.CreateChannel("searchable", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "uniqueterm in message", nil) + user, _ := database.GetUserByUsername(context.Background(), "kim") + chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "uniqueterm in message", nil) rr := chGet(t, router, "/api/v1/search?q=uniqueterm", token) if rr.Code != http.StatusOK { @@ -463,9 +464,9 @@ func TestSearch_WithChannelID(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchch", 1) - user, _ := database.GetUserByUsername("searchch") - chID, _ := database.CreateChannel("filtered", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "filtered message here", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchch") + chID, _ := database.CreateChannel(context.Background(), "filtered", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "filtered message here", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=filtered&channel_id=%d", chID), token) if rr.Code != http.StatusOK { @@ -521,9 +522,9 @@ func TestSearch_InvalidFTSQuery(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "badfts", 1) - user, _ := database.GetUserByUsername("badfts") - chID, _ := database.CreateChannel("fts", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "search seed", nil) + user, _ := database.GetUserByUsername(context.Background(), "badfts") + chID, _ := database.CreateChannel(context.Background(), "fts", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "search seed", nil) // FTS5 operator characters are now stripped by sanitizeFTSQuery, so a // bare quote becomes an empty query which returns 200 with no results. @@ -560,22 +561,22 @@ func TestSearch_ChannelTypeLookupFailure_FailsClosed(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchfailclosed", 1) - user, _ := database.GetUserByUsername("searchfailclosed") - chID, _ := database.CreateChannel("searchable", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "closedlookupterm", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchfailclosed") + chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "closedlookupterm", nil) - _, err := database.Exec(`ALTER TABLE channels RENAME TO channels_with_type`) + _, err := database.ExecContext(context.Background(), `ALTER TABLE channels RENAME TO channels_with_type`) if err != nil { t.Fatalf("rename channels: %v", err) } - _, err = database.Exec(`CREATE TABLE channels ( + _, err = database.ExecContext(context.Background(), `CREATE TABLE channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL )`) if err != nil { t.Fatalf("recreate channels without type: %v", err) } - _, err = database.Exec(`INSERT INTO channels (id, name) SELECT id, name FROM channels_with_type`) + _, err = database.ExecContext(context.Background(), `INSERT INTO channels (id, name) SELECT id, name FROM channels_with_type`) if err != nil { t.Fatalf("copy channels: %v", err) } @@ -590,11 +591,11 @@ func TestSearch_ChannelOverrideLookupFailure_ReturnsError(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchoverridefail", 4) - user, _ := database.GetUserByUsername("searchoverridefail") - chID, _ := database.CreateChannel("searchable", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "overridefailterm", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchoverridefail") + chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "overridefailterm", nil) - _, err := database.Exec(`DROP TABLE channel_overrides`) + _, err := database.ExecContext(context.Background(), `DROP TABLE channel_overrides`) if err != nil { t.Fatalf("drop channel_overrides: %v", err) } @@ -642,12 +643,12 @@ func TestChannelMessages_BeforeCursor(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "cursoruser", 1) - user, _ := database.GetUserByUsername("cursoruser") - chID, _ := database.CreateChannel("cursor", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "cursoruser") + chID, _ := database.CreateChannel(context.Background(), "cursor", "text", "", "", 0) var lastID int64 for i := range 5 { - lastID, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil) + lastID, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("msg%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, lastID), token) @@ -660,7 +661,7 @@ func TestChannelMessages_InvalidLimit(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "badlimituser", 1) - chID, _ := database.CreateChannel("lim", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "lim", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=abc", chID), token) if rr.Code != http.StatusBadRequest { @@ -675,7 +676,7 @@ func newPinTestDB(t *testing.T) *db.DB { t.Helper() database := newChannelTestDB(t) // Add DM tables required by pin handlers for DM authorization. - _, err := database.Exec(` + _, err := database.ExecContext(context.Background(), ` CREATE TABLE IF NOT EXISTS dm_participants ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -718,7 +719,7 @@ func TestGetPins_EmptyPins(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinuser2", 1) - chID, _ := database.CreateChannel("general", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token) if rr.Code != http.StatusOK { @@ -742,13 +743,13 @@ func TestGetPins_ReturnsPinnedMessages(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinuser3", 1) - user, _ := database.GetUserByUsername("pinuser3") - chID, _ := database.CreateChannel("general", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "pinuser3") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "pinned message", nil) - _ = database.SetMessagePinned(msgID, true) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "pinned message", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) // Also create an unpinned message — should not appear. - _, _ = database.CreateMessage(chID, user.ID, "not pinned", nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "not pinned", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token) if rr.Code != http.StatusOK { @@ -773,11 +774,11 @@ func TestGetPins_DMChannel_NonParticipantForbidden(t *testing.T) { chTestCreateToken(t, database, "dmuser2", 4) outsiderToken := chTestCreateToken(t, database, "outsider", 4) - user1, _ := database.GetUserByUsername("dmuser1") - user2, _ := database.GetUserByUsername("dmuser2") + user1, _ := database.GetUserByUsername(context.Background(), "dmuser1") + user2, _ := database.GetUserByUsername(context.Background(), "dmuser2") // Create a DM channel manually. - dmCh, _, _ := database.GetOrCreateDMChannel(user1.ID, user2.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), user1.ID, user2.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", dmCh.ID), outsiderToken) if rr.Code != http.StatusNotFound { @@ -791,10 +792,10 @@ func TestGetPins_MemberNoReadPermission(t *testing.T) { // Role 4 = Member with permissions 1635 (0x663). // Deny READ_MESSAGES on a specific channel via override. token := chTestCreateToken(t, database, "nopermuser", 4) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny all permissions for role 4 on this channel. - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2147483647)`, chID, ) @@ -835,9 +836,9 @@ func TestSetPinned_PinSuccessfully(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinner1", 1) - user, _ := database.GetUserByUsername("pinner1") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "pin me", nil) + user, _ := database.GetUserByUsername(context.Background(), "pinner1") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "pin me", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusNoContent { @@ -845,7 +846,7 @@ func TestSetPinned_PinSuccessfully(t *testing.T) { } // Verify the message is actually pinned. - msg, _ := database.GetMessage(msgID) + msg, _ := database.GetMessage(context.Background(), msgID) if !msg.Pinned { t.Error("message should be pinned after POST") } @@ -855,17 +856,17 @@ func TestSetPinned_UnpinSuccessfully(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "unpinner1", 1) - user, _ := database.GetUserByUsername("unpinner1") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "unpin me", nil) - _ = database.SetMessagePinned(msgID, true) + user, _ := database.GetUserByUsername(context.Background(), "unpinner1") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "unpin me", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) rr := chDelete(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusNoContent { t.Errorf("unpin status = %d, want 204; body: %s", rr.Code, rr.Body.String()) } - msg, _ := database.GetMessage(msgID) + msg, _ := database.GetMessage(context.Background(), msgID) if msg.Pinned { t.Error("message should not be pinned after DELETE") } @@ -875,7 +876,7 @@ func TestSetPinned_MessageNotFound(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinner2", 1) - chID, _ := database.CreateChannel("general", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/9999", chID), token) if rr.Code != http.StatusNotFound { @@ -899,9 +900,9 @@ func TestSetPinned_NoPermission(t *testing.T) { router := buildChannelRouter(database) // Member role (4) has permissions 1635 — does not include MANAGE_MESSAGES (0x2000). token := chTestCreateToken(t, database, "noperm", 4) - user, _ := database.GetUserByUsername("noperm") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "try to pin", nil) + user, _ := database.GetUserByUsername(context.Background(), "noperm") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "try to pin", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusForbidden { @@ -913,10 +914,10 @@ func TestSetPinned_Idempotent(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinner4", 1) - user, _ := database.GetUserByUsername("pinner4") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "already pinned", nil) - _ = database.SetMessagePinned(msgID, true) + user, _ := database.GetUserByUsername(context.Background(), "pinner4") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "already pinned", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) // Pinning again should still succeed (idempotent). rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) diff --git a/Server/api/contract_test.go b/Server/api/contract_test.go index 3d2b1da1..ae59f2ee 100644 --- a/Server/api/contract_test.go +++ b/Server/api/contract_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -17,9 +18,9 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "contract-msg1", 1) - user, _ := database.GetUserByUsername("contract-msg1") - chID, _ := database.CreateChannel("contract-ch", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "contract test message", nil) + user, _ := database.GetUserByUsername(context.Background(), "contract-msg1") + chID, _ := database.CreateChannel(context.Background(), "contract-ch", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "contract test message", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusOK { @@ -85,10 +86,10 @@ func TestContract_Messages_ReactionsHaveMeFlag(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "contract-react1", 1) - user, _ := database.GetUserByUsername("contract-react1") - chID, _ := database.CreateChannel("react-ch", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "reaction target", nil) - _ = database.AddReaction(msgID, user.ID, "👍") + user, _ := database.GetUserByUsername(context.Background(), "contract-react1") + chID, _ := database.CreateChannel(context.Background(), "react-ch", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "reaction target", nil) + _ = database.AddReaction(context.Background(), msgID, user.ID, "👍") rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusOK { @@ -133,9 +134,9 @@ func TestContract_Search_HasRequiredFields(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "contract-search1", 1) - user, _ := database.GetUserByUsername("contract-search1") - chID, _ := database.CreateChannel("search-ch", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "contractsearchterm in body", nil) + user, _ := database.GetUserByUsername(context.Background(), "contract-search1") + chID, _ := database.CreateChannel(context.Background(), "search-ch", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "contractsearchterm in body", nil) rr := chGet(t, router, "/api/v1/search?q=contractsearchterm", token) if rr.Code != http.StatusOK { diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index 23aaf4ab..84488d7b 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -485,7 +485,7 @@ func TestCloseDM_BroadcasterUserOffline(t *testing.T) { tokenAlice := dmCreateToken(t, database, "offline_alice", 4) _ = dmCreateToken(t, database, "offline_bob", 4) - bob, _ := database.GetUserByUsername("offline_bob") + bob, _ := database.GetUserByUsername(context.Background(), "offline_bob") // Create a DM. rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -565,7 +565,7 @@ func TestGetMessages_InvalidLimit(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "msglimit", 1) - chID, _ := database.CreateChannel("limit-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "limit-ch", "text", "", "", 0) // Negative limit. rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=-1", chID), token) @@ -592,7 +592,7 @@ func TestGetPins_Success(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinuser", 1) - chID, _ := database.CreateChannel("pin-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "pin-ch", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token) if rr.Code != http.StatusOK { @@ -603,7 +603,7 @@ func TestGetPins_Success(t *testing.T) { func TestGetPins_Unauthorized(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) - chID, _ := database.CreateChannel("pin-unauth-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "pin-unauth-ch", "text", "", "", 0) req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/channels/%d/pins", chID), nil) req.RemoteAddr = "127.0.0.1:9999" @@ -620,7 +620,7 @@ func TestGetPins_Unauthorized(t *testing.T) { func TestSetPinned_Unauthorized(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) - chID, _ := database.CreateChannel("setpin-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "setpin-ch", "text", "", "", 0) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/channels/%d/messages/1/pin", chID), @@ -712,8 +712,8 @@ func TestListSessions_MultipleSessions(t *testing.T) { token := profileCreateToken(t, database, "multisess", 4) // Create additional session. - user, _ := database.GetUserByUsername("multisess") - _, _ = database.CreateSession(user.ID, auth.HashToken("extra-token"), "Chrome", "1.2.3.4") + user, _ := database.GetUserByUsername(context.Background(), "multisess") + _, _ = database.CreateSession(context.Background(), user.ID, auth.HashToken("extra-token"), "Chrome", "1.2.3.4") rr := getWithToken(t, router, "/api/v1/users/me/sessions", token) if rr.Code != http.StatusOK { @@ -784,7 +784,7 @@ func TestSetPinned_MessageNotFound_Push(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinmissmsg", 1) - chID, _ := database.CreateChannel("pinmiss-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "pinmiss-ch", "text", "", "", 0) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, 99999), token) if rr.Code != http.StatusNotFound { @@ -818,7 +818,7 @@ func TestSetPinned_InvalidMessageID(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinbadmsg", 1) - chID, _ := database.CreateChannel("badmsgid-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "badmsgid-ch", "text", "", "", 0) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/abc", chID), token) if rr.Code != http.StatusBadRequest { @@ -830,10 +830,10 @@ func TestUnpin_Success(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "unpinner", 1) - user, _ := database.GetUserByUsername("unpinner") - chID, _ := database.CreateChannel("unpin-ch", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "to unpin", nil) - _ = database.SetMessagePinned(msgID, true) + user, _ := database.GetUserByUsername(context.Background(), "unpinner") + chID, _ := database.CreateChannel(context.Background(), "unpin-ch", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "to unpin", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) rr := chDelete(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusNoContent { @@ -845,9 +845,9 @@ func TestSetPinned_MemberForbidden(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinmember", 4) - user, _ := database.GetUserByUsername("pinmember") - chID, _ := database.CreateChannel("pinforbid-ch", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "cant pin", nil) + user, _ := database.GetUserByUsername(context.Background(), "pinmember") + chID, _ := database.CreateChannel(context.Background(), "pinforbid-ch", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "cant pin", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusForbidden { @@ -859,10 +859,10 @@ func TestSetPinned_WrongChannel(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinwrongch", 1) - user, _ := database.GetUserByUsername("pinwrongch") - chID1, _ := database.CreateChannel("pin-ch1", "text", "", "", 0) - chID2, _ := database.CreateChannel("pin-ch2", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID1, user.ID, "wrong channel", nil) + user, _ := database.GetUserByUsername(context.Background(), "pinwrongch") + chID1, _ := database.CreateChannel(context.Background(), "pin-ch1", "text", "", "", 0) + chID2, _ := database.CreateChannel(context.Background(), "pin-ch2", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID1, user.ID, "wrong channel", nil) // Try to pin a message from chID1 using chID2. rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID2, msgID), token) @@ -878,11 +878,11 @@ func TestSetPinned_DMChannel_ParticipantSuccess(t *testing.T) { router := buildChannelRouter(database) tokenAlice := chTestCreateToken(t, database, "dmpin_alice", 4) _ = chTestCreateToken(t, database, "dmpin_bob", 4) - alice, _ := database.GetUserByUsername("dmpin_alice") - bob, _ := database.GetUserByUsername("dmpin_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmpin_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmpin_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) - msgID, _ := database.CreateMessage(dmCh.ID, alice.ID, "pin this dm msg", nil) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) + msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "pin this dm msg", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenAlice) if rr.Code != http.StatusNoContent { @@ -896,11 +896,11 @@ func TestSetPinned_DMChannel_NonParticipantForbidden(t *testing.T) { _ = chTestCreateToken(t, database, "dmpinforbid_alice", 4) _ = chTestCreateToken(t, database, "dmpinforbid_bob", 4) tokenCharlie := chTestCreateToken(t, database, "dmpinforbid_charlie", 4) - alice, _ := database.GetUserByUsername("dmpinforbid_alice") - bob, _ := database.GetUserByUsername("dmpinforbid_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) - msgID, _ := database.CreateMessage(dmCh.ID, alice.ID, "secret msg", nil) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) + msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "secret msg", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenCharlie) if rr.Code != http.StatusNotFound { @@ -914,9 +914,9 @@ func TestSearch_WithChannelID_Push(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchch", 1) - user, _ := database.GetUserByUsername("searchch") - chID, _ := database.CreateChannel("search-ch1", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "findable in channel", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchch") + chID, _ := database.CreateChannel(context.Background(), "search-ch1", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "findable in channel", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=findable&channel_id=%d", chID), token) if rr.Code != http.StatusOK { @@ -1028,10 +1028,10 @@ func TestGetMessages_WithBeforeParam(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "msgbefore", 1) - user, _ := database.GetUserByUsername("msgbefore") - chID, _ := database.CreateChannel("before-ch", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "msg one", nil) - msgID2, _ := database.CreateMessage(chID, user.ID, "msg two", nil) + user, _ := database.GetUserByUsername(context.Background(), "msgbefore") + chID, _ := database.CreateChannel(context.Background(), "before-ch", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "msg one", nil) + msgID2, _ := database.CreateMessage(context.Background(), chID, user.ID, "msg two", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, msgID2), token) if rr.Code != http.StatusOK { @@ -1043,7 +1043,7 @@ func TestGetMessages_WithCustomLimit(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "msglimitcust", 1) - chID, _ := database.CreateChannel("limitch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "limitch", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=5", chID), token) if rr.Code != http.StatusOK { @@ -1057,7 +1057,7 @@ func TestListChannels_MemberRole(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "memberchanlist", 4) - _, _ = database.CreateChannel("visible-ch", "text", "", "", 0) + _, _ = database.CreateChannel(context.Background(), "visible-ch", "text", "", "", 0) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -1069,7 +1069,7 @@ func TestListChannels_AdminSeesAll(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "adminchanlist", 2) - _, _ = database.CreateChannel("admin-visible-ch", "text", "", "", 0) + _, _ = database.CreateChannel(context.Background(), "admin-visible-ch", "text", "", "", 0) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -1091,10 +1091,10 @@ func TestGetMessages_DMChannel_NonParticipant(t *testing.T) { _ = chTestCreateToken(t, database, "dmmsg_alice", 4) _ = chTestCreateToken(t, database, "dmmsg_bob", 4) tokenCharlie := chTestCreateToken(t, database, "dmmsg_charlie", 4) - alice, _ := database.GetUserByUsername("dmmsg_alice") - bob, _ := database.GetUserByUsername("dmmsg_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmmsg_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmmsg_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenCharlie) if rr.Code != http.StatusNotFound { @@ -1107,10 +1107,10 @@ func TestGetMessages_DMChannel_ParticipantSuccess(t *testing.T) { router := buildChannelRouter(database) tokenAlice := chTestCreateToken(t, database, "dmmsgok_alice", 4) _ = chTestCreateToken(t, database, "dmmsgok_bob", 4) - alice, _ := database.GetUserByUsername("dmmsgok_alice") - bob, _ := database.GetUserByUsername("dmmsgok_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmmsgok_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmmsgok_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenAlice) if rr.Code != http.StatusOK { @@ -1126,10 +1126,10 @@ func TestSearch_DMChannelFilter_NonParticipant(t *testing.T) { _ = chTestCreateToken(t, database, "dmsearch_alice", 4) _ = chTestCreateToken(t, database, "dmsearch_bob", 4) tokenCharlie := chTestCreateToken(t, database, "dmsearch_charlie", 4) - alice, _ := database.GetUserByUsername("dmsearch_alice") - bob, _ := database.GetUserByUsername("dmsearch_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmsearch_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmsearch_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=test&channel_id=%d", dmCh.ID), tokenCharlie) if rr.Code != http.StatusForbidden { diff --git a/Server/api/diagnostics_handler_test.go b/Server/api/diagnostics_handler_test.go index d7f73b68..9d8270be 100644 --- a/Server/api/diagnostics_handler_test.go +++ b/Server/api/diagnostics_handler_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -38,10 +39,10 @@ func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) { t.Cleanup(cleanup) // Create a user and session for authenticated requests. - uid, _ := database.CreateUser("diaguser", "$2a$12$fake", 1) + uid, _ := database.CreateUser(context.Background(), "diaguser", "$2a$12$fake", 1) token := "diagtest-token-123" hash := auth.HashToken(token) - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, uid, hash, @@ -102,12 +103,12 @@ func TestDiagnosticsConnectivity_Unauthenticated(t *testing.T) { func TestDiagnosticsConnectivity_MemberForbidden(t *testing.T) { router, _, database := setupDiagnosticsRouter(t) - uid, err := database.CreateUser("diagmember", "$2a$12$fake", int(permissions.MemberRoleID)) + uid, err := database.CreateUser(context.Background(), "diagmember", "$2a$12$fake", int(permissions.MemberRoleID)) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "diagtest-member-token" - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, uid, auth.HashToken(token), diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index 1b595469..b61720c3 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -113,7 +113,7 @@ func handleListDMs(svc *service.Services) http.HandlerFunc { return } - channels, err := svc.DMs.ListDMs(user.ID) + channels, err := svc.DMs.ListDMs(r.Context(), user.ID) if err != nil { writeServiceError(w, err) return @@ -138,7 +138,7 @@ func handleCloseDM(svc *service.Services, broadcaster DMBroadcaster) http.Handle return } - if err := svc.DMs.CloseDM(user.ID, channelID); err != nil { + if err := svc.DMs.CloseDM(r.Context(), user.ID, channelID); err != nil { writeServiceError(w, err) return } @@ -191,7 +191,7 @@ func handleUnblockUser(svc *service.Services) http.HandlerFunc { return } - if err := svc.Blocks.UnblockUser(user.ID, targetID); err != nil { + if err := svc.Blocks.UnblockUser(r.Context(), user.ID, targetID); err != nil { writeServiceError(w, err) return } @@ -208,7 +208,7 @@ func handleListBlocks(svc *service.Services) http.HandlerFunc { return } - ids, err := svc.Blocks.ListBlocked(user.ID) + ids, err := svc.Blocks.ListBlocked(r.Context(), user.ID) if err != nil { writeServiceError(w, err) return diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go index 8610f5ee..a1a8c443 100644 --- a/Server/api/dm_handler_test.go +++ b/Server/api/dm_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -154,13 +155,13 @@ func (m *mockBroadcaster) SendToUser(userID int64, msg []byte) bool { // dmCreateToken creates a user+session and returns the plaintext token. func dmCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - _, err := database.CreateUser(username, "$2a$12$fake", roleID) + _, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID) if err != nil { t.Fatalf("CreateUser %q: %v", username, err) } token := "dmtest-token-" + username hash := auth.HashToken(token) - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`, hash, username, @@ -225,7 +226,7 @@ func TestCreateDM_Success_NewDM(t *testing.T) { tokenAlice := dmCreateToken(t, database, "alice", 4) _ = dmCreateToken(t, database, "bob", 4) - bob, _ := database.GetUserByUsername("bob") + bob, _ := database.GetUserByUsername(context.Background(), "bob") rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ "recipient_id": bob.ID, @@ -256,7 +257,7 @@ func TestCreateDM_Success_ExistingDM(t *testing.T) { tokenAlice := dmCreateToken(t, database, "alice2", 4) _ = dmCreateToken(t, database, "bob2", 4) - bob, _ := database.GetUserByUsername("bob2") + bob, _ := database.GetUserByUsername(context.Background(), "bob2") // First call creates the DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -328,7 +329,7 @@ func TestCreateDM_BadRequest_SelfDM(t *testing.T) { database := newDMTestDB(t) router := buildDMRouter(database, nil) token := dmCreateToken(t, database, "selfuser", 4) - self, _ := database.GetUserByUsername("selfuser") + self, _ := database.GetUserByUsername(context.Background(), "selfuser") rr := dmPost(t, router, "/api/v1/dms", token, map[string]any{ "recipient_id": self.ID, @@ -372,7 +373,7 @@ func TestListDMs_ReturnsOpenDMs(t *testing.T) { tokenAlice := dmCreateToken(t, database, "list_alice", 4) _ = dmCreateToken(t, database, "list_bob", 4) - bob, _ := database.GetUserByUsername("list_bob") + bob, _ := database.GetUserByUsername(context.Background(), "list_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -436,7 +437,7 @@ func TestCloseDM_Success(t *testing.T) { tokenAlice := dmCreateToken(t, database, "close_alice", 4) _ = dmCreateToken(t, database, "close_bob", 4) - bob, _ := database.GetUserByUsername("close_bob") + bob, _ := database.GetUserByUsername(context.Background(), "close_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -468,7 +469,7 @@ func TestCloseDM_Success_VerifyRemovedFromList(t *testing.T) { tokenAlice := dmCreateToken(t, database, "closelist_alice", 4) _ = dmCreateToken(t, database, "closelist_bob", 4) - bob, _ := database.GetUserByUsername("closelist_bob") + bob, _ := database.GetUserByUsername(context.Background(), "closelist_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -502,7 +503,7 @@ func TestCloseDM_Forbidden_NotParticipant(t *testing.T) { tokenAlice := dmCreateToken(t, database, "forbid_alice", 4) _ = dmCreateToken(t, database, "forbid_bob", 4) tokenCharlie := dmCreateToken(t, database, "forbid_charlie", 4) - bob, _ := database.GetUserByUsername("forbid_bob") + bob, _ := database.GetUserByUsername(context.Background(), "forbid_bob") // Alice creates DM with Bob. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -549,7 +550,7 @@ func TestCloseDM_NilBroadcaster(t *testing.T) { token := dmCreateToken(t, database, "nilbc_alice", 4) _ = dmCreateToken(t, database, "nilbc_bob", 4) - bob, _ := database.GetUserByUsername("nilbc_bob") + bob, _ := database.GetUserByUsername(context.Background(), "nilbc_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", token, map[string]any{ diff --git a/Server/api/invite_handler.go b/Server/api/invite_handler.go index 50a12914..07689f05 100644 --- a/Server/api/invite_handler.go +++ b/Server/api/invite_handler.go @@ -85,7 +85,7 @@ func handleCreateInvite(svc *service.Services) http.HandlerFunc { // handleListInvites processes GET /api/v1/invites. func handleListInvites(svc *service.Services) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - invites, err := svc.Invites.ListInvites() + invites, err := svc.Invites.ListInvites(r.Context()) if err != nil { writeServiceError(w, err) return @@ -103,7 +103,7 @@ func handleListInvites(svc *service.Services) http.HandlerFunc { func handleRevokeInvite(svc *service.Services) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { code := chi.URLParam(r, "code") - if err := svc.Invites.RevokeInvite(code); err != nil { + if err := svc.Invites.RevokeInvite(r.Context(), code); err != nil { writeServiceError(w, err) return } diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go index 8cea1344..59cd2588 100644 --- a/Server/api/invite_handler_test.go +++ b/Server/api/invite_handler_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -27,9 +28,9 @@ func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler func loginAndGetToken(t *testing.T, _ http.Handler, database *db.DB, username string, roleID int) string { t.Helper() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser(username, hash, roleID) + uid, _ := database.CreateUser(context.Background(), username, hash, roleID) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") return token } @@ -104,11 +105,11 @@ func TestCreateInvite_ChannelAllowOverrideDoesNotGrant(t *testing.T) { token := loginAndGetToken(t, router, database, "overrideuser", 4) - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`); err != nil { t.Fatalf("insert channel: %v", err) } - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, ?, 0)`, permissions.ManageInvites, ); err != nil { @@ -176,7 +177,7 @@ func TestCreateInvite_CreateInviteFailure(t *testing.T) { router := buildInviteRouter(database, limiter) token := loginAndGetToken(t, router, database, "invitecreatefail", 2) - if _, err := database.Exec(`DROP TABLE invites`); err != nil { + if _, err := database.ExecContext(context.Background(), `DROP TABLE invites`); err != nil { t.Fatalf("drop invites table: %v", err) } @@ -200,7 +201,7 @@ func TestCreateInvite_GetInviteFailure(t *testing.T) { router := buildInviteRouter(database, limiter) token := loginAndGetToken(t, router, database, "invitegetfail", 2) - if _, err := database.Exec(` + if _, err := database.ExecContext(context.Background(), ` CREATE TRIGGER delete_invite_after_insert AFTER INSERT ON invites BEGIN @@ -222,7 +223,7 @@ func TestCreateInvite_GetInviteFailure(t *testing.T) { if resp["message"] != "an internal error occurred" { t.Errorf("message = %v, want an internal error occurred", resp["message"]) } - if _, err := database.Exec(`DROP TRIGGER delete_invite_after_insert`); err != nil { + if _, err := database.ExecContext(context.Background(), `DROP TRIGGER delete_invite_after_insert`); err != nil { t.Fatalf("drop trigger: %v", err) } } @@ -331,7 +332,7 @@ func TestRevokeInvite_Success(t *testing.T) { } // Verify invite is revoked. - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv == nil || !inv.Revoked { t.Error("Invite not revoked in database after DELETE") } @@ -397,7 +398,7 @@ func TestRevokeInvite_RevokeFailure(t *testing.T) { } code := created["code"].(string) - if _, err := database.Exec(` + if _, err := database.ExecContext(context.Background(), ` CREATE TRIGGER block_revoke_invite BEFORE UPDATE OF revoked ON invites BEGIN diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 38d1fbc6..7b92f023 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -42,7 +42,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } hash := auth.HashToken(token) - sess, err := database.GetSessionByTokenHash(hash) + sess, err := database.GetSessionByTokenHash(r.Context(), hash) if err != nil || sess == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -54,8 +54,11 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { // Check expiry. if auth.IsSessionExpired(sess.ExpiresAt) { // Clean up expired session in background to prevent accumulation. + // The request ctx is cancelled as soon as the 401 below is + // written, so detach cancellation: the deletion must complete. + cleanupCtx := context.WithoutCancel(r.Context()) go func(h string) { - _ = database.DeleteSession(h) + _ = database.DeleteSession(cleanupCtx, h) }(hash) writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -65,7 +68,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } // Load user. - user, err := database.GetUserByID(sess.UserID) + user, err := database.GetUserByID(r.Context(), sess.UserID) if err != nil || user == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -87,7 +90,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { // A dangling role_id returns (nil, nil) from GetRoleByID, so the nil // check is load-bearing: without it a nil role reaches the context // and every downstream permission check has to re-guard it. - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(r.Context(), user.RoleID) if err != nil || role == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -97,7 +100,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } // Touch session in background — non-fatal if it fails. - if err := database.TouchSession(hash); err != nil { + if err := database.TouchSession(r.Context(), hash); err != nil { slog.Warn("failed to touch session", "error", err, "user_id", user.ID) } diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 68ea2c56..7f2dfc3c 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -52,10 +52,10 @@ func withBearer(req *http.Request, token string) *http.Request { func TestAuthMiddleware_ValidToken(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("alice", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "alice", "hash", 4) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -100,13 +100,13 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) { func TestAuthMiddleware_ExpiredSession(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("bob", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "bob", "hash", 4) token, _ := auth.GenerateToken() hash := auth.HashToken(token) // Insert an already-expired session. pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, uid, hash, "test", "127.0.0.1", pastTime, ) @@ -155,21 +155,21 @@ func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) { // users.role_id has a FK to roles(id), so the dangling row can only be // created with FK enforcement momentarily off (db.Open pins the pool to a // single connection, so the pragma applies to the inserts that follow). - if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil { t.Fatalf("disable foreign keys: %v", err) } - res, err := database.Exec( + res, err := database.ExecContext(context.Background(), `INSERT INTO users (username, password, role_id) VALUES ('dangling', '$2a$12$fake', 999)`) if err != nil { t.Fatalf("insert dangling user: %v", err) } uid, _ := res.LastInsertId() - if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil { t.Fatalf("re-enable foreign keys: %v", err) } token, _ := auth.GenerateToken() - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, uid, auth.HashToken(token), @@ -193,10 +193,10 @@ func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) { func TestRequirePermission_Allowed(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("carol", "hash", 4) // Member role = 0x663 + uid, _ := database.CreateUser(context.Background(), "carol", "hash", 4) // Member role = 0x663 token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.SendMessages)(http.HandlerFunc(ok)), @@ -214,10 +214,10 @@ func TestRequirePermission_Allowed(t *testing.T) { func TestRequirePermission_Forbidden(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("dave", "hash", 4) // Member role = 0x663 + uid, _ := database.CreateUser(context.Background(), "dave", "hash", 4) // Member role = 0x663 token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)), @@ -236,10 +236,10 @@ func TestRequirePermission_Forbidden(t *testing.T) { func TestRequirePermission_Administrator_Bypass(t *testing.T) { database := newAPITestDB(t) // Owner role (id=1) has permissions 0x7FFFFFFF which includes ADMINISTRATOR (0x40000000) - uid, _ := database.CreateUser("owner", "hash", 1) + uid, _ := database.CreateUser(context.Background(), "owner", "hash", 1) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") // Any permission should pass for ADMINISTRATOR h := api.AuthMiddleware(database)( @@ -262,10 +262,10 @@ func TestRequirePermission_Administrator_Bypass(t *testing.T) { // Member holds SendMessages, which was enough to make the mask non-zero. func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("multibit", "hash", 4) // Member role = 1635, has SendMessages, not ManageRoles + uid, _ := database.CreateUser(context.Background(), "multibit", "hash", 4) // Member role = 1635, has SendMessages, not ManageRoles token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.SendMessages | permissions.ManageRoles)(http.HandlerFunc(ok)), @@ -411,11 +411,11 @@ func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) { // with no expiry cannot pass the auth middleware. func TestAuthMiddleware_BannedUserBlocked(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("banneduser", "hash", 4) - _ = database.BanUser(uid, "rule violation", nil) // permanent ban + uid, _ := database.CreateUser(context.Background(), "banneduser", "hash", 4) + _ = database.BanUser(context.Background(), uid, "rule violation", nil) // permanent ban token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -433,15 +433,15 @@ func TestAuthMiddleware_BannedUserBlocked(t *testing.T) { // expired in the past can pass the auth middleware. func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("expbanned", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "expbanned", "hash", 4) // Set ban with an expiry time in the past. past := time.Now().UTC().Add(-time.Hour) - _ = database.BanUser(uid, "temp ban", &past) + _ = database.BanUser(context.Background(), uid, "temp ban", &past) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -459,15 +459,15 @@ func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) { // temporary ban whose expiry is in the future is still blocked. func TestAuthMiddleware_ActiveTemporaryBanBlocked(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("tempbanned", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "tempbanned", "hash", 4) // Set ban with an expiry time in the future. future := time.Now().UTC().Add(time.Hour) - _ = database.BanUser(uid, "temp ban", &future) + _ = database.BanUser(context.Background(), uid, "temp ban", &future) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 4a26eec2..1d4ef805 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -187,14 +187,14 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if !auth.CheckPassword(user.PasswordHash, req.OldPassword) { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "incorrect password", }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) // Reject same old/new password. if req.OldPassword == req.NewPassword { @@ -228,7 +228,7 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http keepSessionID = sess.ID } - res, err := svc.Users.ChangePassword(user.ID, hash, keepSessionID) + res, err := svc.Users.ChangePassword(r.Context(), user.ID, hash, keepSessionID) if err != nil { // Only reachable when the password itself failed to commit. writeServiceError(w, err) @@ -268,7 +268,7 @@ func handleListSessions(svc *service.Services) http.HandlerFunc { return } - sessions, err := svc.Users.ListSessions(user.ID) + sessions, err := svc.Users.ListSessions(r.Context(), user.ID) if err != nil { writeServiceError(w, err) return @@ -308,7 +308,7 @@ func handleRevokeSession(svc *service.Services) http.HandlerFunc { return } - if err := svc.Users.RevokeSession(user.ID, sessionID); err != nil { + if err := svc.Users.RevokeSession(r.Context(), user.ID, sessionID); err != nil { writeServiceError(w, err) return } diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index aeaebb00..f41642c4 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -28,7 +29,7 @@ func buildProfileRouter(database *db.DB) http.Handler { // profileCreateToken creates a user and session, returning the raw token. func profileCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - uid, err := database.CreateUser(username, mustHash(t), roleID) + uid, err := database.CreateUser(context.Background(), username, mustHash(t), roleID) if err != nil { t.Fatalf("CreateUser(%s): %v", username, err) } @@ -37,7 +38,7 @@ func profileCreateToken(t *testing.T, database *db.DB, username string, roleID i t.Fatalf("GenerateToken: %v", err) } expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z") - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), "INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)", uid, auth.HashToken(token), "TestAgent", "127.0.0.1", expiresAt, ) @@ -183,12 +184,12 @@ func TestChangePassword_RevokesOtherSessions(t *testing.T) { // Create user with two sessions. token1 := profileCreateToken(t, database, "pw-revoke", 4) - user, _ := database.GetUserByUsername("pw-revoke") + user, _ := database.GetUserByUsername(context.Background(), "pw-revoke") // Create a second session for the same user. token2, _ := auth.GenerateToken() expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z") - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), "INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)", user.ID, auth.HashToken(token2), "OtherDevice", "10.0.0.1", expiresAt, ) @@ -203,13 +204,13 @@ func TestChangePassword_RevokesOtherSessions(t *testing.T) { } // token1 (current session) should still work. - sess1, _ := database.GetSessionByTokenHash(auth.HashToken(token1)) + sess1, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token1)) if sess1 == nil { t.Error("current session should survive password change") } // token2 (other session) should be revoked. - sess2, _ := database.GetSessionByTokenHash(auth.HashToken(token2)) + sess2, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token2)) if sess2 != nil { t.Error("other session should be revoked after password change") } @@ -314,8 +315,8 @@ func TestRevokeSession_Success(t *testing.T) { token := profileCreateToken(t, database, "revoke", 4) // Create a second session to revoke. - user, _ := database.GetUserByUsername("revoke") - secondSessID, _ := database.CreateSession(user.ID, auth.HashToken("second-tok"), "Firefox", "1.2.3.4") + user, _ := database.GetUserByUsername(context.Background(), "revoke") + secondSessID, _ := database.CreateSession(context.Background(), user.ID, auth.HashToken("second-tok"), "Firefox", "1.2.3.4") rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", secondSessID), token) @@ -342,8 +343,8 @@ func TestRevokeSession_OtherUsersSession(t *testing.T) { token := profileCreateToken(t, database, "revokeother", 4) // Create another user with a session. - otherUID, _ := database.CreateUser("victim", mustHash(t), 4) - otherSessID, _ := database.CreateSession(otherUID, auth.HashToken("victim-tok"), "Safari", "9.8.7.6") + otherUID, _ := database.CreateUser(context.Background(), "victim", mustHash(t), 4) + otherSessID, _ := database.CreateSession(context.Background(), otherUID, auth.HashToken("victim-tok"), "Safari", "9.8.7.6") rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", otherSessID), token) @@ -358,8 +359,8 @@ func TestRevokeSession_CurrentSession(t *testing.T) { token := profileCreateToken(t, database, "revokeself", 4) // Find the current session ID. - user, _ := database.GetUserByUsername("revokeself") - sessions, _ := database.ListUserSessions(user.ID) + user, _ := database.GetUserByUsername(context.Background(), "revokeself") + sessions, _ := database.ListUserSessions(context.Background(), user.ID) if len(sessions) == 0 { t.Fatal("expected at least 1 session") } diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index f108b11d..e15fd413 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "fmt" @@ -81,7 +82,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - user, err := database.GetUserByID(challenge.UserID) + user, err := database.GetUserByID(r.Context(), challenge.UserID) if err != nil || user == nil || user.TOTPSecret == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -111,7 +112,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - limiter.Reset(totpRateLimitKey) + limiter.Reset(r.Context(), totpRateLimitKey) if _, ok := partialStore.Consume(partialToken); !ok { writeJSON(w, http.StatusUnauthorized, errorResponse{ @@ -121,7 +122,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - token, err := issueSession(database, user.ID, challenge.Device, challenge.IP) + token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -131,7 +132,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } slog.Info("totp verified", "user_id", user.ID, "ip", challenge.IP) - db.WriteAudit(database, user.ID, "totp_verified", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_verified", "user", user.ID, "two-factor verification completed from "+challenge.IP) writeJSON(w, http.StatusOK, authSuccessResponse{ @@ -182,7 +183,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -190,7 +191,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) secret, err := auth.GenerateTOTPSecret() if err != nil { @@ -241,7 +242,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -249,7 +250,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) secret, ok := pendingStore.Lookup(user.ID) if !ok { @@ -278,7 +279,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use return } - if err := database.UpdateUserTOTPSecret(user.ID, &encryptedSecret); err != nil { + if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, &encryptedSecret); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to enable two-factor authentication", @@ -289,14 +290,16 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use // BUG-108: Revoke all other sessions after 2FA state change. if sess, ok := r.Context().Value(SessionKey).(*db.Session); ok && sess != nil { - n, _ := database.DeleteOtherSessions(user.ID, sess.ID) + // Security tail of the 2FA change: once the secret update committed, + // revoking the other sessions must not be aborted by a dead request. + n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, sess.ID) if n > 0 { slog.Info("revoked other sessions after totp enable", "user_id", user.ID, "revoked", n) } } slog.Info("totp enabled", "user_id", user.ID) - db.WriteAudit(database, user.ID, "totp_enabled", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_enabled", "user", user.ID, "two-factor authentication enrolled") w.WriteHeader(http.StatusNoContent) @@ -335,7 +338,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -343,9 +346,9 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) - require2FA, err := isRequire2FAEnabled(database) + require2FA, err := isRequire2FAEnabled(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -362,7 +365,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim } pendingStore.Delete(user.ID) - if err := database.UpdateUserTOTPSecret(user.ID, nil); err != nil { + if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, nil); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to disable two-factor authentication", @@ -372,14 +375,16 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim // BUG-108: Revoke all other sessions after 2FA state change. if sess, ok := r.Context().Value(SessionKey).(*db.Session); ok && sess != nil { - n, _ := database.DeleteOtherSessions(user.ID, sess.ID) + // Security tail of the 2FA change: once the secret update committed, + // revoking the other sessions must not be aborted by a dead request. + n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, sess.ID) if n > 0 { slog.Info("revoked other sessions after totp disable", "user_id", user.ID, "revoked", n) } } slog.Info("totp disabled", "user_id", user.ID) - db.WriteAudit(database, user.ID, "totp_disabled", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_disabled", "user", user.ID, "two-factor authentication disabled") w.WriteHeader(http.StatusNoContent) diff --git a/Server/api/totp_handler_test.go b/Server/api/totp_handler_test.go index 2723c8b2..84cb64bc 100644 --- a/Server/api/totp_handler_test.go +++ b/Server/api/totp_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -22,8 +23,8 @@ func TestVerifyTOTP_Success(t *testing.T) { // Create user with TOTP enabled. secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) // Login should return requires_2fa + partial_token. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -70,8 +71,8 @@ func TestVerifyTOTP_InvalidCode(t *testing.T) { secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser2", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser2", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) // Login to get partial token. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -122,8 +123,8 @@ func TestVerifyTOTP_MalformedBody(t *testing.T) { // Need a valid partial token to get past the token check. secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser3", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser3", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "totpuser3", @@ -154,8 +155,8 @@ func TestVerifyTOTP_ReplayProtection(t *testing.T) { secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser4", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser4", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC()) @@ -271,7 +272,7 @@ func TestConfirmTOTP_Success(t *testing.T) { } // Verify TOTP is now stored on user. - user, _ := database.GetUserByUsername("confirmuser") + user, _ := database.GetUserByUsername(context.Background(), "confirmuser") if user == nil { t.Fatal("user not found after confirm") } @@ -392,7 +393,7 @@ func TestDisableTOTP_BlockedByServerPolicy(t *testing.T) { token := loginAndGetToken(t, router, database, "disableuser3", 4) // Enable require_2fa server policy. - _, _ = database.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES ('require_2fa', '1')`) + _, _ = database.ExecContext(context.Background(), `INSERT OR REPLACE INTO settings (key, value) VALUES ('require_2fa', '1')`) rr := deleteWithToken(t, router, "/api/v1/users/me/totp", token, map[string]string{"password": "Password1!"}) diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 016106c1..132c31a7 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -186,7 +186,7 @@ func handleUpload(database *db.DB, store *storage.Storage, limiter *auth.RateLim // Insert attachment record in DB (unlinked — message_id is NULL). user, _ = r.Context().Value(UserKey).(*db.User) safeFilename := sanitizeUploadFilename(header.Filename) - if err := database.CreateAttachment(fileID, user.ID, safeFilename, fileID, mime, writtenBytes, width, height); err != nil { + if err := database.CreateAttachment(r.Context(), fileID, user.ID, safeFilename, fileID, mime, writtenBytes, width, height); err != nil { // Clean up stored file on DB failure. _ = store.Delete(fileID) slog.Error("failed to create attachment record", "error", err) @@ -223,7 +223,7 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s role, _ := r.Context().Value(RoleKey).(*db.Role) // Look up attachment metadata with channel context. - aa, err := database.GetAttachmentWithChannel(fileID) + aa, err := database.GetAttachmentWithChannel(r.Context(), fileID) if err != nil { slog.Error("failed to look up attachment", "id", fileID, "error", err) writeJSON(w, http.StatusInternalServerError, errorResponse{ @@ -269,7 +269,7 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s }) return } - ok, dmErr := database.IsDMParticipant(user.ID, *aa.ChannelID) + ok, dmErr := database.IsDMParticipant(r.Context(), user.ID, *aa.ChannelID) if dmErr != nil || !ok { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", @@ -277,7 +277,7 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s }) return } - } else if user == nil || !permSvc.HasChannelPerm(user.ID, *aa.ChannelID, permissions.ReadMessages) { + } else if user == nil || !permSvc.HasChannelPerm(r.Context(), user.ID, *aa.ChannelID, permissions.ReadMessages) { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "you do not have access to this file", diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 20251174..39bc6131 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "image" @@ -177,13 +178,13 @@ func buildUploadRouterWithLimiter(database *db.DB, store *storage.Storage, limit // uploadCreateToken creates a user+session and returns the plaintext token. func uploadCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - _, err := database.CreateUser(username, "$2a$12$fake", roleID) + _, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID) if err != nil { t.Fatalf("CreateUser %q: %v", username, err) } token := "upload-test-token-" + username hash := auth.HashToken(token) - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`, hash, username, @@ -320,7 +321,7 @@ func TestUpload_Success_TextFile(t *testing.T) { } // Verify attachment record was created in DB. - att, err := database.GetAttachmentByID(resp["id"].(string)) + att, err := database.GetAttachmentByID(context.Background(), resp["id"].(string)) if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -557,7 +558,7 @@ func TestUpload_DBCreateAttachmentFailureDeletesStoredFile(t *testing.T) { router := buildUploadRouter(database, store, nil) token := uploadCreateToken(t, database, "dbfailupload", 1) - if _, err := database.Exec(`DROP TABLE attachments`); err != nil { + if _, err := database.ExecContext(context.Background(), `DROP TABLE attachments`); err != nil { t.Fatalf("drop attachments table: %v", err) } @@ -604,7 +605,7 @@ func TestUpload_SanitizesReservedFilenameToUnnamed(t *testing.T) { t.Fatalf("filename = %v, want unnamed", resp["filename"]) } - att, err := database.GetAttachmentByID(resp["id"].(string)) + att, err := database.GetAttachmentByID(context.Background(), resp["id"].(string)) if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -633,7 +634,7 @@ func TestUpload_SuccessfulUploadCreatesDBRecord(t *testing.T) { _ = json.NewDecoder(rr.Body).Decode(&resp) fileID := resp["id"].(string) - att, err := database.GetAttachmentByID(fileID) + att, err := database.GetAttachmentByID(context.Background(), fileID) if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -1159,20 +1160,20 @@ func TestServeFile_LinkedToGuildChannel_MemberWithPerm(t *testing.T) { fileID := resp["id"].(string) // Create a guild channel and link the attachment via a message. - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`) if err != nil { t.Fatalf("insert channel: %v", err) } // Get the uploader's user ID. var userID int64 - if err := database.QueryRow(`SELECT id FROM users WHERE username = 'guildmember'`).Scan(&userID); err != nil { + if err := database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'guildmember'`).Scan(&userID); err != nil { t.Fatalf("get user id: %v", err) } - _, err = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, userID) + _, err = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, userID) if err != nil { t.Fatalf("insert message: %v", err) } - _, err = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _, err = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) if err != nil { t.Fatalf("link attachment: %v", err) } @@ -1202,24 +1203,24 @@ func TestServeFile_LinkedToGuildChannel_MemberWithoutPerm(t *testing.T) { fileID := resp["id"].(string) // Create channel and link. - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'secret', 'text')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'secret', 'text')`) if err != nil { t.Fatalf("insert channel: %v", err) } var uploaderID int64 - if err := database.QueryRow(`SELECT id FROM users WHERE username = 'guilduploader2'`).Scan(&uploaderID); err != nil { + if err := database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'guilduploader2'`).Scan(&uploaderID); err != nil { t.Fatalf("get user id: %v", err) } - _, err = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, uploaderID) + _, err = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, uploaderID) if err != nil { t.Fatalf("insert message: %v", err) } - _, err = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _, err = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) if err != nil { t.Fatalf("link attachment: %v", err) } // Deny ReadMessages (0x0002) for role 4 (Member) on channel 1. - _, err = database.Exec(`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, 0, 2)`) + _, err = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, 0, 2)`) if err != nil { t.Fatalf("insert channel_override: %v", err) } @@ -1249,17 +1250,17 @@ func TestServeFile_LinkedToDM_ParticipantAllowed(t *testing.T) { fileID := resp["id"].(string) // Create DM channel, add participants, link attachment. - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) if err != nil { t.Fatalf("insert channel: %v", err) } var aliceID, bobID int64 - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmalice'`).Scan(&aliceID) - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmbob'`).Scan(&bobID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, aliceID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, bobID) - _, _ = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, aliceID) - _, _ = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmalice'`).Scan(&aliceID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmbob'`).Scan(&bobID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, aliceID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, bobID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, aliceID) + _, _ = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) // DM participant can access. rr2 := doServeFile(t, router, fileID, token1, nil) @@ -1287,17 +1288,17 @@ func TestServeFile_LinkedToDM_NonParticipantForbidden(t *testing.T) { fileID := resp["id"].(string) // Create DM channel with two participants (not the outsider). - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) if err != nil { t.Fatalf("insert channel: %v", err) } var ownerID, partnerID int64 - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmowner'`).Scan(&ownerID) - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmpartner'`).Scan(&partnerID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, ownerID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, partnerID) - _, _ = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, ownerID) - _, _ = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmowner'`).Scan(&ownerID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmpartner'`).Scan(&partnerID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, ownerID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, partnerID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, ownerID) + _, _ = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) // Non-participant gets 403. rr2 := doServeFile(t, router, fileID, outsiderToken, nil) diff --git a/Server/auth/ratelimit.go b/Server/auth/ratelimit.go index 4a838b0a..bfa7d11b 100644 --- a/Server/auth/ratelimit.go +++ b/Server/auth/ratelimit.go @@ -1,6 +1,7 @@ package auth import ( + "context" "time" "github.com/owncord/server/syncutil" @@ -20,11 +21,11 @@ type lockoutEntry struct { // When provided, lockouts survive server restarts. The interface uses only // stdlib types to avoid circular dependencies between packages. type LockoutPersister interface { - UpsertLockout(key string, expiresAt time.Time) error - DeleteLockout(key string) error - CleanupExpiredLockouts() error + UpsertLockout(ctx context.Context, key string, expiresAt time.Time) error + DeleteLockout(ctx context.Context, key string) error + CleanupExpiredLockouts(ctx context.Context) error // LoadActiveLockouts returns (keys, expiresAt) slices of equal length. - LoadActiveLockouts() (keys []string, expiresAt []time.Time, err error) + LoadActiveLockouts(ctx context.Context) (keys []string, expiresAt []time.Time, err error) } // RateLimiter is an in-memory, thread-safe sliding-window rate limiter with @@ -58,8 +59,9 @@ func NewPersistentRateLimiter(store LockoutPersister) *RateLimiter { lockouts: make(map[string]*lockoutEntry), store: store, } - // Load surviving lockouts from the store. - if keys, expiresAt, err := store.LoadActiveLockouts(); err == nil { + // Load surviving lockouts from the store. Constructor runs at startup + // with no request in flight, so background context. + if keys, expiresAt, err := store.LoadActiveLockouts(context.Background()); err == nil { for i, key := range keys { rl.lockouts[key] = &lockoutEntry{expiresAt: expiresAt[i]} } @@ -111,14 +113,16 @@ func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool { // Lockout prevents any requests from key for duration regardless of the // sliding-window counter. When a LockoutStore is configured, the lockout -// is persisted so it survives server restarts. -func (r *RateLimiter) Lockout(key string, duration time.Duration) { +// is persisted so it survives server restarts. The persist write must land +// once the lockout is decided, so the caller's cancellation is detached +// (WithoutCancel) rather than aborting the write mid-request. +func (r *RateLimiter) Lockout(ctx context.Context, key string, duration time.Duration) { r.mu.Lock() defer r.mu.Unlock() expiresAt := time.Now().Add(duration) r.lockouts[key] = &lockoutEntry{expiresAt: expiresAt} if r.store != nil { - _ = r.store.UpsertLockout(key, expiresAt) + _ = r.store.UpsertLockout(context.WithoutCancel(ctx), key, expiresAt) } } @@ -170,13 +174,14 @@ func (r *RateLimiter) Check(key string, limit int, window time.Duration) bool { } // Reset clears all rate-limit state (timestamps and lockout) for key. -func (r *RateLimiter) Reset(key string) { +// Like Lockout, the store delete must complete once decided (WithoutCancel). +func (r *RateLimiter) Reset(ctx context.Context, key string) { r.mu.Lock() defer r.mu.Unlock() delete(r.windows, key) delete(r.lockouts, key) if r.store != nil { - _ = r.store.DeleteLockout(key) + _ = r.store.DeleteLockout(context.WithoutCancel(ctx), key) } } @@ -217,7 +222,8 @@ func (r *RateLimiter) Cleanup(maxWindow time.Duration) { } if r.store != nil { - _ = r.store.CleanupExpiredLockouts() + // Runs from the StartCleanup background goroutine — no request ctx. + _ = r.store.CleanupExpiredLockouts(context.Background()) } } diff --git a/Server/auth/ratelimit_cleanup_test.go b/Server/auth/ratelimit_cleanup_test.go index c7af31c6..c5ccba3e 100644 --- a/Server/auth/ratelimit_cleanup_test.go +++ b/Server/auth/ratelimit_cleanup_test.go @@ -1,6 +1,7 @@ package auth_test import ( + "context" "testing" "time" @@ -36,7 +37,7 @@ func TestCleanup_RemovesExpiredWindows(t *testing.T) { func TestCleanup_RemovesExpiredLockouts(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("stale-lockout", 20*time.Millisecond) + rl.Lockout(context.Background(), "stale-lockout", 20*time.Millisecond) time.Sleep(40 * time.Millisecond) rl.Cleanup(15 * time.Minute) @@ -69,7 +70,7 @@ func TestCleanup_PreservesActiveWindows(t *testing.T) { func TestCleanup_PreservesActiveLockouts(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("live-lockout", time.Hour) + rl.Lockout(context.Background(), "live-lockout", time.Hour) rl.Cleanup(15 * time.Minute) @@ -92,14 +93,14 @@ func TestCleanup_MixedEntries(t *testing.T) { // Stale window entry — its timestamp will be older than shortWindow. rl.Allow("stale", 10, shortWindow) // Stale lockout — expires in shortWindow. - rl.Lockout("stale-lock", shortWindow) + rl.Lockout(context.Background(), "stale-lock", shortWindow) // Wait until the stale timestamps fall outside shortWindow. time.Sleep(shortWindow + 10*time.Millisecond) // Active entries added AFTER the sleep — their timestamps are fresh. rl.Allow("active", 10, time.Hour) - rl.Lockout("live-lock", time.Hour) + rl.Lockout(context.Background(), "live-lock", time.Hour) // Cleanup with shortWindow: "stale" was recorded before the cutoff, so it // is evicted. "active" was just recorded, so it is kept. @@ -143,8 +144,8 @@ func TestLen_AfterAllows(t *testing.T) { // active lockout entries. func TestLen_AfterLockouts(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("x", time.Hour) - rl.Lockout("y", time.Hour) + rl.Lockout(context.Background(), "x", time.Hour) + rl.Lockout(context.Background(), "y", time.Hour) _, locks := rl.Len() if locks != 2 { diff --git a/Server/auth/ratelimit_test.go b/Server/auth/ratelimit_test.go index 1e557b40..7cd1f92e 100644 --- a/Server/auth/ratelimit_test.go +++ b/Server/auth/ratelimit_test.go @@ -1,6 +1,7 @@ package auth_test import ( + "context" "testing" "time" @@ -69,7 +70,7 @@ func TestRateLimiter_DifferentKeysIndependent(t *testing.T) { func TestRateLimiter_LockoutEnforced(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("keyLock", time.Hour) + rl.Lockout(context.Background(), "keyLock", time.Hour) if !rl.IsLockedOut("keyLock") { t.Error("IsLockedOut() = false after Lockout(), want true") } @@ -77,7 +78,7 @@ func TestRateLimiter_LockoutEnforced(t *testing.T) { func TestRateLimiter_LockoutExpires(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("keyExp", 30*time.Millisecond) + rl.Lockout(context.Background(), "keyExp", 30*time.Millisecond) time.Sleep(50 * time.Millisecond) if rl.IsLockedOut("keyExp") { t.Error("IsLockedOut() = true after lockout expired, want false") @@ -95,7 +96,7 @@ func TestRateLimiter_Reset(t *testing.T) { rl := auth.NewRateLimiter() rl.Allow("keyR", 1, time.Second) rl.Allow("keyR", 1, time.Second) // now blocked - rl.Reset("keyR") + rl.Reset(context.Background(), "keyR") if !rl.Allow("keyR", 1, time.Second) { t.Error("Allow() = false after Reset(), want true") } @@ -103,7 +104,7 @@ func TestRateLimiter_Reset(t *testing.T) { func TestRateLimiter_LockoutBlocksAllow(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("keyLB", time.Hour) + rl.Lockout(context.Background(), "keyLB", time.Hour) // Even under normal limit, lockout should block if rl.Allow("keyLB", 100, time.Second) { t.Error("Allow() = true for locked-out key, want false") @@ -161,7 +162,7 @@ func TestRateLimiter_Check_AtLimit(t *testing.T) { func TestRateLimiter_Check_RespectsLockout(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("checkLocked", time.Hour) + rl.Lockout(context.Background(), "checkLocked", time.Hour) if rl.Check("checkLocked", 100, time.Second) { t.Error("Check() = true for locked-out key, want false") } @@ -169,7 +170,7 @@ func TestRateLimiter_Check_RespectsLockout(t *testing.T) { func TestRateLimiter_Check_LockoutExpired(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("checkExpLock", 10*time.Millisecond) + rl.Lockout(context.Background(), "checkExpLock", 10*time.Millisecond) time.Sleep(30 * time.Millisecond) if !rl.Check("checkExpLock", 5, time.Second) { t.Error("Check() = false after lockout expired, want true") @@ -221,11 +222,11 @@ func TestRateLimiter_ConcurrentHammering(t *testing.T) { func TestRateLimiter_ResetClearsLockout(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("resetLock", time.Hour) + rl.Lockout(context.Background(), "resetLock", time.Hour) if !rl.IsLockedOut("resetLock") { t.Fatal("precondition: key should be locked out") } - rl.Reset("resetLock") + rl.Reset(context.Background(), "resetLock") if rl.IsLockedOut("resetLock") { t.Error("Reset() should clear lockout, but key is still locked out") } diff --git a/Server/db/account_test.go b/Server/db/account_test.go index ff41cfb1..e0654d6d 100644 --- a/Server/db/account_test.go +++ b/Server/db/account_test.go @@ -83,7 +83,7 @@ func TestDeleteAccount_AnonymisesUsername(t *testing.T) { t.Fatalf("DeleteAccount: %v", err) } - user, err := database.GetUserByID(userID) + user, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after delete: %v", err) } @@ -100,7 +100,7 @@ func TestDeleteAccount_ClearsPassword(t *testing.T) { database.DeleteAccount(context.Background(), userID) //nolint:errcheck - user, _ := database.GetUserByID(userID) + user, _ := database.GetUserByID(context.Background(), userID) if user.PasswordHash != "" { t.Errorf("PasswordHash = %q, want empty", user.PasswordHash) } @@ -111,11 +111,11 @@ func TestDeleteAccount_ClearsAvatarAndTOTP(t *testing.T) { userID := seedUser(t, database, "charlie") // Set avatar and TOTP before deletion. - database.Exec("UPDATE users SET avatar = 'pic.png', totp_secret = 'SECRET' WHERE id = ?", userID) //nolint:errcheck + database.ExecContext(context.Background(), "UPDATE users SET avatar = 'pic.png', totp_secret = 'SECRET' WHERE id = ?", userID) //nolint:errcheck database.DeleteAccount(context.Background(), userID) //nolint:errcheck - user, _ := database.GetUserByID(userID) + user, _ := database.GetUserByID(context.Background(), userID) if user.Avatar != nil { t.Errorf("Avatar = %v, want nil", user.Avatar) } @@ -130,7 +130,7 @@ func TestDeleteAccount_SetsBannedAndOffline(t *testing.T) { database.DeleteAccount(context.Background(), userID) //nolint:errcheck - user, _ := database.GetUserByID(userID) + user, _ := database.GetUserByID(context.Background(), userID) if !user.Banned { t.Error("Banned should be true after deletion") } @@ -146,7 +146,7 @@ func TestDeleteAccount_DeletesSessions(t *testing.T) { userID := seedUser(t, database, "eve") // Insert a session directly. - database.Exec( + database.ExecContext(context.Background(), "INSERT INTO sessions (user_id, token, expires_at) VALUES (?, 'tok123', datetime('now', '+1 day'))", userID, ) //nolint:errcheck @@ -154,7 +154,7 @@ func TestDeleteAccount_DeletesSessions(t *testing.T) { database.DeleteAccount(context.Background(), userID) //nolint:errcheck var count int - database.QueryRow("SELECT COUNT(*) FROM sessions WHERE user_id = ?", userID).Scan(&count) //nolint:errcheck + database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM sessions WHERE user_id = ?", userID).Scan(&count) //nolint:errcheck if count != 0 { t.Errorf("sessions count = %d, want 0", count) } @@ -165,11 +165,11 @@ func TestDeleteAccount_SoftDeletesMessages(t *testing.T) { userID := seedUser(t, database, "frank") chID := seedChannel(t, database, "general") - msgID, _ := database.CreateMessage(chID, userID, "hello world", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hello world", nil) database.DeleteAccount(context.Background(), userID) //nolint:errcheck - msg, err := database.GetMessage(msgID) + msg, err := database.GetMessage(context.Background(), msgID) if err != nil { t.Fatalf("GetMessage after delete: %v", err) } @@ -194,7 +194,7 @@ func TestDeleteAccount_NonexistentUser(t *testing.T) { func setRole(t *testing.T, database *db.DB, userID, roleID int64) { t.Helper() - if _, err := database.Exec("UPDATE users SET role_id = ? WHERE id = ?", roleID, userID); err != nil { + if _, err := database.ExecContext(context.Background(), "UPDATE users SET role_id = ? WHERE id = ?", roleID, userID); err != nil { t.Fatalf("setRole(%d, %d): %v", userID, roleID, err) } } diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go index fcf47c47..5d460e20 100644 --- a/Server/db/admin_queries.go +++ b/Server/db/admin_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -13,8 +14,8 @@ import ( // ─── Setup ─────────────────────────────────────────────────────────────────── // UserCount returns the total number of registered users. -func (d *DB) UserCount() (int64, error) { - count, err := d.q.UserCount(dbCtx()) +func (d *DB) UserCount(ctx context.Context) (int64, error) { + count, err := d.q.UserCount(ctx) if err != nil { return 0, fmt.Errorf("UserCount: %w", err) } @@ -26,20 +27,20 @@ func (d *DB) UserCount() (int64, error) { // GetServerStats returns aggregate counts for the admin dashboard. // DBSizeBytes is 0 for in-memory databases (page_count * page_size returns // a meaningful value only for file-backed databases). -func (d *DB) GetServerStats() (*ServerStats, error) { +func (d *DB) GetServerStats(ctx context.Context) (*ServerStats, error) { stats := &ServerStats{} var err error - if stats.UserCount, err = d.q.CountUsers(dbCtx()); err != nil { + if stats.UserCount, err = d.q.CountUsers(ctx); err != nil { return nil, fmt.Errorf("GetServerStats users: %w", err) } - if stats.MessageCount, err = d.q.CountActiveMessages(dbCtx()); err != nil { + if stats.MessageCount, err = d.q.CountActiveMessages(ctx); err != nil { return nil, fmt.Errorf("GetServerStats messages: %w", err) } - if stats.ChannelCount, err = d.q.CountChannels(dbCtx()); err != nil { + if stats.ChannelCount, err = d.q.CountChannels(ctx); err != nil { return nil, fmt.Errorf("GetServerStats channels: %w", err) } - if stats.InviteCount, err = d.q.CountActiveInvites(dbCtx()); err != nil { + if stats.InviteCount, err = d.q.CountActiveInvites(ctx); err != nil { return nil, fmt.Errorf("GetServerStats invites: %w", err) } @@ -47,10 +48,10 @@ func (d *DB) GetServerStats() (*ServerStats, error) { // expressible as sqlc queries, so they stay on the raw connection. // For :memory: databases this still works (returns the in-memory size). var pageCount, pageSize int64 - if err := d.sqlDB.QueryRow(`PRAGMA page_count`).Scan(&pageCount); err != nil { + if err := d.sqlDB.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err != nil { return nil, fmt.Errorf("GetServerStats page_count: %w", err) } - if err := d.sqlDB.QueryRow(`PRAGMA page_size`).Scan(&pageSize); err != nil { + if err := d.sqlDB.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil { return nil, fmt.Errorf("GetServerStats page_size: %w", err) } stats.DBSizeBytes = pageCount * pageSize @@ -62,8 +63,8 @@ func (d *DB) GetServerStats() (*ServerStats, error) { // ListAllUsers returns users joined with their role name, ordered by ID. // limit=0 returns no rows. -func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) { - rows, err := d.q.ListAllUsers(dbCtx(), dbgen.ListAllUsersParams{ +func (d *DB) ListAllUsers(ctx context.Context, limit, offset int) ([]UserWithRole, error) { + rows, err := d.q.ListAllUsers(ctx, dbgen.ListAllUsersParams{ Limit: int64(limit), Offset: int64(offset), }) @@ -92,8 +93,8 @@ func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) { } // UpdateUserRole changes the role_id of a user. -func (d *DB) UpdateUserRole(userID, roleID int64) error { - if err := d.q.UpdateUserRole(dbCtx(), dbgen.UpdateUserRoleParams{ +func (d *DB) UpdateUserRole(ctx context.Context, userID, roleID int64) error { + if err := d.q.UpdateUserRole(ctx, dbgen.UpdateUserRoleParams{ RoleID: roleID, ID: userID, }); err != nil { @@ -103,16 +104,16 @@ func (d *DB) UpdateUserRole(userID, roleID int64) error { } // ForceLogoutUser deletes all sessions for the given user ID. -func (d *DB) ForceLogoutUser(userID int64) error { - if err := d.q.ForceLogoutUser(dbCtx(), userID); err != nil { +func (d *DB) ForceLogoutUser(ctx context.Context, userID int64) error { + if err := d.q.ForceLogoutUser(ctx, userID); err != nil { return fmt.Errorf("ForceLogoutUser: %w", err) } return nil } // GetUserSessions returns all active sessions for the given user ID. -func (d *DB) GetUserSessions(userID int64) ([]Session, error) { - rows, err := d.q.GetUserSessions(dbCtx(), userID) +func (d *DB) GetUserSessions(ctx context.Context, userID int64) ([]Session, error) { + rows, err := d.q.GetUserSessions(ctx, userID) if err != nil { return nil, fmt.Errorf("GetUserSessions: %w", err) } @@ -127,8 +128,8 @@ func (d *DB) GetUserSessions(userID int64) ([]Session, error) { // AdminCreateChannel creates a channel with full field control including position. // No sqlc query covers this exact INSERT shape, so it stays on raw SQL. -func (d *DB) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { - res, err := d.sqlDB.Exec( +func (d *DB) AdminCreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) { + res, err := d.sqlDB.ExecContext(ctx, `INSERT INTO channels (name, type, category, topic, position) VALUES (?, ?, ?, ?, ?)`, name, chanType, strToNullPtr(category), strToNullPtr(topic), position, @@ -140,8 +141,8 @@ func (d *DB) AdminCreateChannel(name, chanType, category, topic string, position } // AdminUpdateChannel updates all mutable channel fields. -func (d *DB) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { - if err := d.q.AdminUpdateChannel(dbCtx(), dbgen.AdminUpdateChannelParams{ +func (d *DB) AdminUpdateChannel(ctx context.Context, id int64, name, topic string, slowMode, position int, archived bool) error { + if err := d.q.AdminUpdateChannel(ctx, dbgen.AdminUpdateChannelParams{ Name: name, Topic: strToNullPtr(topic), SlowMode: int64(slowMode), @@ -155,8 +156,8 @@ func (d *DB) AdminUpdateChannel(id int64, name, topic string, slowMode, position } // AdminDeleteChannel removes a channel by ID (cascades to messages, etc.). -func (d *DB) AdminDeleteChannel(id int64) error { - if err := d.q.DeleteChannel(dbCtx(), id); err != nil { +func (d *DB) AdminDeleteChannel(ctx context.Context, id int64) error { + if err := d.q.DeleteChannel(ctx, id); err != nil { return fmt.Errorf("AdminDeleteChannel: %w", err) } return nil @@ -165,8 +166,8 @@ func (d *DB) AdminDeleteChannel(id int64) error { // ─── Audit Log ──────────────────────────────────────────────────────────────── // LogAudit inserts an audit log entry. -func (d *DB) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { - if err := d.q.LogAudit(dbCtx(), dbgen.LogAuditParams{ +func (d *DB) LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error { + if err := d.q.LogAudit(ctx, dbgen.LogAuditParams{ ActorID: actorID, Action: action, TargetType: targetType, @@ -179,8 +180,8 @@ func (d *DB) LogAudit(actorID int64, action, targetType string, targetID int64, } // GetAuditLog returns audit log entries ordered newest-first with pagination. -func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) { - rows, err := d.q.GetAuditLog(dbCtx(), dbgen.GetAuditLogParams{ +func (d *DB) GetAuditLog(ctx context.Context, limit, offset int) ([]AuditEntry, error) { + rows, err := d.q.GetAuditLog(ctx, dbgen.GetAuditLogParams{ Limit: int64(limit), Offset: int64(offset), }) @@ -207,8 +208,8 @@ func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) { // GetSetting returns the value for the given settings key. // Returns an error (wrapping sql.ErrNoRows) when the key does not exist. -func (d *DB) GetSetting(key string) (string, error) { - value, err := d.q.GetSetting(dbCtx(), key) +func (d *DB) GetSetting(ctx context.Context, key string) (string, error) { + value, err := d.q.GetSetting(ctx, key) if errors.Is(err, sql.ErrNoRows) { return "", fmt.Errorf("GetSetting: key %q: %w", key, ErrNotFound) } @@ -219,8 +220,8 @@ func (d *DB) GetSetting(key string) (string, error) { } // SetSetting upserts a setting value for the given key. -func (d *DB) SetSetting(key, value string) error { - if err := d.q.SetSetting(dbCtx(), dbgen.SetSettingParams{ +func (d *DB) SetSetting(ctx context.Context, key, value string) error { + if err := d.q.SetSetting(ctx, dbgen.SetSettingParams{ Key: key, Value: value, }); err != nil { @@ -230,8 +231,8 @@ func (d *DB) SetSetting(key, value string) error { } // GetAllSettings returns all settings as a key→value map. -func (d *DB) GetAllSettings() (map[string]string, error) { - rows, err := d.q.GetAllSettings(dbCtx()) +func (d *DB) GetAllSettings(ctx context.Context) (map[string]string, error) { + rows, err := d.q.GetAllSettings(ctx) if err != nil { return nil, fmt.Errorf("GetAllSettings: %w", err) } @@ -244,8 +245,8 @@ func (d *DB) GetAllSettings() (map[string]string, error) { // CountUsersWithoutTOTP returns the number of non-banned users that do not // currently have a confirmed TOTP secret. -func (d *DB) CountUsersWithoutTOTP() (int, error) { - count, err := d.q.CountUsersWithoutTOTP(dbCtx()) +func (d *DB) CountUsersWithoutTOTP(ctx context.Context) (int, error) { + count, err := d.q.CountUsersWithoutTOTP(ctx) if err != nil { return 0, fmt.Errorf("CountUsersWithoutTOTP: %w", err) } @@ -266,13 +267,13 @@ func (d *DB) CountUsersWithoutTOTP() (int, error) { // // The caller in handleBackup constructs the path from a hardcoded directory // and a timestamp — no user input reaches this function. -func (d *DB) BackupTo(path string) error { - return d.BackupToSafe(path, filepath.Join("data", "backups")) +func (d *DB) BackupTo(ctx context.Context, path string) error { + return d.BackupToSafe(ctx, path, filepath.Join("data", "backups")) } // BackupToSafe is the internal implementation that accepts an explicit safe // root directory. Exported for testing with isolated directories. -func (d *DB) BackupToSafe(path, safeRoot string) error { +func (d *DB) BackupToSafe(ctx context.Context, path, safeRoot string) error { clean := filepath.Clean(path) absRoot, err := filepath.Abs(safeRoot) @@ -310,7 +311,7 @@ func (d *DB) BackupToSafe(path, safeRoot string) error { return fmt.Errorf("BackupToSafe: path contains forbidden sequence %q", "--") } - _, err = d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", absClean)) + _, err = d.sqlDB.ExecContext(ctx, fmt.Sprintf("VACUUM INTO '%s'", absClean)) if err != nil { return fmt.Errorf("BackupToSafe: %w", err) } diff --git a/Server/db/admin_queries_test.go b/Server/db/admin_queries_test.go index a0316da2..57920bee 100644 --- a/Server/db/admin_queries_test.go +++ b/Server/db/admin_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "fmt" "os" "path/filepath" @@ -87,7 +88,7 @@ func newAdminTestDB(t *testing.T) *db.DB { func TestGetServerStats_EmptyDB(t *testing.T) { database := newAdminTestDB(t) - stats, err := database.GetServerStats() + stats, err := database.GetServerStats(context.Background()) if err != nil { t.Fatalf("GetServerStats() error: %v", err) } @@ -114,17 +115,17 @@ func TestGetServerStats_EmptyDB(t *testing.T) { func TestGetServerStats_WithData(t *testing.T) { database := newAdminTestDB(t) - _, err := database.CreateUser("statuser", "hash", 4) + _, err := database.CreateUser(context.Background(), "statuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - _, err = database.CreateChannel("general", "text", "", "", 0) + _, err = database.CreateChannel(context.Background(), "general", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel error: %v", err) } - stats, err := database.GetServerStats() + stats, err := database.GetServerStats(context.Background()) if err != nil { t.Fatalf("GetServerStats() error: %v", err) } @@ -141,7 +142,7 @@ func TestGetServerStats_WithData(t *testing.T) { func TestListAllUsers_Empty(t *testing.T) { database := newAdminTestDB(t) - users, err := database.ListAllUsers(50, 0) + users, err := database.ListAllUsers(context.Background(), 50, 0) if err != nil { t.Fatalf("ListAllUsers() error: %v", err) } @@ -153,12 +154,12 @@ func TestListAllUsers_Empty(t *testing.T) { func TestListAllUsers_WithRoleName(t *testing.T) { database := newAdminTestDB(t) - _, err := database.CreateUser("alice", "hash", 4) + _, err := database.CreateUser(context.Background(), "alice", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - users, err := database.ListAllUsers(50, 0) + users, err := database.ListAllUsers(context.Background(), 50, 0) if err != nil { t.Fatalf("ListAllUsers() error: %v", err) } @@ -178,7 +179,7 @@ func TestListAllUsers_Pagination(t *testing.T) { database := newAdminTestDB(t) for i := range 5 { - _, err := database.CreateUser( + _, err := database.CreateUser(context.Background(), strings.Repeat("u", i+1), "hash", 4, @@ -188,7 +189,7 @@ func TestListAllUsers_Pagination(t *testing.T) { } } - page1, err := database.ListAllUsers(3, 0) + page1, err := database.ListAllUsers(context.Background(), 3, 0) if err != nil { t.Fatalf("ListAllUsers page1 error: %v", err) } @@ -196,7 +197,7 @@ func TestListAllUsers_Pagination(t *testing.T) { t.Errorf("page1 len = %d, want 3", len(page1)) } - page2, err := database.ListAllUsers(3, 3) + page2, err := database.ListAllUsers(context.Background(), 3, 3) if err != nil { t.Fatalf("ListAllUsers page2 error: %v", err) } @@ -207,9 +208,9 @@ func TestListAllUsers_Pagination(t *testing.T) { func TestListAllUsers_ZeroLimit(t *testing.T) { database := newAdminTestDB(t) - _, _ = database.CreateUser("zerotest", "hash", 4) + _, _ = database.CreateUser(context.Background(), "zerotest", "hash", 4) - users, err := database.ListAllUsers(0, 0) + users, err := database.ListAllUsers(context.Background(), 0, 0) if err != nil { t.Fatalf("ListAllUsers(0, 0) error: %v", err) } @@ -224,16 +225,16 @@ func TestListAllUsers_ZeroLimit(t *testing.T) { func TestUpdateUserRole(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("roleuser", "hash", 4) + uid, err := database.CreateUser(context.Background(), "roleuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - if err := database.UpdateUserRole(uid, 2); err != nil { + if err := database.UpdateUserRole(context.Background(), uid, 2); err != nil { t.Fatalf("UpdateUserRole() error: %v", err) } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil { t.Fatalf("GetUserByID error: %v", err) } @@ -246,7 +247,7 @@ func TestUpdateUserRole_NonexistentUser(t *testing.T) { database := newAdminTestDB(t) // UPDATE with no matching rows is not an error - err := database.UpdateUserRole(99999, 2) + err := database.UpdateUserRole(context.Background(), 99999, 2) if err != nil { t.Errorf("UpdateUserRole() for nonexistent user returned unexpected error: %v", err) } @@ -257,15 +258,15 @@ func TestUpdateUserRole_NonexistentUser(t *testing.T) { func TestForceLogoutUser_DeletesSessions(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("logoutuser", "hash", 4) + uid, err := database.CreateUser(context.Background(), "logoutuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - _, _ = database.CreateSession(uid, "token1hash", "device1", "127.0.0.1") - _, _ = database.CreateSession(uid, "token2hash", "device2", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, "token1hash", "device1", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, "token2hash", "device2", "127.0.0.1") - sessions, err := database.GetUserSessions(uid) + sessions, err := database.GetUserSessions(context.Background(), uid) if err != nil { t.Fatalf("GetUserSessions error: %v", err) } @@ -273,11 +274,11 @@ func TestForceLogoutUser_DeletesSessions(t *testing.T) { t.Fatalf("expected 2 sessions before logout, got %d", len(sessions)) } - if err := database.ForceLogoutUser(uid); err != nil { + if err := database.ForceLogoutUser(context.Background(), uid); err != nil { t.Fatalf("ForceLogoutUser() error: %v", err) } - sessions, err = database.GetUserSessions(uid) + sessions, err = database.GetUserSessions(context.Background(), uid) if err != nil { t.Fatalf("GetUserSessions after logout error: %v", err) } @@ -289,12 +290,12 @@ func TestForceLogoutUser_DeletesSessions(t *testing.T) { func TestForceLogoutUser_NoSessions(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("nosessions", "hash", 4) + uid, err := database.CreateUser(context.Background(), "nosessions", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - if err := database.ForceLogoutUser(uid); err != nil { + if err := database.ForceLogoutUser(context.Background(), uid); err != nil { t.Errorf("ForceLogoutUser() on user with no sessions returned error: %v", err) } } @@ -304,12 +305,12 @@ func TestForceLogoutUser_NoSessions(t *testing.T) { func TestGetUserSessions_Empty(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("sessionuser", "hash", 4) + uid, err := database.CreateUser(context.Background(), "sessionuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - sessions, err := database.GetUserSessions(uid) + sessions, err := database.GetUserSessions(context.Background(), uid) if err != nil { t.Fatalf("GetUserSessions() error: %v", err) } @@ -321,14 +322,14 @@ func TestGetUserSessions_Empty(t *testing.T) { func TestGetUserSessions_IsolatedByUser(t *testing.T) { database := newAdminTestDB(t) - uid1, _ := database.CreateUser("user1sess", "hash", 4) - uid2, _ := database.CreateUser("user2sess", "hash", 4) + uid1, _ := database.CreateUser(context.Background(), "user1sess", "hash", 4) + uid2, _ := database.CreateUser(context.Background(), "user2sess", "hash", 4) - _, _ = database.CreateSession(uid1, "u1t1", "web", "1.2.3.4") - _, _ = database.CreateSession(uid1, "u1t2", "mobile", "1.2.3.5") - _, _ = database.CreateSession(uid2, "u2t1", "web", "1.2.3.6") + _, _ = database.CreateSession(context.Background(), uid1, "u1t1", "web", "1.2.3.4") + _, _ = database.CreateSession(context.Background(), uid1, "u1t2", "mobile", "1.2.3.5") + _, _ = database.CreateSession(context.Background(), uid2, "u2t1", "web", "1.2.3.6") - sessions, err := database.GetUserSessions(uid1) + sessions, err := database.GetUserSessions(context.Background(), uid1) if err != nil { t.Fatalf("GetUserSessions() error: %v", err) } @@ -347,7 +348,7 @@ func TestGetUserSessions_IsolatedByUser(t *testing.T) { func TestAdminCreateChannel(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("announce", "text", "General", "Announcements", 1) + id, err := database.AdminCreateChannel(context.Background(), "announce", "text", "General", "Announcements", 1) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } @@ -355,7 +356,7 @@ func TestAdminCreateChannel(t *testing.T) { t.Errorf("AdminCreateChannel() id = %d, want > 0", id) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -382,12 +383,12 @@ func TestAdminCreateChannel(t *testing.T) { func TestAdminCreateChannel_EmptyOptionals(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("simple", "voice", "", "", 0) + id, err := database.AdminCreateChannel(context.Background(), "simple", "voice", "", "", 0) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -404,16 +405,16 @@ func TestAdminCreateChannel_EmptyOptionals(t *testing.T) { func TestAdminUpdateChannel(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("old-name", "text", "", "", 0) + id, err := database.AdminCreateChannel(context.Background(), "old-name", "text", "", "", 0) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } - if err := database.AdminUpdateChannel(id, "new-name", "new topic", 5, 2, true); err != nil { + if err := database.AdminUpdateChannel(context.Background(), id, "new-name", "new topic", 5, 2, true); err != nil { t.Fatalf("AdminUpdateChannel() error: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -437,17 +438,17 @@ func TestAdminUpdateChannel(t *testing.T) { func TestAdminUpdateChannel_Unarchive(t *testing.T) { database := newAdminTestDB(t) - id, _ := database.AdminCreateChannel("arch-ch", "text", "", "", 0) - _ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, true) + id, _ := database.AdminCreateChannel(context.Background(), "arch-ch", "text", "", "", 0) + _ = database.AdminUpdateChannel(context.Background(), id, "arch-ch", "", 0, 0, true) - ch, _ := database.GetChannel(id) + ch, _ := database.GetChannel(context.Background(), id) if !ch.Archived { t.Fatal("channel should be archived") } // Unarchive - _ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, false) - ch, _ = database.GetChannel(id) + _ = database.AdminUpdateChannel(context.Background(), id, "arch-ch", "", 0, 0, false) + ch, _ = database.GetChannel(context.Background(), id) if ch.Archived { t.Error("Archived = true after unarchiving, want false") } @@ -458,16 +459,16 @@ func TestAdminUpdateChannel_Unarchive(t *testing.T) { func TestAdminDeleteChannel(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("to-delete", "text", "", "", 0) + id, err := database.AdminCreateChannel(context.Background(), "to-delete", "text", "", "", 0) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } - if err := database.AdminDeleteChannel(id); err != nil { + if err := database.AdminDeleteChannel(context.Background(), id); err != nil { t.Fatalf("AdminDeleteChannel() error: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() after delete error: %v", err) } @@ -480,7 +481,7 @@ func TestAdminDeleteChannel_NonExistent(t *testing.T) { database := newAdminTestDB(t) // Deleting nonexistent channel should not error - if err := database.AdminDeleteChannel(99999); err != nil { + if err := database.AdminDeleteChannel(context.Background(), 99999); err != nil { t.Errorf("AdminDeleteChannel(nonexistent) error: %v", err) } } @@ -490,16 +491,16 @@ func TestAdminDeleteChannel_NonExistent(t *testing.T) { func TestLogAudit_AndRetrieve(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("auditor", "hash", 1) + uid, err := database.CreateUser(context.Background(), "auditor", "hash", 1) if err != nil { t.Fatalf("CreateUser error: %v", err) } - if err := database.LogAudit(uid, "USER_BANNED", "user", 42, "banned for spam"); err != nil { + if err := database.LogAudit(context.Background(), uid, "USER_BANNED", "user", 42, "banned for spam"); err != nil { t.Fatalf("LogAudit() error: %v", err) } - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog() error: %v", err) } @@ -534,7 +535,7 @@ func TestLogAudit_AndRetrieve(t *testing.T) { func TestGetAuditLog_Empty(t *testing.T) { database := newAdminTestDB(t) - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog() error: %v", err) } @@ -546,12 +547,12 @@ func TestGetAuditLog_Empty(t *testing.T) { func TestGetAuditLog_Pagination(t *testing.T) { database := newAdminTestDB(t) - uid, _ := database.CreateUser("auditpager", "hash", 1) + uid, _ := database.CreateUser(context.Background(), "auditpager", "hash", 1) for i := range 5 { - _ = database.LogAudit(uid, "ACTION", "target", int64(i), "detail") + _ = database.LogAudit(context.Background(), uid, "ACTION", "target", int64(i), "detail") } - page1, err := database.GetAuditLog(3, 0) + page1, err := database.GetAuditLog(context.Background(), 3, 0) if err != nil { t.Fatalf("GetAuditLog page1 error: %v", err) } @@ -559,7 +560,7 @@ func TestGetAuditLog_Pagination(t *testing.T) { t.Errorf("page1 len = %d, want 3", len(page1)) } - page2, err := database.GetAuditLog(3, 3) + page2, err := database.GetAuditLog(context.Background(), 3, 3) if err != nil { t.Fatalf("GetAuditLog page2 error: %v", err) } @@ -571,11 +572,11 @@ func TestGetAuditLog_Pagination(t *testing.T) { func TestGetAuditLog_NewestFirst(t *testing.T) { database := newAdminTestDB(t) - uid, _ := database.CreateUser("auditorder", "hash", 1) - _ = database.LogAudit(uid, "FIRST", "", 0, "") - _ = database.LogAudit(uid, "SECOND", "", 0, "") + uid, _ := database.CreateUser(context.Background(), "auditorder", "hash", 1) + _ = database.LogAudit(context.Background(), uid, "FIRST", "", 0, "") + _ = database.LogAudit(context.Background(), uid, "SECOND", "", 0, "") - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog() error: %v", err) } @@ -592,7 +593,7 @@ func TestGetAuditLog_NewestFirst(t *testing.T) { func TestGetSetting_Exists(t *testing.T) { database := newAdminTestDB(t) - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting() error: %v", err) } @@ -604,7 +605,7 @@ func TestGetSetting_Exists(t *testing.T) { func TestGetSetting_NotFound(t *testing.T) { database := newAdminTestDB(t) - _, err := database.GetSetting("nonexistent_key_xyz") + _, err := database.GetSetting(context.Background(), "nonexistent_key_xyz") if err == nil { t.Error("GetSetting() for nonexistent key should return error") } @@ -613,11 +614,11 @@ func TestGetSetting_NotFound(t *testing.T) { func TestSetSetting_NewKey(t *testing.T) { database := newAdminTestDB(t) - if err := database.SetSetting("custom_key", "custom_val"); err != nil { + if err := database.SetSetting(context.Background(), "custom_key", "custom_val"); err != nil { t.Fatalf("SetSetting() error: %v", err) } - val, err := database.GetSetting("custom_key") + val, err := database.GetSetting(context.Background(), "custom_key") if err != nil { t.Fatalf("GetSetting() after SetSetting error: %v", err) } @@ -629,11 +630,11 @@ func TestSetSetting_NewKey(t *testing.T) { func TestSetSetting_UpdateExisting(t *testing.T) { database := newAdminTestDB(t) - if err := database.SetSetting("server_name", "My Custom Server"); err != nil { + if err := database.SetSetting(context.Background(), "server_name", "My Custom Server"); err != nil { t.Fatalf("SetSetting() update error: %v", err) } - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting() error: %v", err) } @@ -645,7 +646,7 @@ func TestSetSetting_UpdateExisting(t *testing.T) { func TestGetAllSettings_ReturnsMap(t *testing.T) { database := newAdminTestDB(t) - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(context.Background()) if err != nil { t.Fatalf("GetAllSettings() error: %v", err) } @@ -660,9 +661,9 @@ func TestGetAllSettings_ReturnsMap(t *testing.T) { func TestGetAllSettings_AfterClearing(t *testing.T) { database := newAdminTestDB(t) - _, _ = database.Exec("DELETE FROM settings") + _, _ = database.ExecContext(context.Background(), "DELETE FROM settings") - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(context.Background()) if err != nil { t.Fatalf("GetAllSettings() after clearing error: %v", err) } @@ -693,7 +694,7 @@ func TestBackupToSafe_AdminQueries(t *testing.T) { backupDir := filepath.Join(tmpDir, "backups") _ = os.MkdirAll(backupDir, 0o755) backupPath := filepath.Join(backupDir, "backup.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() error: %v", err) } @@ -725,7 +726,7 @@ func TestBackupToSafe_CreatesDirectoryFile(t *testing.T) { _ = os.MkdirAll(backupDir, 0o755) backupPath := filepath.Join(backupDir, "chatserver_20260314_120000.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() error: %v", err) } @@ -739,7 +740,7 @@ func TestBackupToSafe_CreatesDirectoryFile(t *testing.T) { func TestUserCount_Empty(t *testing.T) { database := newAdminTestDB(t) - count, err := database.UserCount() + count, err := database.UserCount(context.Background()) if err != nil { t.Fatalf("UserCount() error: %v", err) } @@ -752,7 +753,7 @@ func TestUserCount_WithUsers(t *testing.T) { database := newAdminTestDB(t) for i := range 3 { - _, err := database.CreateUser( + _, err := database.CreateUser(context.Background(), fmt.Sprintf("countuser%d", i), "hash", 4, @@ -762,7 +763,7 @@ func TestUserCount_WithUsers(t *testing.T) { } } - count, err := database.UserCount() + count, err := database.UserCount(context.Background()) if err != nil { t.Fatalf("UserCount() error: %v", err) } @@ -793,7 +794,7 @@ func TestBackupToSafe_DirectCall(t *testing.T) { backupDir := filepath.Join(tmpDir, "backups") _ = os.MkdirAll(backupDir, 0o755) backupPath := filepath.Join(backupDir, "backup_direct.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() error: %v", err) } @@ -825,7 +826,7 @@ func TestBackupToSafe_RejectsTraversal(t *testing.T) { _ = os.MkdirAll(safeRoot, 0o755) unsafePath := filepath.Join(tmpDir, "outside", "evil.db") - err = database.BackupToSafe(unsafePath, safeRoot) + err = database.BackupToSafe(context.Background(), unsafePath, safeRoot) if err == nil { t.Error("BackupToSafe should reject path outside safe root") } diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index c6cdcafd..14a9630f 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -33,8 +34,8 @@ type AttachmentAccess struct { // CreateAttachment inserts a new attachment record (initially unlinked to any message). // uploaderID records who uploaded the file for ownership checks on unlinked files. // width and height are optional image dimensions (pass nil for non-image files). -func (d *DB) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { - if err := d.q.CreateAttachment(dbCtx(), dbgen.CreateAttachmentParams{ +func (d *DB) CreateAttachment(ctx context.Context, id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { + if err := d.q.CreateAttachment(ctx, dbgen.CreateAttachmentParams{ ID: id, UploaderID: &uploaderID, Filename: filename, @@ -50,8 +51,8 @@ func (d *DB) CreateAttachment(id string, uploaderID int64, filename, storedAs, m } // GetAttachmentByID returns the attachment with the given ID, or nil if not found. -func (d *DB) GetAttachmentByID(id string) (*Attachment, error) { - r, err := d.q.GetAttachmentByID(dbCtx(), id) +func (d *DB) GetAttachmentByID(ctx context.Context, id string) (*Attachment, error) { + r, err := d.q.GetAttachmentByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -74,8 +75,8 @@ func (d *DB) GetAttachmentByID(id string) (*Attachment, error) { // (channel ID and type) for access-control checks. Returns nil if the // attachment does not exist. ChannelID/ChannelType are nil/empty when the // attachment is unlinked or its message/channel was deleted. -func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) { - r, err := d.q.GetAttachmentWithChannel(dbCtx(), id) +func (d *DB) GetAttachmentWithChannel(ctx context.Context, id string) (*AttachmentAccess, error) { + r, err := d.q.GetAttachmentWithChannel(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -107,7 +108,7 @@ func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) { // is the atomic attachment-IDOR guard for message sends: ownership is // enforced in the same statement that links, so there is no check-then-link // race. Returns the number of rows updated. -func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { +func (d *DB) LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID int64, attachmentIDs []string) (int64, error) { if len(attachmentIDs) == 0 { return 0, nil } @@ -127,7 +128,7 @@ func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs AND (uploader_id = ? OR uploader_id IS NULL)`, strings.Join(placeholders, ","), ) - res, err := d.sqlDB.Exec(query, args...) + res, err := d.sqlDB.ExecContext(ctx, query, args...) if err != nil { return 0, fmt.Errorf("LinkAttachmentsToMessage: %w", err) } @@ -135,7 +136,7 @@ func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs } // GetAttachmentsByMessageIDs returns attachments grouped by message ID. -func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentInfo, error) { +func (d *DB) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]AttachmentInfo, error) { if len(msgIDs) == 0 { return map[int64][]AttachmentInfo{}, nil } @@ -152,7 +153,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI FROM attachments WHERE message_id IN (%s)`, strings.Join(placeholders, ","), ) - rows, err := d.sqlDB.Query(query, args...) + rows, err := d.sqlDB.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("GetAttachmentsByMessageIDs: %w", err) } @@ -184,8 +185,8 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI // BUG-132: Uses DELETE ... RETURNING to make select+delete atomic, // preventing a race where an attachment linked between SELECT and DELETE // would have its file deleted while the DB row survives. -func (d *DB) DeleteOrphanedAttachments(cutoff string) ([]string, error) { - files, err := d.q.DeleteOrphanedAttachments(dbCtx(), cutoff) +func (d *DB) DeleteOrphanedAttachments(ctx context.Context, cutoff string) ([]string, error) { + files, err := d.q.DeleteOrphanedAttachments(ctx, cutoff) if err != nil { return nil, fmt.Errorf("DeleteOrphanedAttachments: %w", err) } diff --git a/Server/db/attachment_queries_test.go b/Server/db/attachment_queries_test.go index 4048b63c..b1a18e47 100644 --- a/Server/db/attachment_queries_test.go +++ b/Server/db/attachment_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -9,7 +10,7 @@ import ( func TestGetAttachmentByID_NotFound(t *testing.T) { database := openMigratedMemory(t) - att, err := database.GetAttachmentByID("nonexistent-id") + att, err := database.GetAttachmentByID(context.Background(), "nonexistent-id") if err != nil { t.Errorf("GetAttachmentByID for nonexistent ID should return nil error, got %v", err) } @@ -22,7 +23,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { database := openMigratedMemory(t) // Insert an attachment directly. - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES (?, ?, ?, ?, ?)`, "att-001", "photo.png", "stored-photo.png", "image/png", 12345, @@ -31,7 +32,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { t.Fatalf("inserting attachment: %v", err) } - att, err := database.GetAttachmentByID("att-001") + att, err := database.GetAttachmentByID(context.Background(), "att-001") if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -57,7 +58,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { func TestLinkAttachmentsToMessage_Empty(t *testing.T) { database := openMigratedMemory(t) - n, err := database.LinkAttachmentsToMessage(1, 1, nil) + n, err := database.LinkAttachmentsToMessage(context.Background(), 1, 1, nil) if err != nil { t.Fatalf("LinkAttachmentsToMessage(nil): %v", err) } @@ -70,11 +71,11 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "linkuser") chID := seedChannel(t, database, "linkchan") - msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil) // Insert two unlinked attachments. for _, id := range []string{"att-a", "att-b"} { - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES (?, ?, ?, ?, ?)`, id, "file.txt", "stored.txt", "text/plain", 100, @@ -84,7 +85,7 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { } } - n, err := database.LinkAttachmentsToMessage(msgID, userID, []string{"att-a", "att-b"}) + n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"att-a", "att-b"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -93,7 +94,7 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { } // Verify linkage. - att, _ := database.GetAttachmentByID("att-a") + att, _ := database.GetAttachmentByID(context.Background(), "att-a") if att.MessageID == nil || *att.MessageID != msgID { t.Errorf("att-a MessageID = %v, want %d", att.MessageID, msgID) } @@ -103,17 +104,17 @@ func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "linkuser2") chID := seedChannel(t, database, "linkchan2") - msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) - msg2, _ := database.CreateMessage(chID, userID, "msg2", nil) + msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil) + msg2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil) - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id) VALUES (?, ?, ?, ?, ?, ?)`, "att-linked", "file.txt", "stored.txt", "text/plain", 100, msg1, ) // Try to re-link to a different message — should skip (WHERE message_id IS NULL). - n, err := database.LinkAttachmentsToMessage(msg2, userID, []string{"att-linked"}) + n, err := database.LinkAttachmentsToMessage(context.Background(), msg2, userID, []string{"att-linked"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -131,23 +132,23 @@ func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { owner := seedUser(t, database, "att-owner") other := seedUser(t, database, "att-other") chID := seedChannel(t, database, "att-owner-ch") - msgID, _ := database.CreateMessage(chID, owner, "attachment carrier", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, owner, "attachment carrier", nil) - if err := database.CreateAttachment("att-owned", owner, "o.txt", "s-o.txt", "text/plain", 1, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-owned", owner, "o.txt", "s-o.txt", "text/plain", 1, nil, nil); err != nil { t.Fatalf("CreateAttachment att-owned: %v", err) } - if err := database.CreateAttachment("att-foreign", other, "f.txt", "s-f.txt", "text/plain", 1, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-foreign", other, "f.txt", "s-f.txt", "text/plain", 1, nil, nil); err != nil { t.Fatalf("CreateAttachment att-foreign: %v", err) } // Legacy row from before uploader tracking: uploader_id IS NULL. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES ('att-legacy', 'l.txt', 's-l.txt', 'text/plain', 1)`, ); err != nil { t.Fatalf("inserting legacy attachment: %v", err) } - n, err := database.LinkAttachmentsToMessage(msgID, owner, + n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, owner, []string{"att-owned", "att-foreign", "att-legacy", "att-missing"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) @@ -155,13 +156,13 @@ func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { if n != 2 { t.Errorf("expected 2 linked (owned + legacy), got %d", n) } - if att, _ := database.GetAttachmentByID("att-owned"); att.MessageID == nil || *att.MessageID != msgID { + if att, _ := database.GetAttachmentByID(context.Background(), "att-owned"); att.MessageID == nil || *att.MessageID != msgID { t.Error("owner's unlinked attachment should link") } - if att, _ := database.GetAttachmentByID("att-foreign"); att.MessageID != nil { + if att, _ := database.GetAttachmentByID(context.Background(), "att-foreign"); att.MessageID != nil { t.Error("another user's attachment must never link (IDOR guard)") } - if att, _ := database.GetAttachmentByID("att-legacy"); att.MessageID == nil { + if att, _ := database.GetAttachmentByID(context.Background(), "att-legacy"); att.MessageID == nil { t.Error("legacy NULL-uploader attachment should be claimable") } } @@ -171,7 +172,7 @@ func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { func TestGetAttachmentsByMessageIDs_Empty(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetAttachmentsByMessageIDs(nil) + result, err := database.GetAttachmentsByMessageIDs(context.Background(), nil) if err != nil { t.Fatalf("GetAttachmentsByMessageIDs(nil): %v", err) } @@ -184,8 +185,8 @@ func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "attuser") chID := seedChannel(t, database, "attchan") - msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) - msg2, _ := database.CreateMessage(chID, userID, "msg2", nil) + msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil) + msg2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil) // Two attachments on msg1, one on msg2. for _, row := range []struct { @@ -196,7 +197,7 @@ func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) { {"att-1b", msg1}, {"att-2a", msg2}, } { - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id) VALUES (?, ?, ?, ?, ?, ?)`, row.id, "f.txt", "s.txt", "text/plain", 50, row.msgID, @@ -206,7 +207,7 @@ func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) { } } - result, err := database.GetAttachmentsByMessageIDs([]int64{msg1, msg2}) + result, err := database.GetAttachmentsByMessageIDs(context.Background(), []int64{msg1, msg2}) if err != nil { t.Fatalf("GetAttachmentsByMessageIDs: %v", err) } diff --git a/Server/db/audit.go b/Server/db/audit.go index 025d287d..e12f348d 100644 --- a/Server/db/audit.go +++ b/Server/db/audit.go @@ -1,13 +1,16 @@ package db -import "log/slog" +import ( + "context" + "log/slog" +) // Auditor is the minimal audit-write surface WriteAudit needs. *DB satisfies // it directly, and the service layer's Store interface does too, so every // caller — api, admin, ws, service — can route its audit writes through this // one helper regardless of whether it holds a *DB or a narrower interface. type Auditor interface { - LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error + LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error } // WriteAudit records an audit entry best-effort. @@ -19,8 +22,8 @@ type Auditor interface { // gap is visible in the logs. The detail string is intentionally not logged; // it can carry request-specific or sensitive text and the structured fields // already identify what was attempted. -func WriteAudit(a Auditor, actorID int64, action, targetType string, targetID int64, detail string) { - if err := a.LogAudit(actorID, action, targetType, targetID, detail); err != nil { +func WriteAudit(ctx context.Context, a Auditor, actorID int64, action, targetType string, targetID int64, detail string) { + if err := a.LogAudit(ctx, actorID, action, targetType, targetID, detail); err != nil { slog.Error("audit log write failed", "action", action, "actor_id", actorID, diff --git a/Server/db/audit_test.go b/Server/db/audit_test.go index d638a8f7..ec2de03e 100644 --- a/Server/db/audit_test.go +++ b/Server/db/audit_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "errors" "log/slog" "strings" @@ -16,7 +17,7 @@ type fakeAuditor struct { called bool } -func (f *fakeAuditor) LogAudit(_ int64, _, _ string, _ int64, _ string) error { +func (f *fakeAuditor) LogAudit(_ context.Context, _ int64, _, _ string, _ int64, _ string) error { f.called = true return f.err } @@ -39,7 +40,7 @@ func TestWriteAudit_LogsFailureButDoesNotPropagate(t *testing.T) { // WriteAudit returns nothing, so "never propagated" is structural — the // call simply must not panic and must record the failure. out := captureLogs(t, func() { - db.WriteAudit(a, 7, "user_ban", "user", 42, "spam") + db.WriteAudit(context.Background(), a, 7, "user_ban", "user", 42, "spam") }) if !a.called { @@ -67,7 +68,7 @@ func TestWriteAudit_SuccessLogsNothing(t *testing.T) { a := &fakeAuditor{err: nil} out := captureLogs(t, func() { - db.WriteAudit(a, 1, "user_login", "user", 1, "") + db.WriteAudit(context.Background(), a, 1, "user_login", "user", 1, "") }) if !a.called { diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 66b84aa9..9062faf4 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "crypto/rand" "database/sql" "encoding/hex" @@ -14,8 +15,8 @@ import ( // ─── User Operations ────────────────────────────────────────────────────────── // CreateUser inserts a new user record and returns the assigned ID. -func (d *DB) CreateUser(username, passwordHash string, roleID int) (int64, error) { - res, err := d.sqlDB.Exec( +func (d *DB) CreateUser(ctx context.Context, username, passwordHash string, roleID int) (int64, error) { + res, err := d.sqlDB.ExecContext(ctx, `INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`, username, passwordHash, roleID, ) @@ -28,8 +29,8 @@ func (d *DB) CreateUser(username, passwordHash string, roleID int) (int64, error // CreateOwnerIfEmpty atomically checks that no users exist and inserts the // first owner in a single transaction. Returns ErrConflict if any user already // exists, closing the TOCTOU race in the setup endpoint (BUG-119). -func (d *DB) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) { - tx, err := d.sqlDB.Begin() +func (d *DB) CreateOwnerIfEmpty(ctx context.Context, username, passwordHash string, roleID int) (int64, error) { + tx, err := d.sqlDB.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("CreateOwnerIfEmpty begin: %w", err) } @@ -70,8 +71,8 @@ func (d *DB) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int6 // CreateUserWithInvite atomically consumes an invite and creates the user in // the same transaction so a failed registration does not burn the invite. -func (d *DB) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) { - tx, err := d.sqlDB.Begin() +func (d *DB) CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error) { + tx, err := d.sqlDB.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("CreateUserWithInvite begin: %w", err) } @@ -120,8 +121,8 @@ func (d *DB) CreateUserWithInvite(username, passwordHash string, roleID int, inv // GetUserByUsername returns the user with the given username (case-insensitive), // or nil if not found. -func (d *DB) GetUserByUsername(username string) (*User, error) { - u, err := d.q.GetUserByUsername(dbCtx(), username) +func (d *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) { + u, err := d.q.GetUserByUsername(ctx, username) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -132,8 +133,8 @@ func (d *DB) GetUserByUsername(username string) (*User, error) { } // GetUserByID returns the user with the given ID, or nil if not found. -func (d *DB) GetUserByID(id int64) (*User, error) { - u, err := d.q.GetUserByID(dbCtx(), id) +func (d *DB) GetUserByID(ctx context.Context, id int64) (*User, error) { + u, err := d.q.GetUserByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -144,8 +145,8 @@ func (d *DB) GetUserByID(id int64) (*User, error) { } // UpdateUserStatus sets the status column for the given user ID. -func (d *DB) UpdateUserStatus(id int64, status string) error { - if err := d.q.UpdateUserStatus(dbCtx(), dbgen.UpdateUserStatusParams{ +func (d *DB) UpdateUserStatus(ctx context.Context, id int64, status string) error { + if err := d.q.UpdateUserStatus(ctx, dbgen.UpdateUserStatusParams{ Status: status, ID: id, }); err != nil { @@ -155,8 +156,8 @@ func (d *DB) UpdateUserStatus(id int64, status string) error { } // UpdateUserTOTPSecret sets or clears the TOTP secret for a user. -func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error { - if err := d.q.UpdateUserTOTPSecret(dbCtx(), dbgen.UpdateUserTOTPSecretParams{ +func (d *DB) UpdateUserTOTPSecret(ctx context.Context, id int64, secret *string) error { + if err := d.q.UpdateUserTOTPSecret(ctx, dbgen.UpdateUserTOTPSecretParams{ TotpSecret: secret, ID: id, }); err != nil { @@ -167,8 +168,8 @@ func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error { // ResetAllUserStatuses sets all users to "offline". Called on server startup // to clear stale statuses from a previous run or crash. -func (d *DB) ResetAllUserStatuses() error { - if err := d.q.ResetAllUserStatuses(dbCtx()); err != nil { +func (d *DB) ResetAllUserStatuses(ctx context.Context) error { + if err := d.q.ResetAllUserStatuses(ctx); err != nil { return fmt.Errorf("ResetAllUserStatuses: %w", err) } return nil @@ -176,14 +177,14 @@ func (d *DB) ResetAllUserStatuses() error { // BanUser marks a user as banned with an optional expiry. Pass nil for a // permanent ban. -func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { +func (d *DB) BanUser(ctx context.Context, id int64, reason string, expires *time.Time) error { var expiresStr *string if expires != nil { s := expires.UTC().Format("2006-01-02T15:04:05Z") expiresStr = &s } reasonCopy := reason - if err := d.q.BanUser(dbCtx(), dbgen.BanUserParams{ + if err := d.q.BanUser(ctx, dbgen.BanUserParams{ BanReason: &reasonCopy, BanExpires: expiresStr, ID: id, @@ -194,8 +195,8 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { } // UnbanUser removes the ban from a user. -func (d *DB) UnbanUser(id int64) error { - if err := d.q.UnbanUser(dbCtx(), id); err != nil { +func (d *DB) UnbanUser(ctx context.Context, id int64) error { + if err := d.q.UnbanUser(ctx, id); err != nil { return fmt.Errorf("UnbanUser: %w", err) } return nil @@ -212,16 +213,16 @@ const maxSessionsPerUser = 25 // tokenHash must already be hashed (never store plaintext tokens). // H-6: Enforces a per-user session cap by evicting the oldest session when // the limit is reached. -func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { +func (d *DB) CreateSession(ctx context.Context, userID int64, tokenHash, device, ip string) (int64, error) { // Evict oldest sessions if at or above the cap. - _ = d.q.EvictOldestSessions(dbCtx(), dbgen.EvictOldestSessionsParams{ + _ = d.q.EvictOldestSessions(ctx, dbgen.EvictOldestSessionsParams{ UserID: userID, Offset: maxSessionsPerUser - 1, }) expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z") deviceCopy, ipCopy := device, ip - res, err := d.q.InsertSession(dbCtx(), dbgen.InsertSessionParams{ + res, err := d.q.InsertSession(ctx, dbgen.InsertSessionParams{ UserID: userID, Token: tokenHash, Device: &deviceCopy, @@ -236,8 +237,8 @@ func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, e // GetSessionByTokenHash retrieves a session by its hashed token, or nil if // not found. -func (d *DB) GetSessionByTokenHash(tokenHash string) (*Session, error) { - s, err := d.q.GetSessionByTokenHash(dbCtx(), tokenHash) +func (d *DB) GetSessionByTokenHash(ctx context.Context, tokenHash string) (*Session, error) { + s, err := d.q.GetSessionByTokenHash(ctx, tokenHash) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -259,8 +260,8 @@ type SessionWithBanStatus struct { // GetSessionWithBanStatus returns the session joined with the user's ban // status in a single query. Returns nil, nil when not found. -func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, error) { - row, err := d.q.GetSessionWithBanStatus(dbCtx(), tokenHash) +func (d *DB) GetSessionWithBanStatus(ctx context.Context, tokenHash string) (*SessionWithBanStatus, error) { + row, err := d.q.GetSessionWithBanStatus(ctx, tokenHash) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -285,8 +286,8 @@ func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, e } // DeleteSession removes the session with the given token hash. -func (d *DB) DeleteSession(tokenHash string) error { - if err := d.q.DeleteSessionByToken(dbCtx(), tokenHash); err != nil { +func (d *DB) DeleteSession(ctx context.Context, tokenHash string) error { + if err := d.q.DeleteSessionByToken(ctx, tokenHash); err != nil { return fmt.Errorf("DeleteSession: %w", err) } return nil @@ -295,8 +296,8 @@ func (d *DB) DeleteSession(tokenHash string) error { // DeleteOtherSessions removes all sessions for the given user except the one // with keepSessionID. Used after password change or 2FA state change to // invalidate all other sessions (BUG-108). -func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { - result, err := d.q.DeleteOtherSessions(dbCtx(), dbgen.DeleteOtherSessionsParams{ +func (d *DB) DeleteOtherSessions(ctx context.Context, userID, keepSessionID int64) (int64, error) { + result, err := d.q.DeleteOtherSessions(ctx, dbgen.DeleteOtherSessionsParams{ UserID: userID, ID: keepSessionID, }) @@ -309,16 +310,16 @@ func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { // DeleteExpiredSessions removes all sessions whose expires_at is in the past. // Compares using strftime to handle both ISO-8601 and SQLite datetime formats. -func (d *DB) DeleteExpiredSessions() error { - if err := d.q.DeleteExpiredSessions(dbCtx()); err != nil { +func (d *DB) DeleteExpiredSessions(ctx context.Context) error { + if err := d.q.DeleteExpiredSessions(ctx); err != nil { return fmt.Errorf("DeleteExpiredSessions: %w", err) } return nil } // TouchSession updates last_used for the session with the given token hash. -func (d *DB) TouchSession(tokenHash string) error { - if err := d.q.TouchSession(dbCtx(), tokenHash); err != nil { +func (d *DB) TouchSession(ctx context.Context, tokenHash string) error { + if err := d.q.TouchSession(ctx, tokenHash); err != nil { return fmt.Errorf("TouchSession: %w", err) } return nil @@ -328,7 +329,7 @@ func (d *DB) TouchSession(tokenHash string) error { // CreateInvite generates a random invite code, persists it, and returns the // code. maxUses=0 means unlimited. expiresAt=nil means never expires. -func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { +func (d *DB) CreateInvite(ctx context.Context, createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { code, err := generateInviteCode() if err != nil { return "", fmt.Errorf("CreateInvite generate code: %w", err) @@ -344,7 +345,7 @@ func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (s expiresStr = &s } - if err := d.q.CreateInvite(dbCtx(), dbgen.CreateInviteParams{ + if err := d.q.CreateInvite(ctx, dbgen.CreateInviteParams{ Code: code, CreatedBy: createdBy, MaxUses: ptrItoI64(maxUsesVal), @@ -356,8 +357,8 @@ func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (s } // GetInvite returns the invite for the given code, or nil if not found. -func (d *DB) GetInvite(code string) (*Invite, error) { - r, err := d.q.GetInvite(dbCtx(), code) +func (d *DB) GetInvite(ctx context.Context, code string) (*Invite, error) { + r, err := d.q.GetInvite(ctx, code) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -388,8 +389,8 @@ func (d *DB) GetInvite(code string) (*Invite, error) { // // If zero rows are affected the invite is missing, revoked, expired, or // exhausted — an error is returned in all such cases. -func (d *DB) UseInviteAtomic(code string) error { - result, err := d.q.UseInviteAtomic(dbCtx(), code) +func (d *DB) UseInviteAtomic(ctx context.Context, code string) error { + result, err := d.q.UseInviteAtomic(ctx, code) if err != nil { return fmt.Errorf("UseInviteAtomic: %w", err) } @@ -404,8 +405,8 @@ func (d *DB) UseInviteAtomic(code string) error { } // RevokeInvite marks an invite as revoked. -func (d *DB) RevokeInvite(code string) error { - if err := d.q.RevokeInvite(dbCtx(), code); err != nil { +func (d *DB) RevokeInvite(ctx context.Context, code string) error { + if err := d.q.RevokeInvite(ctx, code); err != nil { return fmt.Errorf("RevokeInvite: %w", err) } return nil @@ -424,8 +425,8 @@ type MemberSummary struct { // ListMembers returns non-banned users as lightweight summaries. // M-12: Limited to 1000 rows to prevent unbounded result sets on large servers. -func (d *DB) ListMembers() ([]MemberSummary, error) { - rows, err := d.q.ListMembers(dbCtx()) +func (d *DB) ListMembers(ctx context.Context) ([]MemberSummary, error) { + rows, err := d.q.ListMembers(ctx) if err != nil { return nil, fmt.Errorf("ListMembers: %w", err) } diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go index 11f31d1b..44f20ce7 100644 --- a/Server/db/auth_queries_test.go +++ b/Server/db/auth_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "testing/fstest" "time" @@ -93,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); func TestCreateUser_Success(t *testing.T) { database := newTestDB(t) - id, err := database.CreateUser("alice", "hash123", 4) + id, err := database.CreateUser(context.Background(), "alice", "hash123", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -104,10 +105,10 @@ func TestCreateUser_Success(t *testing.T) { func TestCreateUser_DuplicateUsername(t *testing.T) { database := newTestDB(t) - if _, err := database.CreateUser("bob", "hash1", 4); err != nil { + if _, err := database.CreateUser(context.Background(), "bob", "hash1", 4); err != nil { t.Fatalf("first CreateUser: %v", err) } - _, err := database.CreateUser("bob", "hash2", 4) + _, err := database.CreateUser(context.Background(), "bob", "hash2", 4) if err == nil { t.Error("CreateUser() with duplicate username returned nil error, want error") } @@ -115,10 +116,10 @@ func TestCreateUser_DuplicateUsername(t *testing.T) { func TestCreateUser_CaseInsensitiveDuplicate(t *testing.T) { database := newTestDB(t) - if _, err := database.CreateUser("Charlie", "hash1", 4); err != nil { + if _, err := database.CreateUser(context.Background(), "Charlie", "hash1", 4); err != nil { t.Fatalf("first CreateUser: %v", err) } - _, err := database.CreateUser("charlie", "hash2", 4) + _, err := database.CreateUser(context.Background(), "charlie", "hash2", 4) if err == nil { t.Error("CreateUser() with case-insensitive duplicate returned nil error, want error") } @@ -126,9 +127,9 @@ func TestCreateUser_CaseInsensitiveDuplicate(t *testing.T) { func TestGetUserByUsername_Found(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("dave", "hashDave", 4) + _, _ = database.CreateUser(context.Background(), "dave", "hashDave", 4) - user, err := database.GetUserByUsername("dave") + user, err := database.GetUserByUsername(context.Background(), "dave") if err != nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -142,9 +143,9 @@ func TestGetUserByUsername_Found(t *testing.T) { func TestGetUserByUsername_CaseInsensitive(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("Eve", "hashEve", 4) + _, _ = database.CreateUser(context.Background(), "Eve", "hashEve", 4) - user, err := database.GetUserByUsername("EVE") + user, err := database.GetUserByUsername(context.Background(), "EVE") if err != nil { t.Fatalf("GetUserByUsername case-insensitive: %v", err) } @@ -155,7 +156,7 @@ func TestGetUserByUsername_CaseInsensitive(t *testing.T) { func TestGetUserByUsername_NotFound(t *testing.T) { database := newTestDB(t) - user, err := database.GetUserByUsername("nobody") + user, err := database.GetUserByUsername(context.Background(), "nobody") if err != nil { t.Fatalf("GetUserByUsername(not found): %v", err) } @@ -166,9 +167,9 @@ func TestGetUserByUsername_NotFound(t *testing.T) { func TestGetUserByID_Found(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("frank", "hashFrank", 4) + id, _ := database.CreateUser(context.Background(), "frank", "hashFrank", 4) - user, err := database.GetUserByID(id) + user, err := database.GetUserByID(context.Background(), id) if err != nil { t.Fatalf("GetUserByID: %v", err) } @@ -179,7 +180,7 @@ func TestGetUserByID_Found(t *testing.T) { func TestGetUserByID_NotFound(t *testing.T) { database := newTestDB(t) - user, err := database.GetUserByID(999) + user, err := database.GetUserByID(context.Background(), 999) if err != nil { t.Fatalf("GetUserByID(not found): %v", err) } @@ -190,12 +191,12 @@ func TestGetUserByID_NotFound(t *testing.T) { func TestUpdateUserStatus(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("grace", "hash", 4) + id, _ := database.CreateUser(context.Background(), "grace", "hash", 4) - if err := database.UpdateUserStatus(id, "online"); err != nil { + if err := database.UpdateUserStatus(context.Background(), id, "online"); err != nil { t.Fatalf("UpdateUserStatus: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if user.Status != "online" { t.Errorf("Status = %q, want %q", user.Status, "online") } @@ -203,12 +204,12 @@ func TestUpdateUserStatus(t *testing.T) { func TestBanUser_Permanent(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("hank", "hash", 4) + id, _ := database.CreateUser(context.Background(), "hank", "hash", 4) - if err := database.BanUser(id, "spam", nil); err != nil { + if err := database.BanUser(context.Background(), id, "spam", nil); err != nil { t.Fatalf("BanUser: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if !user.Banned { t.Error("Banned = false after BanUser, want true") } @@ -219,13 +220,13 @@ func TestBanUser_Permanent(t *testing.T) { func TestBanUser_Temporary(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("ivan", "hash", 4) + id, _ := database.CreateUser(context.Background(), "ivan", "hash", 4) expires := time.Now().Add(24 * time.Hour) - if err := database.BanUser(id, "temp ban", &expires); err != nil { + if err := database.BanUser(context.Background(), id, "temp ban", &expires); err != nil { t.Fatalf("BanUser (temp): %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if !user.Banned { t.Error("Banned = false after temp ban") } @@ -238,9 +239,9 @@ func TestBanUser_Temporary(t *testing.T) { func TestCreateSession_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("jack", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "jack", "hash", 4) - id, err := database.CreateSession(uid, "tokenHash1", "GoTest/1.0", "127.0.0.1") + id, err := database.CreateSession(context.Background(), uid, "tokenHash1", "GoTest/1.0", "127.0.0.1") if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -251,10 +252,10 @@ func TestCreateSession_Success(t *testing.T) { func TestGetSessionByTokenHash_Found(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("kate", "hash", 4) - _, _ = database.CreateSession(uid, "myTokenHash", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "kate", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "myTokenHash", "GoTest/1.0", "127.0.0.1") - sess, err := database.GetSessionByTokenHash("myTokenHash") + sess, err := database.GetSessionByTokenHash(context.Background(), "myTokenHash") if err != nil { t.Fatalf("GetSessionByTokenHash: %v", err) } @@ -268,7 +269,7 @@ func TestGetSessionByTokenHash_Found(t *testing.T) { func TestGetSessionByTokenHash_NotFound(t *testing.T) { database := newTestDB(t) - sess, err := database.GetSessionByTokenHash("nonexistent") + sess, err := database.GetSessionByTokenHash(context.Background(), "nonexistent") if err != nil { t.Fatalf("GetSessionByTokenHash(not found): %v", err) } @@ -279,10 +280,10 @@ func TestGetSessionByTokenHash_NotFound(t *testing.T) { func TestGetSessionWithBanStatus_Found(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("zara", "hash", 4) - _, _ = database.CreateSession(uid, "banCheckToken", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "zara", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "banCheckToken", "GoTest/1.0", "127.0.0.1") - result, err := database.GetSessionWithBanStatus("banCheckToken") + result, err := database.GetSessionWithBanStatus(context.Background(), "banCheckToken") if err != nil { t.Fatalf("GetSessionWithBanStatus: %v", err) } @@ -299,13 +300,13 @@ func TestGetSessionWithBanStatus_Found(t *testing.T) { func TestGetSessionWithBanStatus_BannedUser(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("banned-zara", "hash", 4) - _, _ = database.CreateSession(uid, "bannedToken", "GoTest/1.0", "127.0.0.1") - if err := database.BanUser(uid, "rule violation", nil); err != nil { + uid, _ := database.CreateUser(context.Background(), "banned-zara", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "bannedToken", "GoTest/1.0", "127.0.0.1") + if err := database.BanUser(context.Background(), uid, "rule violation", nil); err != nil { t.Fatalf("BanUser: %v", err) } - result, err := database.GetSessionWithBanStatus("bannedToken") + result, err := database.GetSessionWithBanStatus(context.Background(), "bannedToken") if err != nil { t.Fatalf("GetSessionWithBanStatus: %v", err) } @@ -322,7 +323,7 @@ func TestGetSessionWithBanStatus_BannedUser(t *testing.T) { func TestGetSessionWithBanStatus_NotFound(t *testing.T) { database := newTestDB(t) - result, err := database.GetSessionWithBanStatus("nonexistent") + result, err := database.GetSessionWithBanStatus(context.Background(), "nonexistent") if err != nil { t.Fatalf("GetSessionWithBanStatus(not found): %v", err) } @@ -333,13 +334,13 @@ func TestGetSessionWithBanStatus_NotFound(t *testing.T) { func TestDeleteSession(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("leo", "hash", 4) - _, _ = database.CreateSession(uid, "delToken", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "leo", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "delToken", "GoTest/1.0", "127.0.0.1") - if err := database.DeleteSession("delToken"); err != nil { + if err := database.DeleteSession(context.Background(), "delToken"); err != nil { t.Fatalf("DeleteSession: %v", err) } - sess, _ := database.GetSessionByTokenHash("delToken") + sess, _ := database.GetSessionByTokenHash(context.Background(), "delToken") if sess != nil { t.Error("Session still exists after DeleteSession") } @@ -347,12 +348,12 @@ func TestDeleteSession(t *testing.T) { func TestDeleteExpiredSessions(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("mia", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "mia", "hash", 4) // Insert an already-expired session directly via Exec. // Use SQLite datetime format (space separator) to match what datetime('now') produces. pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, uid, "expiredToken", "test", "127.0.0.1", pastTime, ) @@ -361,17 +362,17 @@ func TestDeleteExpiredSessions(t *testing.T) { } // Insert a valid session through the normal path. - _, _ = database.CreateSession(uid, "validToken", "GoTest/1.0", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, "validToken", "GoTest/1.0", "127.0.0.1") - if err := database.DeleteExpiredSessions(); err != nil { + if err := database.DeleteExpiredSessions(context.Background()); err != nil { t.Fatalf("DeleteExpiredSessions: %v", err) } - expired, _ := database.GetSessionByTokenHash("expiredToken") + expired, _ := database.GetSessionByTokenHash(context.Background(), "expiredToken") if expired != nil { t.Error("Expired session still exists after DeleteExpiredSessions") } - valid, _ := database.GetSessionByTokenHash("validToken") + valid, _ := database.GetSessionByTokenHash(context.Background(), "validToken") if valid == nil { t.Error("Valid session was deleted by DeleteExpiredSessions") } @@ -379,17 +380,17 @@ func TestDeleteExpiredSessions(t *testing.T) { func TestTouchSession(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("noah", "hash", 4) - _, _ = database.CreateSession(uid, "touchToken", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "noah", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "touchToken", "GoTest/1.0", "127.0.0.1") - sess1, _ := database.GetSessionByTokenHash("touchToken") + sess1, _ := database.GetSessionByTokenHash(context.Background(), "touchToken") time.Sleep(2 * time.Millisecond) - if err := database.TouchSession("touchToken"); err != nil { + if err := database.TouchSession(context.Background(), "touchToken"); err != nil { t.Fatalf("TouchSession: %v", err) } - sess2, _ := database.GetSessionByTokenHash("touchToken") + sess2, _ := database.GetSessionByTokenHash(context.Background(), "touchToken") if sess1.LastUsed == sess2.LastUsed { // last_used should have advanced; if they're equal the touch had no effect // (This can be flaky at millisecond resolution, but is a reasonable sanity check.) @@ -401,9 +402,9 @@ func TestTouchSession(t *testing.T) { func TestCreateInvite_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("olivia", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "olivia", "hash", 4) - code, err := database.CreateInvite(uid, 0, nil) + code, err := database.CreateInvite(context.Background(), uid, 0, nil) if err != nil { t.Fatalf("CreateInvite: %v", err) } @@ -414,10 +415,10 @@ func TestCreateInvite_Success(t *testing.T) { func TestGetInvite_Found(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("pedro", "hash", 4) - code, _ := database.CreateInvite(uid, 5, nil) + uid, _ := database.CreateUser(context.Background(), "pedro", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 5, nil) - inv, err := database.GetInvite(code) + inv, err := database.GetInvite(context.Background(), code) if err != nil { t.Fatalf("GetInvite: %v", err) } @@ -434,7 +435,7 @@ func TestGetInvite_Found(t *testing.T) { func TestGetInvite_NotFound(t *testing.T) { database := newTestDB(t) - inv, err := database.GetInvite("bogus") + inv, err := database.GetInvite(context.Background(), "bogus") if err != nil { t.Fatalf("GetInvite(not found): %v", err) } @@ -445,14 +446,14 @@ func TestGetInvite_NotFound(t *testing.T) { func TestRevokeInvite(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("uma", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) + uid, _ := database.CreateUser(context.Background(), "uma", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) - if err := database.RevokeInvite(code); err != nil { + if err := database.RevokeInvite(context.Background(), code); err != nil { t.Fatalf("RevokeInvite: %v", err) } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if !inv.Revoked { t.Error("Revoked = false after RevokeInvite, want true") } @@ -460,10 +461,10 @@ func TestRevokeInvite(t *testing.T) { func TestCreateInvite_UnlimitedUses(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("vera", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) // 0 = unlimited + uid, _ := database.CreateUser(context.Background(), "vera", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) // 0 = unlimited - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.MaxUses != nil { t.Errorf("MaxUses = %v, want nil for unlimited", inv.MaxUses) } @@ -475,14 +476,14 @@ func TestCreateInvite_UnlimitedUses(t *testing.T) { // its use_count incremented in one operation. func TestUseInviteAtomic_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user1", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user1", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) - if err := database.UseInviteAtomic(code); err != nil { + if err := database.UseInviteAtomic(context.Background(), code); err != nil { t.Fatalf("UseInviteAtomic: %v", err) } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 1 { t.Errorf("Uses = %d, want 1", inv.Uses) } @@ -492,16 +493,16 @@ func TestUseInviteAtomic_Success(t *testing.T) { // multiple sequential calls. func TestUseInviteAtomic_IncrementsUses(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user2", "hash", 4) - code, _ := database.CreateInvite(uid, 5, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user2", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 5, nil) for i := range 3 { - if err := database.UseInviteAtomic(code); err != nil { + if err := database.UseInviteAtomic(context.Background(), code); err != nil { t.Fatalf("UseInviteAtomic iteration %d: %v", i, err) } } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 3 { t.Errorf("Uses = %d, want 3", inv.Uses) } @@ -511,16 +512,16 @@ func TestUseInviteAtomic_IncrementsUses(t *testing.T) { // modifying the database. func TestUseInviteAtomic_Revoked(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user3", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) - _ = database.RevokeInvite(code) + uid, _ := database.CreateUser(context.Background(), "atomic_user3", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) + _ = database.RevokeInvite(context.Background(), code) - if err := database.UseInviteAtomic(code); err == nil { + if err := database.UseInviteAtomic(context.Background(), code); err == nil { t.Error("UseInviteAtomic returned nil error for revoked invite, want error") } // use_count must not have changed. - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 0 { t.Errorf("Uses = %d after revoked attempt, want 0", inv.Uses) } @@ -529,12 +530,12 @@ func TestUseInviteAtomic_Revoked(t *testing.T) { // TestUseInviteAtomic_Expired returns an error for an expired invite. func TestUseInviteAtomic_Expired(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user4", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "atomic_user4", "hash", 4) past := time.Now().Add(-time.Hour) - code, _ := database.CreateInvite(uid, 0, &past) + code, _ := database.CreateInvite(context.Background(), uid, 0, &past) - if err := database.UseInviteAtomic(code); err == nil { + if err := database.UseInviteAtomic(context.Background(), code); err == nil { t.Error("UseInviteAtomic returned nil error for expired invite, want error") } } @@ -543,13 +544,13 @@ func TestUseInviteAtomic_Expired(t *testing.T) { // reached its maximum use count. func TestUseInviteAtomic_ExceedsMaxUses(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user5", "hash", 4) - code, _ := database.CreateInvite(uid, 1, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user5", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 1, nil) - if err := database.UseInviteAtomic(code); err != nil { + if err := database.UseInviteAtomic(context.Background(), code); err != nil { t.Fatalf("UseInviteAtomic first use: %v", err) } - if err := database.UseInviteAtomic(code); err == nil { + if err := database.UseInviteAtomic(context.Background(), code); err == nil { t.Error("UseInviteAtomic returned nil error after exceeding max_uses, want error") } } @@ -558,7 +559,7 @@ func TestUseInviteAtomic_ExceedsMaxUses(t *testing.T) { func TestUseInviteAtomic_NotFound(t *testing.T) { database := newTestDB(t) - if err := database.UseInviteAtomic("doesnotexist"); err == nil { + if err := database.UseInviteAtomic(context.Background(), "doesnotexist"); err == nil { t.Error("UseInviteAtomic returned nil error for unknown code, want error") } } @@ -568,15 +569,15 @@ func TestUseInviteAtomic_NotFound(t *testing.T) { // fail; the use_count must end up at 1. func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user6", "hash", 4) - code, _ := database.CreateInvite(uid, 1, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user6", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 1, nil) type result struct{ err error } results := make(chan result, 2) for range 2 { go func() { - results <- result{err: database.UseInviteAtomic(code)} + results <- result{err: database.UseInviteAtomic(context.Background(), code)} }() } @@ -592,7 +593,7 @@ func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { t.Errorf("concurrent redemptions: %d succeeded, want exactly 1", successes) } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 1 { t.Errorf("use_count = %d after concurrent race, want 1", inv.Uses) } @@ -602,22 +603,22 @@ func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { func TestUnbanUser_ClearsBan(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("unban_target", "hash", 4) + id, _ := database.CreateUser(context.Background(), "unban_target", "hash", 4) - if err := database.BanUser(id, "spam", nil); err != nil { + if err := database.BanUser(context.Background(), id, "spam", nil); err != nil { t.Fatalf("BanUser: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if !user.Banned { t.Fatal("user should be banned before unban") } - if err := database.UnbanUser(id); err != nil { + if err := database.UnbanUser(context.Background(), id); err != nil { t.Fatalf("UnbanUser: %v", err) } - user, _ = database.GetUserByID(id) + user, _ = database.GetUserByID(context.Background(), id) if user.Banned { t.Error("Banned = true after UnbanUser, want false") } @@ -633,7 +634,7 @@ func TestUnbanUser_NonexistentUser(t *testing.T) { database := newTestDB(t) // Unbanning nonexistent user should not error. - if err := database.UnbanUser(99999); err != nil { + if err := database.UnbanUser(context.Background(), 99999); err != nil { t.Errorf("UnbanUser(nonexistent) error: %v", err) } } @@ -642,18 +643,18 @@ func TestUnbanUser_NonexistentUser(t *testing.T) { func TestResetAllUserStatuses(t *testing.T) { database := newTestDB(t) - id1, _ := database.CreateUser("status_u1", "hash", 4) - id2, _ := database.CreateUser("status_u2", "hash", 4) + id1, _ := database.CreateUser(context.Background(), "status_u1", "hash", 4) + id2, _ := database.CreateUser(context.Background(), "status_u2", "hash", 4) - _ = database.UpdateUserStatus(id1, "online") - _ = database.UpdateUserStatus(id2, "dnd") + _ = database.UpdateUserStatus(context.Background(), id1, "online") + _ = database.UpdateUserStatus(context.Background(), id2, "dnd") - if err := database.ResetAllUserStatuses(); err != nil { + if err := database.ResetAllUserStatuses(context.Background()); err != nil { t.Fatalf("ResetAllUserStatuses: %v", err) } - u1, _ := database.GetUserByID(id1) - u2, _ := database.GetUserByID(id2) + u1, _ := database.GetUserByID(context.Background(), id1) + u2, _ := database.GetUserByID(context.Background(), id2) if u1.Status != "offline" { t.Errorf("user1 status = %q, want 'offline'", u1.Status) } @@ -664,10 +665,10 @@ func TestResetAllUserStatuses(t *testing.T) { func TestResetAllUserStatuses_AlreadyOffline(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("offline_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "offline_user", "hash", 4) // Should not error when all users are already offline. - if err := database.ResetAllUserStatuses(); err != nil { + if err := database.ResetAllUserStatuses(context.Background()); err != nil { t.Errorf("ResetAllUserStatuses: %v", err) } } @@ -677,7 +678,7 @@ func TestResetAllUserStatuses_AlreadyOffline(t *testing.T) { func TestListMembers_Empty(t *testing.T) { database := newTestDB(t) - members, err := database.ListMembers() + members, err := database.ListMembers(context.Background()) if err != nil { t.Fatalf("ListMembers: %v", err) } @@ -688,12 +689,12 @@ func TestListMembers_Empty(t *testing.T) { func TestListMembers_ExcludesBanned(t *testing.T) { database := newTestDB(t) - id1, _ := database.CreateUser("member_visible", "hash", 4) - id2, _ := database.CreateUser("member_banned", "hash", 4) - _ = database.BanUser(id2, "test ban", nil) + id1, _ := database.CreateUser(context.Background(), "member_visible", "hash", 4) + id2, _ := database.CreateUser(context.Background(), "member_banned", "hash", 4) + _ = database.BanUser(context.Background(), id2, "test ban", nil) _ = id1 // suppress unused - members, err := database.ListMembers() + members, err := database.ListMembers(context.Background()) if err != nil { t.Fatalf("ListMembers: %v", err) } @@ -710,11 +711,11 @@ func TestListMembers_ExcludesBanned(t *testing.T) { func TestListMembers_SortedByUsername(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("zeta_user", "hash", 4) - _, _ = database.CreateUser("alpha_user", "hash", 4) - _, _ = database.CreateUser("mid_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "zeta_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "alpha_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "mid_user", "hash", 4) - members, err := database.ListMembers() + members, err := database.ListMembers(context.Background()) if err != nil { t.Fatalf("ListMembers: %v", err) } diff --git a/Server/db/backup_test.go b/Server/db/backup_test.go index 5dd45586..1ef48c02 100644 --- a/Server/db/backup_test.go +++ b/Server/db/backup_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "os" "path/filepath" "testing" @@ -43,7 +44,7 @@ func TestBackupToSafe_ValidPath(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } backupPath := filepath.Join(backupDir, "chatserver_20260315_120000.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() with valid path returned error: %v", err) } @@ -67,7 +68,7 @@ func TestBackupToSafe_RejectsPathOutsideRoot(t *testing.T) { } // Try to write outside backupDir escapePath := filepath.Join(tmpDir, "escaped.db") - err := database.BackupToSafe(escapePath, backupDir) + err := database.BackupToSafe(context.Background(), escapePath, backupDir) if err == nil { t.Error("BackupToSafe() should reject path outside safe root, got nil") } @@ -83,7 +84,7 @@ func TestBackupToSafe_RejectsSingleQuote(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil'.db") - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with single-quote in path should return error, got nil") } @@ -98,7 +99,7 @@ func TestBackupToSafe_RejectsSemicolon(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil;drop.db") - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with semicolon in path should return error, got nil") } @@ -113,7 +114,7 @@ func TestBackupToSafe_RejectsSQLComment(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil--comment.db") - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with '--' in path should return error, got nil") } @@ -128,7 +129,7 @@ func TestBackupToSafe_RejectsNullByte(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil\x00.db") //nolint:gocritic // intentional null byte for security test - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with null byte in path should return error, got nil") } @@ -144,7 +145,7 @@ func TestBackupToSafe_RejectsDoubleQuote(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, `evil".db`) - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with double-quote in path should return error, got nil") } diff --git a/Server/db/block_queries.go b/Server/db/block_queries.go index eea5ae86..4d97a704 100644 --- a/Server/db/block_queries.go +++ b/Server/db/block_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "errors" "fmt" @@ -11,8 +12,8 @@ import ( // BlockUser adds a block from blocker to blocked. Idempotent — re-blocking // a user that is already blocked is a no-op (INSERT OR IGNORE). -func (d *DB) BlockUser(blockerID, blockedID int64) error { - if err := d.q.BlockUser(dbCtx(), dbgen.BlockUserParams{ +func (d *DB) BlockUser(ctx context.Context, blockerID, blockedID int64) error { + if err := d.q.BlockUser(ctx, dbgen.BlockUserParams{ BlockerID: blockerID, BlockedID: blockedID, }); err != nil { @@ -23,8 +24,8 @@ func (d *DB) BlockUser(blockerID, blockedID int64) error { // UnblockUser removes a block. Idempotent — unblocking a non-blocked user is // a no-op. -func (d *DB) UnblockUser(blockerID, blockedID int64) error { - if err := d.q.UnblockUser(dbCtx(), dbgen.UnblockUserParams{ +func (d *DB) UnblockUser(ctx context.Context, blockerID, blockedID int64) error { + if err := d.q.UnblockUser(ctx, dbgen.UnblockUserParams{ BlockerID: blockerID, BlockedID: blockedID, }); err != nil { @@ -34,8 +35,8 @@ func (d *DB) UnblockUser(blockerID, blockedID int64) error { } // IsBlocked returns true if blockerID has blocked blockedID. -func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) { - _, err := d.q.IsBlocked(dbCtx(), dbgen.IsBlockedParams{ +func (d *DB) IsBlocked(ctx context.Context, blockerID, blockedID int64) (bool, error) { + _, err := d.q.IsBlocked(ctx, dbgen.IsBlockedParams{ BlockerID: blockerID, BlockedID: blockedID, }) @@ -51,8 +52,8 @@ func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) { // IsEitherBlocked returns true if either user has blocked the other. // Used for DM authorization — if either party has blocked the other, // messaging is denied. -func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) { - _, err := d.q.IsEitherBlocked(dbCtx(), dbgen.IsEitherBlockedParams{ +func (d *DB) IsEitherBlocked(ctx context.Context, userA, userB int64) (bool, error) { + _, err := d.q.IsEitherBlocked(ctx, dbgen.IsEitherBlockedParams{ BlockerID: userA, BlockedID: userB, BlockerID_2: userB, @@ -68,8 +69,8 @@ func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) { } // ListBlockedUsers returns the IDs of all users blocked by the given user. -func (d *DB) ListBlockedUsers(blockerID int64) ([]int64, error) { - ids, err := d.q.ListBlockedUsers(dbCtx(), blockerID) +func (d *DB) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) { + ids, err := d.q.ListBlockedUsers(ctx, blockerID) if err != nil { return nil, fmt.Errorf("ListBlockedUsers: %w", err) } diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index 1350b227..828c50aa 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -47,21 +48,21 @@ func channelFromFields(f channelFields) Channel { } // ListChannels returns all channels ordered by position. -func (d *DB) ListChannels() ([]Channel, error) { - rows, err := d.q.ListChannels(dbCtx()) +func (d *DB) ListChannels(ctx context.Context) ([]Channel, error) { + rows, err := d.q.ListChannels(ctx) if err != nil { return nil, fmt.Errorf("ListChannels: %w", err) } channels := make([]Channel, 0, len(rows)) - for _, r := range rows { - channels = append(channels, channelFromFields(channelFields(r))) + for i := range rows { + channels = append(channels, channelFromFields(channelFields(rows[i]))) } return channels, nil } // GetChannel returns the channel with the given id, or nil if not found. -func (d *DB) GetChannel(id int64) (*Channel, error) { - r, err := d.q.GetChannel(dbCtx(), id) +func (d *DB) GetChannel(ctx context.Context, id int64) (*Channel, error) { + r, err := d.q.GetChannel(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -73,8 +74,8 @@ func (d *DB) GetChannel(id int64) (*Channel, error) { } // CreateChannel inserts a new channel and returns the assigned ID. -func (d *DB) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { - res, err := d.q.CreateChannel(dbCtx(), dbgen.CreateChannelParams{ +func (d *DB) CreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) { + res, err := d.q.CreateChannel(ctx, dbgen.CreateChannelParams{ Name: name, Type: chanType, Category: strToNullPtr(category), @@ -88,8 +89,8 @@ func (d *DB) CreateChannel(name, chanType, category, topic string, position int) } // UpdateChannel modifies name, topic, and slow_mode for the given channel. -func (d *DB) UpdateChannel(id int64, name, topic string, slowMode int) error { - if err := d.q.UpdateChannel(dbCtx(), dbgen.UpdateChannelParams{ +func (d *DB) UpdateChannel(ctx context.Context, id int64, name, topic string, slowMode int) error { + if err := d.q.UpdateChannel(ctx, dbgen.UpdateChannelParams{ Name: name, Topic: strToNullPtr(topic), SlowMode: int64(slowMode), @@ -101,8 +102,8 @@ func (d *DB) UpdateChannel(id int64, name, topic string, slowMode int) error { } // SetChannelSlowMode updates only the slow_mode field for the given channel. -func (d *DB) SetChannelSlowMode(id int64, slowMode int) error { - if err := d.q.SetChannelSlowMode(dbCtx(), dbgen.SetChannelSlowModeParams{ +func (d *DB) SetChannelSlowMode(ctx context.Context, id int64, slowMode int) error { + if err := d.q.SetChannelSlowMode(ctx, dbgen.SetChannelSlowModeParams{ SlowMode: int64(slowMode), ID: id, }); err != nil { @@ -112,8 +113,8 @@ func (d *DB) SetChannelSlowMode(id int64, slowMode int) error { } // SetChannelVoiceMaxUsers updates the voice_max_users field for the given channel. -func (d *DB) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { - if err := d.q.SetChannelVoiceMaxUsers(dbCtx(), dbgen.SetChannelVoiceMaxUsersParams{ +func (d *DB) SetChannelVoiceMaxUsers(ctx context.Context, id int64, maxUsers int) error { + if err := d.q.SetChannelVoiceMaxUsers(ctx, dbgen.SetChannelVoiceMaxUsersParams{ VoiceMaxUsers: int64(maxUsers), ID: id, }); err != nil { @@ -123,8 +124,8 @@ func (d *DB) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { } // DeleteChannel removes the channel row (cascades to messages, overrides, etc.). -func (d *DB) DeleteChannel(id int64) error { - if err := d.q.DeleteChannel(dbCtx(), id); err != nil { +func (d *DB) DeleteChannel(ctx context.Context, id int64) error { + if err := d.q.DeleteChannel(ctx, id); err != nil { return fmt.Errorf("DeleteChannel: %w", err) } return nil @@ -132,8 +133,8 @@ func (d *DB) DeleteChannel(id int64) error { // GetChannelPermissions returns the allow/deny override bits for a role on a // channel. Returns (0, 0, nil) when no override exists. -func (d *DB) GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) { - r, scanErr := d.q.GetChannelPermission(dbCtx(), dbgen.GetChannelPermissionParams{ +func (d *DB) GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) { + r, scanErr := d.q.GetChannelPermission(ctx, dbgen.GetChannelPermissionParams{ ChannelID: channelID, RoleID: roleID, }) @@ -155,8 +156,8 @@ type ChannelOverride struct { // GetAllChannelPermissionsForRole returns all channel permission overrides for // a role in a single query, keyed by channel ID. Eliminates N+1 queries when // filtering channels by permission. -func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOverride, error) { - rows, err := d.q.GetRoleChannelPermissions(dbCtx(), roleID) +func (d *DB) GetAllChannelPermissionsForRole(ctx context.Context, roleID int64) (map[int64]ChannelOverride, error) { + rows, err := d.q.GetRoleChannelPermissions(ctx, roleID) if err != nil { return nil, fmt.Errorf("GetAllChannelPermissionsForRole: %w", err) } @@ -169,8 +170,8 @@ func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOve // UpsertChannelOverride inserts or updates the allow/deny permission override // for a role on a channel. -func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error { - if err := d.q.UpsertChannelPermission(dbCtx(), dbgen.UpsertChannelPermissionParams{ +func (d *DB) UpsertChannelOverride(ctx context.Context, channelID, roleID, allow, deny int64) error { + if err := d.q.UpsertChannelPermission(ctx, dbgen.UpsertChannelPermissionParams{ ChannelID: channelID, RoleID: roleID, Allow: allow, @@ -183,8 +184,8 @@ func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error { // DeleteChannelOverride removes the permission override for a role on a // channel. Deleting a non-existent override is a no-op. -func (d *DB) DeleteChannelOverride(channelID, roleID int64) error { - if err := d.q.DeleteChannelPermission(dbCtx(), dbgen.DeleteChannelPermissionParams{ +func (d *DB) DeleteChannelOverride(ctx context.Context, channelID, roleID int64) error { + if err := d.q.DeleteChannelPermission(ctx, dbgen.DeleteChannelPermissionParams{ ChannelID: channelID, RoleID: roleID, }); err != nil { @@ -208,8 +209,8 @@ type ChannelRoleOverride struct { // ListChannelRoleOverrides returns every role together with its override bits // on the given channel (zero allow/deny when no override row exists), ordered // by role position descending. -func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, error) { - rows, err := d.sqlDB.Query( +func (d *DB) ListChannelRoleOverrides(ctx context.Context, channelID int64) ([]ChannelRoleOverride, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT r.id, r.name, r.position, r.permissions, COALESCE(o.allow, 0), COALESCE(o.deny, 0) FROM roles r @@ -243,7 +244,7 @@ func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, e // GetChannelTypes returns a map of channel ID → type string for the given IDs // in a single query, avoiding N+1 lookups. -func (d *DB) GetChannelTypes(ids []int64) (map[int64]string, error) { +func (d *DB) GetChannelTypes(ctx context.Context, ids []int64) (map[int64]string, error) { if len(ids) == 0 { return map[int64]string{}, nil } @@ -261,7 +262,7 @@ func (d *DB) GetChannelTypes(ids []int64) (map[int64]string, error) { strings.Join(placeholders, ","), ) - rows, err := d.sqlDB.Query(query, args...) + rows, err := d.sqlDB.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("GetChannelTypes query: %w", err) } diff --git a/Server/db/channel_queries_test.go b/Server/db/channel_queries_test.go index eabd8d07..dba7aea9 100644 --- a/Server/db/channel_queries_test.go +++ b/Server/db/channel_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "github.com/owncord/server/db" @@ -21,7 +22,7 @@ func openMigratedMemory(t *testing.T) *db.DB { func TestListChannels_Empty(t *testing.T) { database := openMigratedMemory(t) - channels, err := database.ListChannels() + channels, err := database.ListChannels(context.Background()) if err != nil { t.Fatalf("ListChannels() error: %v", err) } @@ -33,14 +34,14 @@ func TestListChannels_Empty(t *testing.T) { func TestListChannels_ReturnsAll(t *testing.T) { database := openMigratedMemory(t) - if _, err := database.CreateChannel("general", "text", "", "General chat", 0); err != nil { + if _, err := database.CreateChannel(context.Background(), "general", "text", "", "General chat", 0); err != nil { t.Fatalf("CreateChannel general: %v", err) } - if _, err := database.CreateChannel("announcements", "text", "", "", 1); err != nil { + if _, err := database.CreateChannel(context.Background(), "announcements", "text", "", "", 1); err != nil { t.Fatalf("CreateChannel announcements: %v", err) } - channels, err := database.ListChannels() + channels, err := database.ListChannels(context.Background()) if err != nil { t.Fatalf("ListChannels() error: %v", err) } @@ -54,7 +55,7 @@ func TestListChannels_ReturnsAll(t *testing.T) { func TestGetChannel_NotFound(t *testing.T) { database := openMigratedMemory(t) - ch, err := database.GetChannel(9999) + ch, err := database.GetChannel(context.Background(), 9999) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -66,12 +67,12 @@ func TestGetChannel_NotFound(t *testing.T) { func TestGetChannel_Found(t *testing.T) { database := openMigratedMemory(t) - id, err := database.CreateChannel("general", "text", "Public", "hello", 0) + id, err := database.CreateChannel(context.Background(), "general", "text", "Public", "hello", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel: %v", err) } @@ -100,7 +101,7 @@ func TestGetChannel_Found(t *testing.T) { func TestCreateChannel_ReturnsID(t *testing.T) { database := openMigratedMemory(t) - id, err := database.CreateChannel("test", "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), "test", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -112,8 +113,8 @@ func TestCreateChannel_ReturnsID(t *testing.T) { func TestCreateChannel_UniqueIDs(t *testing.T) { database := openMigratedMemory(t) - id1, _ := database.CreateChannel("ch1", "text", "", "", 0) - id2, _ := database.CreateChannel("ch2", "text", "", "", 1) + id1, _ := database.CreateChannel(context.Background(), "ch1", "text", "", "", 0) + id2, _ := database.CreateChannel(context.Background(), "ch2", "text", "", "", 1) if id1 == id2 { t.Error("expected different IDs for different channels") } @@ -122,11 +123,11 @@ func TestCreateChannel_UniqueIDs(t *testing.T) { func TestCreateChannel_EmptyCategory(t *testing.T) { database := openMigratedMemory(t) - id, err := database.CreateChannel("nocategory", "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), "nocategory", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel with empty category: %v", err) } - ch, _ := database.GetChannel(id) + ch, _ := database.GetChannel(context.Background(), id) if ch.Category != "" { t.Errorf("Category = %q, want ''", ch.Category) } @@ -137,13 +138,13 @@ func TestCreateChannel_EmptyCategory(t *testing.T) { func TestUpdateChannel_ChangesNameAndTopic(t *testing.T) { database := openMigratedMemory(t) - id, _ := database.CreateChannel("old", "text", "", "old topic", 0) + id, _ := database.CreateChannel(context.Background(), "old", "text", "", "old topic", 0) - if err := database.UpdateChannel(id, "new", "new topic", 5); err != nil { + if err := database.UpdateChannel(context.Background(), id, "new", "new topic", 5); err != nil { t.Fatalf("UpdateChannel: %v", err) } - ch, _ := database.GetChannel(id) + ch, _ := database.GetChannel(context.Background(), id) if ch.Name != "new" { t.Errorf("Name = %q, want 'new'", ch.Name) } @@ -158,7 +159,7 @@ func TestUpdateChannel_ChangesNameAndTopic(t *testing.T) { func TestUpdateChannel_NonExistent(t *testing.T) { database := openMigratedMemory(t) // Should not error even for non-existent row (0 rows affected is still ok). - err := database.UpdateChannel(9999, "x", "y", 0) + err := database.UpdateChannel(context.Background(), 9999, "x", "y", 0) if err != nil { t.Errorf("UpdateChannel non-existent should not error: %v", err) } @@ -169,13 +170,13 @@ func TestUpdateChannel_NonExistent(t *testing.T) { func TestDeleteChannel_RemovesChannel(t *testing.T) { database := openMigratedMemory(t) - id, _ := database.CreateChannel("todelete", "text", "", "", 0) + id, _ := database.CreateChannel(context.Background(), "todelete", "text", "", "", 0) - if err := database.DeleteChannel(id); err != nil { + if err := database.DeleteChannel(context.Background(), id); err != nil { t.Fatalf("DeleteChannel: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel after delete: %v", err) } @@ -186,7 +187,7 @@ func TestDeleteChannel_RemovesChannel(t *testing.T) { func TestDeleteChannel_NonExistent(t *testing.T) { database := openMigratedMemory(t) - err := database.DeleteChannel(9999) + err := database.DeleteChannel(context.Background(), 9999) if err != nil { t.Errorf("DeleteChannel non-existent should not error: %v", err) } @@ -197,10 +198,10 @@ func TestDeleteChannel_NonExistent(t *testing.T) { func TestGetChannelPermissions_Default(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("perms", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "perms", "text", "", "", 0) // No override set — should return 0, 0. - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -212,9 +213,9 @@ func TestGetChannelPermissions_Default(t *testing.T) { func TestGetChannelPermissions_WithOverride(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("perms2", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "perms2", "text", "", "", 0) // Insert an override directly. - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, chID, 4, int64(0x400), int64(0x200), ) @@ -222,7 +223,7 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) { t.Fatalf("insert override: %v", err) } - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -238,12 +239,12 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) { func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("private", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "private", "text", "", "", 0) - if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0, 0x202); err != nil { t.Fatalf("UpsertChannelOverride insert: %v", err) } - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -252,10 +253,10 @@ func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { } // Upsert again with different bits — must update, not duplicate. - if err := database.UpsertChannelOverride(chID, 4, 0x2, 0x200); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0x2, 0x200); err != nil { t.Fatalf("UpsertChannelOverride update: %v", err) } - allow, deny, err = database.GetChannelPermissions(chID, 4) + allow, deny, err = database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -266,15 +267,15 @@ func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { func TestDeleteChannelOverride(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("private2", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "private2", "text", "", "", 0) - if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0, 0x202); err != nil { t.Fatalf("UpsertChannelOverride: %v", err) } - if err := database.DeleteChannelOverride(chID, 4); err != nil { + if err := database.DeleteChannelOverride(context.Background(), chID, 4); err != nil { t.Fatalf("DeleteChannelOverride: %v", err) } - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -283,20 +284,20 @@ func TestDeleteChannelOverride(t *testing.T) { } // Deleting again is a no-op. - if err := database.DeleteChannelOverride(chID, 4); err != nil { + if err := database.DeleteChannelOverride(context.Background(), chID, 4); err != nil { t.Errorf("DeleteChannelOverride non-existent should not error: %v", err) } } func TestListChannelRoleOverrides(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("private3", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "private3", "text", "", "", 0) - if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0, 0x202); err != nil { t.Fatalf("UpsertChannelOverride: %v", err) } - overrides, err := database.ListChannelRoleOverrides(chID) + overrides, err := database.ListChannelRoleOverrides(context.Background(), chID) if err != nil { t.Fatalf("ListChannelRoleOverrides: %v", err) } @@ -328,13 +329,13 @@ func TestListChannelRoleOverrides(t *testing.T) { func TestSetChannelSlowMode(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("slowch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "slowch", "text", "", "", 0) - if err := database.SetChannelSlowMode(chID, 10); err != nil { + if err := database.SetChannelSlowMode(context.Background(), chID, 10); err != nil { t.Fatalf("SetChannelSlowMode: %v", err) } - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.SlowMode != 10 { t.Errorf("SlowMode = %d, want 10", ch.SlowMode) } @@ -342,12 +343,12 @@ func TestSetChannelSlowMode(t *testing.T) { func TestSetChannelSlowMode_Zero(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("slowch2", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "slowch2", "text", "", "", 0) - _ = database.SetChannelSlowMode(chID, 30) - _ = database.SetChannelSlowMode(chID, 0) + _ = database.SetChannelSlowMode(context.Background(), chID, 30) + _ = database.SetChannelSlowMode(context.Background(), chID, 0) - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.SlowMode != 0 { t.Errorf("SlowMode = %d, want 0 (disabled)", ch.SlowMode) } @@ -357,13 +358,13 @@ func TestSetChannelSlowMode_Zero(t *testing.T) { func TestSetChannelVoiceMaxUsers(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("voicech", "voice", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "voicech", "voice", "", "", 0) - if err := database.SetChannelVoiceMaxUsers(chID, 25); err != nil { + if err := database.SetChannelVoiceMaxUsers(context.Background(), chID, 25); err != nil { t.Fatalf("SetChannelVoiceMaxUsers: %v", err) } - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.VoiceMaxUsers != 25 { t.Errorf("VoiceMaxUsers = %d, want 25", ch.VoiceMaxUsers) } @@ -371,12 +372,12 @@ func TestSetChannelVoiceMaxUsers(t *testing.T) { func TestSetChannelVoiceMaxUsers_Unlimited(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("voicech2", "voice", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "voicech2", "voice", "", "", 0) - _ = database.SetChannelVoiceMaxUsers(chID, 10) - _ = database.SetChannelVoiceMaxUsers(chID, 0) + _ = database.SetChannelVoiceMaxUsers(context.Background(), chID, 10) + _ = database.SetChannelVoiceMaxUsers(context.Background(), chID, 0) - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.VoiceMaxUsers != 0 { t.Errorf("VoiceMaxUsers = %d, want 0 (unlimited)", ch.VoiceMaxUsers) } diff --git a/Server/db/coverage_boost_test.go b/Server/db/coverage_boost_test.go index 6202bafc..c4cea5ab 100644 --- a/Server/db/coverage_boost_test.go +++ b/Server/db/coverage_boost_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "time" @@ -14,12 +15,12 @@ func TestVoice_JoinVoiceChannelIfCapacity_UnderLimit(t *testing.T) { u1 := seedVoiceUser(t, database, "cap-u1") chanID := seedVoiceChannel(t, database, "cap-ch") - err := database.JoinVoiceChannelIfCapacity(u1, chanID, 2) + err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2) if err != nil { t.Fatalf("JoinVoiceChannelIfCapacity: %v", err) } - state, err := database.GetVoiceState(u1) + state, err := database.GetVoiceState(context.Background(), u1) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -39,15 +40,15 @@ func TestVoice_JoinVoiceChannelIfCapacity_AtLimit(t *testing.T) { chanID := seedVoiceChannel(t, database, "cap-full-ch") // Fill channel to capacity (max 2). - if err := database.JoinVoiceChannelIfCapacity(u1, chanID, 2); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2); err != nil { t.Fatalf("first join: %v", err) } - if err := database.JoinVoiceChannelIfCapacity(u2, chanID, 2); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u2, chanID, 2); err != nil { t.Fatalf("second join: %v", err) } // Third join should fail with ErrChannelFull. - err := database.JoinVoiceChannelIfCapacity(u3, chanID, 2) + err := database.JoinVoiceChannelIfCapacity(context.Background(), u3, chanID, 2) if err == nil { t.Fatal("expected ErrChannelFull, got nil") } @@ -63,14 +64,14 @@ func TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(t *testing.T) { ch2 := seedVoiceChannel(t, database, "cap-ch2") // Join ch1, then join ch2 with capacity check — should replace. - if err := database.JoinVoiceChannelIfCapacity(u1, ch1, 5); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch1, 5); err != nil { t.Fatalf("join ch1: %v", err) } - if err := database.JoinVoiceChannelIfCapacity(u1, ch2, 5); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch2, 5); err != nil { t.Fatalf("join ch2: %v", err) } - state, _ := database.GetVoiceState(u1) + state, _ := database.GetVoiceState(context.Background(), u1) if state == nil || state.ChannelID != ch2 { t.Errorf("expected channel %d, got %v", ch2, state) } @@ -81,7 +82,7 @@ func TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(t *testing.T) { func TestVoice_GetAllVoiceStates_Empty(t *testing.T) { database := newVoiceTestDB(t) - states, err := database.GetAllVoiceStates() + states, err := database.GetAllVoiceStates(context.Background()) if err != nil { t.Fatalf("GetAllVoiceStates: %v", err) } @@ -98,11 +99,11 @@ func TestVoice_GetAllVoiceStates_MultipleChannels(t *testing.T) { ch1 := seedVoiceChannel(t, database, "all-vs-ch1") ch2 := seedVoiceChannel(t, database, "all-vs-ch2") - _ = database.JoinVoiceChannel(u1, ch1) - _ = database.JoinVoiceChannel(u2, ch1) - _ = database.JoinVoiceChannel(u3, ch2) + _ = database.JoinVoiceChannel(context.Background(), u1, ch1) + _ = database.JoinVoiceChannel(context.Background(), u2, ch1) + _ = database.JoinVoiceChannel(context.Background(), u3, ch2) - states, err := database.GetAllVoiceStates() + states, err := database.GetAllVoiceStates(context.Background()) if err != nil { t.Fatalf("GetAllVoiceStates: %v", err) } @@ -117,7 +118,7 @@ func TestVoice_CountActiveCameras_Zero(t *testing.T) { database := newVoiceTestDB(t) chanID := seedVoiceChannel(t, database, "cam-count-empty") - count, err := database.CountActiveCameras(chanID) + count, err := database.CountActiveCameras(context.Background(), chanID) if err != nil { t.Fatalf("CountActiveCameras: %v", err) } @@ -133,15 +134,15 @@ func TestVoice_CountActiveCameras_SomeCameras(t *testing.T) { u3 := seedVoiceUser(t, database, "cam-cnt-u3") chanID := seedVoiceChannel(t, database, "cam-cnt-ch") - _ = database.JoinVoiceChannel(u1, chanID) - _ = database.JoinVoiceChannel(u2, chanID) - _ = database.JoinVoiceChannel(u3, chanID) + _ = database.JoinVoiceChannel(context.Background(), u1, chanID) + _ = database.JoinVoiceChannel(context.Background(), u2, chanID) + _ = database.JoinVoiceChannel(context.Background(), u3, chanID) - _ = database.UpdateVoiceCamera(u1, true) - _ = database.UpdateVoiceCamera(u2, true) + _ = database.UpdateVoiceCamera(context.Background(), u1, true) + _ = database.UpdateVoiceCamera(context.Background(), u2, true) // u3 camera stays off. - count, err := database.CountActiveCameras(chanID) + count, err := database.CountActiveCameras(context.Background(), chanID) if err != nil { t.Fatalf("CountActiveCameras: %v", err) } @@ -157,9 +158,9 @@ func TestVoice_EnableCameraIfUnderLimit_Success(t *testing.T) { u1 := seedVoiceUser(t, database, "cam-limit-ok") chanID := seedVoiceChannel(t, database, "cam-limit-ch") - _ = database.JoinVoiceChannel(u1, chanID) + _ = database.JoinVoiceChannel(context.Background(), u1, chanID) - ok, err := database.EnableCameraIfUnderLimit(u1, chanID, 2) + ok, err := database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2) if err != nil { t.Fatalf("EnableCameraIfUnderLimit: %v", err) } @@ -167,7 +168,7 @@ func TestVoice_EnableCameraIfUnderLimit_Success(t *testing.T) { t.Error("expected camera to be enabled") } - state, _ := database.GetVoiceState(u1) + state, _ := database.GetVoiceState(context.Background(), u1) if state == nil || !state.Camera { t.Error("camera should be true after enable") } @@ -180,16 +181,16 @@ func TestVoice_EnableCameraIfUnderLimit_AtLimit(t *testing.T) { u3 := seedVoiceUser(t, database, "cam-lim-u3") chanID := seedVoiceChannel(t, database, "cam-lim-ch") - _ = database.JoinVoiceChannel(u1, chanID) - _ = database.JoinVoiceChannel(u2, chanID) - _ = database.JoinVoiceChannel(u3, chanID) + _ = database.JoinVoiceChannel(context.Background(), u1, chanID) + _ = database.JoinVoiceChannel(context.Background(), u2, chanID) + _ = database.JoinVoiceChannel(context.Background(), u3, chanID) // Enable cameras for u1 and u2 (max is 2). - _, _ = database.EnableCameraIfUnderLimit(u1, chanID, 2) - _, _ = database.EnableCameraIfUnderLimit(u2, chanID, 2) + _, _ = database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2) + _, _ = database.EnableCameraIfUnderLimit(context.Background(), u2, chanID, 2) // u3 should be denied. - ok, err := database.EnableCameraIfUnderLimit(u3, chanID, 2) + ok, err := database.EnableCameraIfUnderLimit(context.Background(), u3, chanID, 2) if err != nil { t.Fatalf("EnableCameraIfUnderLimit: %v", err) } @@ -207,12 +208,12 @@ func TestSearchMessagesInChannels_FindsInAllowedChannels(t *testing.T) { ch2 := seedChannel(t, database, "srch-ch2") ch3 := seedChannel(t, database, "srch-ch3") - _, _ = database.CreateMessage(ch1, userID, "alpha keyword here", nil) - _, _ = database.CreateMessage(ch2, userID, "beta keyword here", nil) - _, _ = database.CreateMessage(ch3, userID, "gamma keyword here", nil) + _, _ = database.CreateMessage(context.Background(), ch1, userID, "alpha keyword here", nil) + _, _ = database.CreateMessage(context.Background(), ch2, userID, "beta keyword here", nil) + _, _ = database.CreateMessage(context.Background(), ch3, userID, "gamma keyword here", nil) // Search only in ch1 and ch2. - results, err := database.SearchMessagesInChannels("keyword", []int64{ch1, ch2}, 10) + results, err := database.SearchMessagesInChannels(context.Background(), "keyword", []int64{ch1, ch2}, 10) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -229,7 +230,7 @@ func TestSearchMessagesInChannels_FindsInAllowedChannels(t *testing.T) { func TestSearchMessagesInChannels_EmptyQuery(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessagesInChannels("", []int64{1}, 10) + results, err := database.SearchMessagesInChannels(context.Background(), "", []int64{1}, 10) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -241,7 +242,7 @@ func TestSearchMessagesInChannels_EmptyQuery(t *testing.T) { func TestSearchMessagesInChannels_EmptyChannelIDs(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessagesInChannels("test", nil, 10) + results, err := database.SearchMessagesInChannels(context.Background(), "test", nil, 10) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -256,10 +257,10 @@ func TestSearchMessagesInChannels_LimitRespected(t *testing.T) { ch1 := seedChannel(t, database, "srch-lim-ch") for range 5 { - _, _ = database.CreateMessage(ch1, userID, "findme content here", nil) + _, _ = database.CreateMessage(context.Background(), ch1, userID, "findme content here", nil) } - results, err := database.SearchMessagesInChannels("findme", []int64{ch1}, 2) + results, err := database.SearchMessagesInChannels(context.Background(), "findme", []int64{ch1}, 2) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -271,7 +272,7 @@ func TestSearchMessagesInChannels_LimitRespected(t *testing.T) { func TestSearchMessagesInChannels_ZeroLimit(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessagesInChannels("test", []int64{1}, 0) + results, err := database.SearchMessagesInChannels(context.Background(), "test", []int64{1}, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -287,7 +288,7 @@ func TestGetPinnedMessages_Empty(t *testing.T) { userID := seedUser(t, database, "pin-empty-u") chID := seedChannel(t, database, "pin-empty") - msgs, err := database.GetPinnedMessages(chID, userID) + msgs, err := database.GetPinnedMessages(context.Background(), chID, userID) if err != nil { t.Fatalf("GetPinnedMessages: %v", err) } @@ -301,11 +302,11 @@ func TestGetPinnedMessages_ReturnsPinnedOnly(t *testing.T) { userID := seedUser(t, database, "pin-user") chID := seedChannel(t, database, "pin-ch") - id1, _ := database.CreateMessage(chID, userID, "pinned msg", nil) - _, _ = database.CreateMessage(chID, userID, "not pinned", nil) - _ = database.SetMessagePinned(id1, true) + id1, _ := database.CreateMessage(context.Background(), chID, userID, "pinned msg", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "not pinned", nil) + _ = database.SetMessagePinned(context.Background(), id1, true) - msgs, err := database.GetPinnedMessages(chID, userID) + msgs, err := database.GetPinnedMessages(context.Background(), chID, userID) if err != nil { t.Fatalf("GetPinnedMessages: %v", err) } @@ -327,13 +328,13 @@ func TestSetMessagePinned_Pin(t *testing.T) { userID := seedUser(t, database, "setpin-u") chID := seedChannel(t, database, "setpin-ch") - id, _ := database.CreateMessage(chID, userID, "to pin", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "to pin", nil) - if err := database.SetMessagePinned(id, true); err != nil { + if err := database.SetMessagePinned(context.Background(), id, true); err != nil { t.Fatalf("SetMessagePinned(true): %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg == nil || !msg.Pinned { t.Error("message should be pinned") } @@ -344,13 +345,13 @@ func TestSetMessagePinned_Unpin(t *testing.T) { userID := seedUser(t, database, "unpin-u") chID := seedChannel(t, database, "unpin-ch") - id, _ := database.CreateMessage(chID, userID, "to unpin", nil) - _ = database.SetMessagePinned(id, true) - if err := database.SetMessagePinned(id, false); err != nil { + id, _ := database.CreateMessage(context.Background(), chID, userID, "to unpin", nil) + _ = database.SetMessagePinned(context.Background(), id, true) + if err := database.SetMessagePinned(context.Background(), id, false); err != nil { t.Fatalf("SetMessagePinned(false): %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg == nil || msg.Pinned { t.Error("message should not be pinned") } @@ -359,7 +360,7 @@ func TestSetMessagePinned_Unpin(t *testing.T) { func TestSetMessagePinned_NotFound(t *testing.T) { database := openMigratedMemory(t) - err := database.SetMessagePinned(99999, true) + err := database.SetMessagePinned(context.Background(), 99999, true) if err == nil { t.Error("expected error for non-existent message") } @@ -370,10 +371,10 @@ func TestSetMessagePinned_DeletedMessage(t *testing.T) { userID := seedUser(t, database, "pin-del-u") chID := seedChannel(t, database, "pin-del-ch") - id, _ := database.CreateMessage(chID, userID, "deleted", nil) - _ = database.DeleteMessage(id, userID, false) + id, _ := database.CreateMessage(context.Background(), chID, userID, "deleted", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) - err := database.SetMessagePinned(id, true) + err := database.SetMessagePinned(context.Background(), id, true) if err == nil { t.Error("expected error when pinning deleted message") } @@ -385,12 +386,12 @@ func TestCreateAttachment_Success(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "att-uploader") - err := database.CreateAttachment("att-001", userID, "photo.png", "stored-001.png", "image/png", 12345, nil, nil) + err := database.CreateAttachment(context.Background(), "att-001", userID, "photo.png", "stored-001.png", "image/png", 12345, nil, nil) if err != nil { t.Fatalf("CreateAttachment: %v", err) } - att, err := database.GetAttachmentByID("att-001") + att, err := database.GetAttachmentByID(context.Background(), "att-001") if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -413,12 +414,12 @@ func TestCreateAttachment_WithDimensions(t *testing.T) { userID := seedUser(t, database, "att-dim-uploader") w, h := 1920, 1080 - err := database.CreateAttachment("att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h) + err := database.CreateAttachment(context.Background(), "att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h) if err != nil { t.Fatalf("CreateAttachment with dims: %v", err) } - att, _ := database.GetAttachmentByID("att-dim") + att, _ := database.GetAttachmentByID(context.Background(), "att-dim") if att == nil { t.Fatal("expected attachment") } @@ -431,10 +432,10 @@ func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) { userID := seedUser(t, database, "orphan-uploader") // Create an unlinked attachment (message_id IS NULL). - _ = database.CreateAttachment("orphan-1", userID, "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil) + _ = database.CreateAttachment(context.Background(), "orphan-1", userID, "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil) // Use a cutoff far in the future so the attachment is considered old. - files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), "2099-01-01T00:00:00Z") if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -446,7 +447,7 @@ func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) { } // Should be removed from DB. - att, _ := database.GetAttachmentByID("orphan-1") + att, _ := database.GetAttachmentByID(context.Background(), "orphan-1") if att != nil { t.Error("orphaned attachment should be deleted from DB") } @@ -458,11 +459,11 @@ func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) { chID := seedChannel(t, database, "orphan-linked-ch") // Create attachment and link it to a message. - _ = database.CreateAttachment("linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil) - msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil) - _, _ = database.LinkAttachmentsToMessage(msgID, userID, []string{"linked-1"}) + _ = database.CreateAttachment(context.Background(), "linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil) + _, _ = database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"linked-1"}) - files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), "2099-01-01T00:00:00Z") if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -475,10 +476,10 @@ func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "cutoff-uploader") - _ = database.CreateAttachment("future-1", userID, "file.txt", "stored-future.txt", "text/plain", 100, nil, nil) + _ = database.CreateAttachment(context.Background(), "future-1", userID, "file.txt", "stored-future.txt", "text/plain", 100, nil, nil) // Cutoff in the past — newly created attachment should NOT be deleted. - files, err := database.DeleteOrphanedAttachments("2000-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), "2000-01-01T00:00:00Z") if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -492,7 +493,7 @@ func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) { func TestGetAllChannelPermissionsForRole_Empty(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetAllChannelPermissionsForRole(4) + result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4) if err != nil { t.Fatalf("GetAllChannelPermissionsForRole: %v", err) } @@ -504,20 +505,20 @@ func TestGetAllChannelPermissionsForRole_Empty(t *testing.T) { func TestGetAllChannelPermissionsForRole_WithOverrides(t *testing.T) { database := openMigratedMemory(t) - ch1, _ := database.CreateChannel("perm-ch1", "text", "", "", 0) - ch2, _ := database.CreateChannel("perm-ch2", "text", "", "", 0) + ch1, _ := database.CreateChannel(context.Background(), "perm-ch1", "text", "", "", 0) + ch2, _ := database.CreateChannel(context.Background(), "perm-ch2", "text", "", "", 0) // Insert overrides for role 4. - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, ch1, 4, int64(0x100), int64(0x200), ) - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, ch2, 4, int64(0x300), int64(0), ) - result, err := database.GetAllChannelPermissionsForRole(4) + result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4) if err != nil { t.Fatalf("GetAllChannelPermissionsForRole: %v", err) } @@ -534,7 +535,7 @@ func TestGetAllChannelPermissionsForRole_WithOverrides(t *testing.T) { func TestGetChannelTypes_Empty(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetChannelTypes(nil) + result, err := database.GetChannelTypes(context.Background(), nil) if err != nil { t.Fatalf("GetChannelTypes: %v", err) } @@ -546,10 +547,10 @@ func TestGetChannelTypes_Empty(t *testing.T) { func TestGetChannelTypes_ReturnsTypes(t *testing.T) { database := openMigratedMemory(t) - ch1, _ := database.CreateChannel("type-text", "text", "", "", 0) - ch2, _ := database.CreateChannel("type-voice", "voice", "", "", 0) + ch1, _ := database.CreateChannel(context.Background(), "type-text", "text", "", "", 0) + ch2, _ := database.CreateChannel(context.Background(), "type-voice", "voice", "", "", 0) - result, err := database.GetChannelTypes([]int64{ch1, ch2}) + result, err := database.GetChannelTypes(context.Background(), []int64{ch1, ch2}) if err != nil { t.Fatalf("GetChannelTypes: %v", err) } @@ -564,7 +565,7 @@ func TestGetChannelTypes_ReturnsTypes(t *testing.T) { func TestGetChannelTypes_NonExistentIDs(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetChannelTypes([]int64{99999}) + result, err := database.GetChannelTypes(context.Background(), []int64{99999}) if err != nil { t.Fatalf("GetChannelTypes: %v", err) } @@ -577,10 +578,10 @@ func TestGetChannelTypes_NonExistentIDs(t *testing.T) { func TestCountUsersWithoutTOTP_AllWithout(t *testing.T) { database := openMigratedMemory(t) - _, _ = database.CreateUser("totp-u1", "hash", 4) - _, _ = database.CreateUser("totp-u2", "hash", 4) + _, _ = database.CreateUser(context.Background(), "totp-u1", "hash", 4) + _, _ = database.CreateUser(context.Background(), "totp-u2", "hash", 4) - count, err := database.CountUsersWithoutTOTP() + count, err := database.CountUsersWithoutTOTP(context.Background()) if err != nil { t.Fatalf("CountUsersWithoutTOTP: %v", err) } @@ -591,13 +592,13 @@ func TestCountUsersWithoutTOTP_AllWithout(t *testing.T) { func TestCountUsersWithoutTOTP_WithTOTPSetup(t *testing.T) { database := openMigratedMemory(t) - uid, _ := database.CreateUser("totp-with", "hash", 4) - _, _ = database.CreateUser("totp-without", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "totp-with", "hash", 4) + _, _ = database.CreateUser(context.Background(), "totp-without", "hash", 4) secret := "JBSWY3DPEHPK3PXP" - _ = database.UpdateUserTOTPSecret(uid, &secret) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) - count, err := database.CountUsersWithoutTOTP() + count, err := database.CountUsersWithoutTOTP(context.Background()) if err != nil { t.Fatalf("CountUsersWithoutTOTP: %v", err) } @@ -610,14 +611,14 @@ func TestCountUsersWithoutTOTP_WithTOTPSetup(t *testing.T) { func TestUpdateUserTOTPSecret_Set(t *testing.T) { database := openMigratedMemory(t) - uid, _ := database.CreateUser("totp-set", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "totp-set", "hash", 4) secret := "JBSWY3DPEHPK3PXP" - if err := database.UpdateUserTOTPSecret(uid, &secret); err != nil { + if err := database.UpdateUserTOTPSecret(context.Background(), uid, &secret); err != nil { t.Fatalf("UpdateUserTOTPSecret(set): %v", err) } - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.TOTPSecret == nil || *user.TOTPSecret != secret { t.Error("TOTP secret should be set") } @@ -625,15 +626,15 @@ func TestUpdateUserTOTPSecret_Set(t *testing.T) { func TestUpdateUserTOTPSecret_Clear(t *testing.T) { database := openMigratedMemory(t) - uid, _ := database.CreateUser("totp-clear", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "totp-clear", "hash", 4) secret := "JBSWY3DPEHPK3PXP" - _ = database.UpdateUserTOTPSecret(uid, &secret) - if err := database.UpdateUserTOTPSecret(uid, nil); err != nil { + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) + if err := database.UpdateUserTOTPSecret(context.Background(), uid, nil); err != nil { t.Fatalf("UpdateUserTOTPSecret(clear): %v", err) } - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.TOTPSecret != nil { t.Error("TOTP secret should be nil after clear") } @@ -644,14 +645,14 @@ func TestUpdateUserTOTPSecret_Clear(t *testing.T) { func TestCreateUserWithInvite_Success(t *testing.T) { database := openMigratedMemory(t) // Create a user who will create the invite. - creatorID, _ := database.CreateUser("invite-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "invite-creator", "hash", 2) - code, err := database.CreateInvite(creatorID, 5, nil) + code, err := database.CreateInvite(context.Background(), creatorID, 5, nil) if err != nil { t.Fatalf("CreateInvite: %v", err) } - uid, err := database.CreateUserWithInvite("newuser", "hash", 4, code) + uid, err := database.CreateUserWithInvite(context.Background(), "newuser", "hash", 4, code) if err != nil { t.Fatalf("CreateUserWithInvite: %v", err) } @@ -660,7 +661,7 @@ func TestCreateUserWithInvite_Success(t *testing.T) { } // Verify invite use count incremented. - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv == nil || inv.Uses != 1 { t.Errorf("invite uses = %v, want 1", inv) } @@ -669,7 +670,7 @@ func TestCreateUserWithInvite_Success(t *testing.T) { func TestCreateUserWithInvite_InvalidCode(t *testing.T) { database := openMigratedMemory(t) - _, err := database.CreateUserWithInvite("baduser", "hash", 4, "nonexistent-code") + _, err := database.CreateUserWithInvite(context.Background(), "baduser", "hash", 4, "nonexistent-code") if err == nil { t.Error("expected error for invalid invite code") } @@ -677,12 +678,12 @@ func TestCreateUserWithInvite_InvalidCode(t *testing.T) { func TestCreateUserWithInvite_RevokedInvite(t *testing.T) { database := openMigratedMemory(t) - creatorID, _ := database.CreateUser("inv-revoke-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "inv-revoke-creator", "hash", 2) - code, _ := database.CreateInvite(creatorID, 0, nil) - _ = database.RevokeInvite(code) + code, _ := database.CreateInvite(context.Background(), creatorID, 0, nil) + _ = database.RevokeInvite(context.Background(), code) - _, err := database.CreateUserWithInvite("revokeduser", "hash", 4, code) + _, err := database.CreateUserWithInvite(context.Background(), "revokeduser", "hash", 4, code) if err == nil { t.Error("expected error for revoked invite") } @@ -690,13 +691,13 @@ func TestCreateUserWithInvite_RevokedInvite(t *testing.T) { func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) { database := openMigratedMemory(t) - creatorID, _ := database.CreateUser("inv-expire-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "inv-expire-creator", "hash", 2) // Create an invite that expires in the past. pastTime := time.Now().Add(-1 * time.Hour) - code, _ := database.CreateInvite(creatorID, 0, &pastTime) + code, _ := database.CreateInvite(context.Background(), creatorID, 0, &pastTime) - _, err := database.CreateUserWithInvite("expireduser", "hash", 4, code) + _, err := database.CreateUserWithInvite(context.Background(), "expireduser", "hash", 4, code) if err == nil { t.Error("expected error for expired invite") } @@ -707,7 +708,7 @@ func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) { func TestListInvites_DB_Empty(t *testing.T) { database := openMigratedMemory(t) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites: %v", err) } @@ -718,12 +719,12 @@ func TestListInvites_DB_Empty(t *testing.T) { func TestListInvites_DB_ReturnsAll(t *testing.T) { database := openMigratedMemory(t) - creatorID, _ := database.CreateUser("list-inv-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "list-inv-creator", "hash", 2) - _, _ = database.CreateInvite(creatorID, 5, nil) - _, _ = database.CreateInvite(creatorID, 0, nil) + _, _ = database.CreateInvite(context.Background(), creatorID, 5, nil) + _, _ = database.CreateInvite(context.Background(), creatorID, 0, nil) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites: %v", err) } @@ -735,7 +736,7 @@ func TestListInvites_DB_ReturnsAll(t *testing.T) { func TestUseInviteAtomic_NonExistent(t *testing.T) { database := openMigratedMemory(t) - err := database.UseInviteAtomic("does-not-exist") + err := database.UseInviteAtomic(context.Background(), "does-not-exist") if err == nil { t.Error("expected error for non-existent invite") } @@ -746,7 +747,7 @@ func TestUseInviteAtomic_NonExistent(t *testing.T) { func TestSearchMessages_EmptyQuery(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessages("", nil, 10) + results, err := database.SearchMessages(context.Background(), "", nil, 10) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -758,7 +759,7 @@ func TestSearchMessages_EmptyQuery(t *testing.T) { func TestSearchMessages_ZeroLimit(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessages("test", nil, 0) + results, err := database.SearchMessages(context.Background(), "test", nil, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -772,10 +773,10 @@ func TestSearchMessages_SpecialCharsStripped(t *testing.T) { userID := seedUser(t, database, "srch-special") chID := seedChannel(t, database, "srch-special-ch") - _, _ = database.CreateMessage(chID, userID, "hello world content", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello world content", nil) // FTS special chars should be stripped, leaving a valid query. - results, err := database.SearchMessages("hello* \"world\"", nil, 10) + results, err := database.SearchMessages(context.Background(), "hello* \"world\"", nil, 10) if err != nil { t.Fatalf("SearchMessages with special chars: %v", err) } diff --git a/Server/db/db.go b/Server/db/db.go index 9425dd7b..293e2f82 100644 --- a/Server/db/db.go +++ b/Server/db/db.go @@ -25,11 +25,6 @@ type DB struct { q *dbgen.Queries } -// dbCtx is the context used for delegated dbgen calls. The public db.DB API is -// context-free today; callers that need cancellation use the *Context helpers -// directly. Using Background here preserves the existing behavior exactly. -func dbCtx() context.Context { return context.Background() } - // Open opens (or creates) a SQLite database at path, enables WAL mode and // foreign key enforcement, and returns a ready-to-use DB. func Open(path string) (*DB, error) { @@ -104,41 +99,21 @@ func (d *DB) Close() error { return d.sqlDB.Close() } -// QueryRow executes a query that returns at most one row. -func (d *DB) QueryRow(query string, args ...any) *sql.Row { - return d.sqlDB.QueryRow(query, args...) -} - // QueryRowContext executes a query that returns at most one row, with context. func (d *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { return d.sqlDB.QueryRowContext(ctx, query, args...) } -// Exec executes a query that doesn't return rows. -func (d *DB) Exec(query string, args ...any) (sql.Result, error) { - return d.sqlDB.Exec(query, args...) -} - // ExecContext executes a query that doesn't return rows, with context. func (d *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { return d.sqlDB.ExecContext(ctx, query, args...) } -// Query executes a query that returns multiple rows. -func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { - return d.sqlDB.Query(query, args...) -} - // QueryContext executes a query that returns multiple rows, with context. func (d *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { return d.sqlDB.QueryContext(ctx, query, args...) } -// Begin starts a database transaction. -func (d *DB) Begin() (*sql.Tx, error) { - return d.sqlDB.Begin() -} - // BeginTx starts a database transaction with context and options. func (d *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { return d.sqlDB.BeginTx(ctx, opts) diff --git a/Server/db/db_test.go b/Server/db/db_test.go index fc491e32..16dbdaab 100644 --- a/Server/db/db_test.go +++ b/Server/db/db_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "database/sql" "fmt" "io" @@ -59,7 +60,7 @@ func TestWALModeEnabled(t *testing.T) { database := openMemory(t) var journalMode string - err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode) + err := database.QueryRowContext(context.Background(), "PRAGMA journal_mode;").Scan(&journalMode) if err != nil { t.Fatalf("PRAGMA journal_mode query error: %v", err) } @@ -82,7 +83,7 @@ func TestWALModeEnabledOnFile(t *testing.T) { defer database.Close() //nolint:errcheck var journalMode string - if err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode); err != nil { + if err := database.QueryRowContext(context.Background(), "PRAGMA journal_mode;").Scan(&journalMode); err != nil { t.Fatalf("PRAGMA journal_mode query error: %v", err) } if journalMode != "wal" { @@ -94,7 +95,7 @@ func TestForeignKeysEnabled(t *testing.T) { database := openMemory(t) var fkEnabled int - if err := database.QueryRow("PRAGMA foreign_keys;").Scan(&fkEnabled); err != nil { + if err := database.QueryRowContext(context.Background(), "PRAGMA foreign_keys;").Scan(&fkEnabled); err != nil { t.Fatalf("PRAGMA foreign_keys query error: %v", err) } if fkEnabled != 1 { @@ -118,7 +119,7 @@ func TestMigrateCreatesAllTables(t *testing.T) { for _, table := range expectedTables { t.Run(table, func(t *testing.T) { var name string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, ).Scan(&name) @@ -139,7 +140,7 @@ func TestMigrateCreatesFTSTable(t *testing.T) { } var name string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'", ).Scan(&name) if err == sql.ErrNoRows { @@ -169,7 +170,7 @@ func TestMigrateInsertsDefaultRoles(t *testing.T) { } var count int - if err := database.QueryRow("SELECT COUNT(*) FROM roles").Scan(&count); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM roles").Scan(&count); err != nil { t.Fatalf("COUNT roles error: %v", err) } if count < 4 { @@ -185,7 +186,7 @@ func TestMigrateInsertsDefaultSettings(t *testing.T) { } var value string - err := database.QueryRow("SELECT value FROM settings WHERE key='registration_open'").Scan(&value) + err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='registration_open'").Scan(&value) if err != nil { t.Fatalf("settings query error: %v", err) } @@ -211,7 +212,7 @@ func TestMigrateCreatesIndexes(t *testing.T) { for _, idx := range expectedIndexes { t.Run(idx, func(t *testing.T) { var name string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='index' AND name=?", idx, ).Scan(&name) @@ -244,7 +245,7 @@ func TestQueryRow(t *testing.T) { // Verify we can run a simple query via the exposed DB. var schemaVersion string - err := database.QueryRow("SELECT value FROM settings WHERE key='schema_version'").Scan(&schemaVersion) + err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='schema_version'").Scan(&schemaVersion) if err != nil { t.Fatalf("QueryRow error: %v", err) } @@ -261,13 +262,13 @@ func TestExec(t *testing.T) { } // Insert a settings row using Exec. - _, err := database.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", "test_key", "test_val") + _, err := database.ExecContext(context.Background(), "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", "test_key", "test_val") if err != nil { t.Fatalf("Exec() error: %v", err) } var val string - if err := database.QueryRow("SELECT value FROM settings WHERE key='test_key'").Scan(&val); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='test_key'").Scan(&val); err != nil { t.Fatalf("QueryRow after Exec error: %v", err) } if val != "test_val" { @@ -282,7 +283,7 @@ func TestQuery(t *testing.T) { t.Fatalf("Migrate() error: %v", err) } - rows, err := database.Query("SELECT key FROM settings") + rows, err := database.QueryContext(context.Background(), "SELECT key FROM settings") if err != nil { t.Fatalf("Query() error: %v", err) } @@ -308,7 +309,7 @@ func TestBegin(t *testing.T) { t.Fatalf("Migrate() error: %v", err) } - tx, err := database.Begin() + tx, err := database.BeginTx(context.Background(), nil) if err != nil { t.Fatalf("Begin() error: %v", err) } @@ -325,7 +326,7 @@ func TestBegin(t *testing.T) { // After rollback, tx_key should not exist. var val string - err = database.QueryRow("SELECT value FROM settings WHERE key='tx_key'").Scan(&val) + err = database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='tx_key'").Scan(&val) if err == nil { t.Error("tx_key should not exist after rollback") } @@ -433,7 +434,7 @@ func TestMigrateFSSkipsNonSQL(t *testing.T) { // The table from the .sql file should exist. var name string - if err := database.QueryRow( + if err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='test_skip'", ).Scan(&name); err != nil { t.Error("table test_skip not found after MigrateFS") @@ -465,7 +466,7 @@ func TestMigrateWALAndFKOnFile(t *testing.T) { // Tables should exist. var name string - if err := database.QueryRow( + if err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", ).Scan(&name); err != nil { t.Errorf("users table not found after migration on file db: %v", err) diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index f1700f7d..207e97f9 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -36,8 +36,8 @@ type DMUser struct { // The entire lookup+create is wrapped in a single IMMEDIATE transaction to // prevent a TOCTOU race where two concurrent requests both see ErrNoRows and // each create a separate DM channel for the same user pair. -func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error) { - tx, err := d.sqlDB.BeginTx(context.Background(), &sql.TxOptions{ +func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*Channel, bool, error) { + tx, err := d.sqlDB.BeginTx(ctx, &sql.TxOptions{ Isolation: sql.LevelSerializable, }) if err != nil { @@ -66,7 +66,7 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error if commitErr := tx.Commit(); commitErr != nil { return nil, false, fmt.Errorf("GetOrCreateDMChannel commit existing: %w", commitErr) } - ch, getErr := d.GetChannel(existingID) + ch, getErr := d.GetChannel(ctx, existingID) if getErr != nil { return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch existing: %w", getErr) } @@ -120,7 +120,7 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error return nil, false, fmt.Errorf("GetOrCreateDMChannel commit: %w", err) } - ch, err := d.GetChannel(channelID) + ch, err := d.GetChannel(ctx, channelID) if err != nil { return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch new: %w", err) } @@ -136,8 +136,8 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error // (dm_open_state only contains rows for DM channels), and the explicit // "c.type = 'dm'" predicate in the JOIN provides a defensive second check. // No additional channel-type validation is needed at the Go layer. -func (d *DB) GetUserDMChannels(userID int64) ([]DMChannelInfo, error) { - rows, err := d.sqlDB.Query( +func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelInfo, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT c.id AS channel_id, u.id AS recipient_id, @@ -203,8 +203,8 @@ func (d *DB) GetUserDMChannels(userID int64) ([]DMChannelInfo, error) { // ─── OpenDM / CloseDM ────────────────────────────────────────────────────── // OpenDM adds a DM channel to a user's open list (idempotent). -func (d *DB) OpenDM(userID, channelID int64) error { - if err := d.q.OpenDM(dbCtx(), dbgen.OpenDMParams{ +func (d *DB) OpenDM(ctx context.Context, userID, channelID int64) error { + if err := d.q.OpenDM(ctx, dbgen.OpenDMParams{ UserID: userID, ChannelID: channelID, }); err != nil { @@ -214,8 +214,8 @@ func (d *DB) OpenDM(userID, channelID int64) error { } // CloseDM removes a DM channel from a user's open list. -func (d *DB) CloseDM(userID, channelID int64) error { - if err := d.q.CloseDM(dbCtx(), dbgen.CloseDMParams{ +func (d *DB) CloseDM(ctx context.Context, userID, channelID int64) error { + if err := d.q.CloseDM(ctx, dbgen.CloseDMParams{ UserID: userID, ChannelID: channelID, }); err != nil { @@ -227,8 +227,8 @@ func (d *DB) CloseDM(userID, channelID int64) error { // ─── Participant helpers ──────────────────────────────────────────────────── // IsDMParticipant checks if a user is a participant in a DM channel. -func (d *DB) IsDMParticipant(userID, channelID int64) (bool, error) { - _, err := d.q.IsDMParticipant(dbCtx(), dbgen.IsDMParticipantParams{ +func (d *DB) IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) { + _, err := d.q.IsDMParticipant(ctx, dbgen.IsDMParticipantParams{ UserID: userID, ChannelID: channelID, }) @@ -242,8 +242,8 @@ func (d *DB) IsDMParticipant(userID, channelID int64) (bool, error) { } // GetDMParticipantIDs returns all participant user IDs for a DM channel. -func (d *DB) GetDMParticipantIDs(channelID int64) ([]int64, error) { - ids, err := d.q.GetDMParticipantIDs(dbCtx(), channelID) +func (d *DB) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) { + ids, err := d.q.GetDMParticipantIDs(ctx, channelID) if err != nil { return nil, fmt.Errorf("GetDMParticipantIDs: %w", err) } @@ -251,9 +251,9 @@ func (d *DB) GetDMParticipantIDs(channelID int64) ([]int64, error) { } // GetDMRecipient returns the other participant in a DM channel. -func (d *DB) GetDMRecipient(channelID, requestingUserID int64) (*User, error) { +func (d *DB) GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*User, error) { var recipientID int64 - err := d.sqlDB.QueryRow( + err := d.sqlDB.QueryRowContext(ctx, `SELECT user_id FROM dm_participants WHERE channel_id = ? AND user_id != ? LIMIT 1`, @@ -265,5 +265,5 @@ func (d *DB) GetDMRecipient(channelID, requestingUserID int64) (*User, error) { if err != nil { return nil, fmt.Errorf("GetDMRecipient lookup: %w", err) } - return d.GetUserByID(recipientID) + return d.GetUserByID(ctx, recipientID) } diff --git a/Server/db/dm_queries_test.go b/Server/db/dm_queries_test.go index d5b6e886..c486dfdc 100644 --- a/Server/db/dm_queries_test.go +++ b/Server/db/dm_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -11,7 +12,7 @@ func TestGetOrCreateDMChannel_CreatesNew(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, created, err := database.GetOrCreateDMChannel(user1, user2) + ch, created, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } @@ -34,7 +35,7 @@ func TestGetOrCreateDMChannel_Idempotent(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch1, created1, err := database.GetOrCreateDMChannel(user1, user2) + ch1, created1, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("first GetOrCreateDMChannel: %v", err) } @@ -42,7 +43,7 @@ func TestGetOrCreateDMChannel_Idempotent(t *testing.T) { t.Error("expected created=true on first call") } - ch2, created2, err := database.GetOrCreateDMChannel(user1, user2) + ch2, created2, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("second GetOrCreateDMChannel: %v", err) } @@ -59,13 +60,13 @@ func TestGetOrCreateDMChannel_IdempotentReversedOrder(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch1, _, err := database.GetOrCreateDMChannel(user1, user2) + ch1, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel(u1,u2): %v", err) } // Reversed argument order should find the same channel. - ch2, created, err := database.GetOrCreateDMChannel(user2, user1) + ch2, created, err := database.GetOrCreateDMChannel(context.Background(), user2, user1) if err != nil { t.Fatalf("GetOrCreateDMChannel(u2,u1): %v", err) } @@ -82,18 +83,18 @@ func TestGetOrCreateDMChannel_ReopensForCaller(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Close the DM for user1. - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } // Verify user1 no longer sees it. - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -102,7 +103,7 @@ func TestGetOrCreateDMChannel_ReopensForCaller(t *testing.T) { } // Call GetOrCreateDMChannel again — should re-open for user1. - ch2, created, err := database.GetOrCreateDMChannel(user1, user2) + ch2, created, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel after close: %v", err) } @@ -114,7 +115,7 @@ func TestGetOrCreateDMChannel_ReopensForCaller(t *testing.T) { } // User1 should now see the DM again. - dms, err = database.GetUserDMChannels(user1) + dms, err = database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels after reopen: %v", err) } @@ -129,7 +130,7 @@ func TestGetUserDMChannels_EmptyList(t *testing.T) { database := openMigratedMemory(t) user1 := seedUser(t, database, "alice") - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -144,16 +145,16 @@ func TestGetUserDMChannels_ReturnsOpenDMs(t *testing.T) { user2 := seedUser(t, database, "bob") user3 := seedUser(t, database, "charlie") - _, _, err := database.GetOrCreateDMChannel(user1, user2) + _, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel(u1,u2): %v", err) } - _, _, err = database.GetOrCreateDMChannel(user1, user3) + _, _, err = database.GetOrCreateDMChannel(context.Background(), user1, user3) if err != nil { t.Fatalf("GetOrCreateDMChannel(u1,u3): %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -179,16 +180,16 @@ func TestGetUserDMChannels_ExcludesClosedDMs(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -197,7 +198,7 @@ func TestGetUserDMChannels_ExcludesClosedDMs(t *testing.T) { } // User2 should still see the DM (only user1 closed it). - dms2, err := database.GetUserDMChannels(user2) + dms2, err := database.GetUserDMChannels(context.Background(), user2) if err != nil { t.Fatalf("GetUserDMChannels(user2): %v", err) } @@ -211,31 +212,31 @@ func TestGetUserDMChannels_UnreadCount(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Send 3 messages from user2 in the DM channel. - msg1, err := database.CreateMessage(ch.ID, user2, "hello", nil) + msg1, err := database.CreateMessage(context.Background(), ch.ID, user2, "hello", nil) if err != nil { t.Fatalf("CreateMessage 1: %v", err) } - _, err = database.CreateMessage(ch.ID, user2, "how are you", nil) + _, err = database.CreateMessage(context.Background(), ch.ID, user2, "how are you", nil) if err != nil { t.Fatalf("CreateMessage 2: %v", err) } - _, err = database.CreateMessage(ch.ID, user2, "anyone there?", nil) + _, err = database.CreateMessage(context.Background(), ch.ID, user2, "anyone there?", nil) if err != nil { t.Fatalf("CreateMessage 3: %v", err) } // Mark user1 as having read only the first message. - if err := database.UpdateReadState(user1, ch.ID, msg1); err != nil { + if err := database.UpdateReadState(context.Background(), user1, ch.ID, msg1); err != nil { t.Fatalf("UpdateReadState: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -252,12 +253,12 @@ func TestGetUserDMChannels_NoMessages(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - _, _, err := database.GetOrCreateDMChannel(user1, user2) + _, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -277,21 +278,21 @@ func TestGetUserDMChannels_LastMessagePreview(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - _, err = database.CreateMessage(ch.ID, user2, "first message", nil) + _, err = database.CreateMessage(context.Background(), ch.ID, user2, "first message", nil) if err != nil { t.Fatalf("CreateMessage 1: %v", err) } - lastMsgID, err := database.CreateMessage(ch.ID, user2, "latest message", nil) + lastMsgID, err := database.CreateMessage(context.Background(), ch.ID, user2, "latest message", nil) if err != nil { t.Fatalf("CreateMessage 2: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -316,12 +317,12 @@ func TestIsDMParticipant_ValidParticipant(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - ok, err := database.IsDMParticipant(user1, ch.ID) + ok, err := database.IsDMParticipant(context.Background(), user1, ch.ID) if err != nil { t.Fatalf("IsDMParticipant(user1): %v", err) } @@ -329,7 +330,7 @@ func TestIsDMParticipant_ValidParticipant(t *testing.T) { t.Error("expected true for user1") } - ok, err = database.IsDMParticipant(user2, ch.ID) + ok, err = database.IsDMParticipant(context.Background(), user2, ch.ID) if err != nil { t.Fatalf("IsDMParticipant(user2): %v", err) } @@ -344,12 +345,12 @@ func TestIsDMParticipant_NonParticipant(t *testing.T) { user2 := seedUser(t, database, "bob") user3 := seedUser(t, database, "charlie") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - ok, err := database.IsDMParticipant(user3, ch.ID) + ok, err := database.IsDMParticipant(context.Background(), user3, ch.ID) if err != nil { t.Fatalf("IsDMParticipant(user3): %v", err) } @@ -362,7 +363,7 @@ func TestIsDMParticipant_NonExistentChannel(t *testing.T) { database := openMigratedMemory(t) user1 := seedUser(t, database, "alice") - ok, err := database.IsDMParticipant(user1, 99999) + ok, err := database.IsDMParticipant(context.Background(), user1, 99999) if err != nil { t.Fatalf("IsDMParticipant: %v", err) } @@ -378,18 +379,18 @@ func TestOpenDM_Idempotent(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Already open from creation — opening again should not error. - if err := database.OpenDM(user1, ch.ID); err != nil { + if err := database.OpenDM(context.Background(), user1, ch.ID); err != nil { t.Errorf("OpenDM (idempotent) error: %v", err) } // Should still have exactly 1 DM. - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -403,16 +404,16 @@ func TestCloseDM_RemovesFromOpenList(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -426,16 +427,16 @@ func TestCloseDM_Idempotent(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Close twice — should not error. - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Errorf("first CloseDM error: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Errorf("second CloseDM (idempotent) error: %v", err) } } @@ -445,20 +446,20 @@ func TestOpenDM_AfterClose(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } - if err := database.OpenDM(user1, ch.ID); err != nil { + if err := database.OpenDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("OpenDM after close: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -474,12 +475,12 @@ func TestGetDMParticipantIDs_ReturnsBoth(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - ids, err := database.GetDMParticipantIDs(ch.ID) + ids, err := database.GetDMParticipantIDs(context.Background(), ch.ID) if err != nil { t.Fatalf("GetDMParticipantIDs: %v", err) } @@ -499,7 +500,7 @@ func TestGetDMParticipantIDs_ReturnsBoth(t *testing.T) { func TestGetDMParticipantIDs_NonExistentChannel(t *testing.T) { database := openMigratedMemory(t) - ids, err := database.GetDMParticipantIDs(99999) + ids, err := database.GetDMParticipantIDs(context.Background(), 99999) if err != nil { t.Fatalf("GetDMParticipantIDs: %v", err) } @@ -515,13 +516,13 @@ func TestGetDMRecipient_ReturnsOtherUser(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // From user1's perspective, recipient should be user2. - recipient, err := database.GetDMRecipient(ch.ID, user1) + recipient, err := database.GetDMRecipient(context.Background(), ch.ID, user1) if err != nil { t.Fatalf("GetDMRecipient(user1): %v", err) } @@ -536,7 +537,7 @@ func TestGetDMRecipient_ReturnsOtherUser(t *testing.T) { } // From user2's perspective, recipient should be user1. - recipient2, err := database.GetDMRecipient(ch.ID, user2) + recipient2, err := database.GetDMRecipient(context.Background(), ch.ID, user2) if err != nil { t.Fatalf("GetDMRecipient(user2): %v", err) } @@ -555,7 +556,7 @@ func TestGetDMRecipient_NonExistentChannel(t *testing.T) { database := openMigratedMemory(t) user1 := seedUser(t, database, "alice") - recipient, err := database.GetDMRecipient(99999, user1) + recipient, err := database.GetDMRecipient(context.Background(), 99999, user1) if err != nil { t.Fatalf("GetDMRecipient: %v", err) } diff --git a/Server/db/invite_queries.go b/Server/db/invite_queries.go index 800ee8da..ab28f1a0 100644 --- a/Server/db/invite_queries.go +++ b/Server/db/invite_queries.go @@ -1,11 +1,14 @@ package db -import "fmt" +import ( + "context" + "fmt" +) // ListInvites returns invites ordered by creation time descending. // M-12: Limited to 200 rows to prevent unbounded result sets. -func (d *DB) ListInvites() ([]*Invite, error) { - rows, err := d.q.ListInvites(dbCtx()) +func (d *DB) ListInvites(ctx context.Context) ([]*Invite, error) { + rows, err := d.q.ListInvites(ctx) if err != nil { return nil, fmt.Errorf("ListInvites: %w", err) } diff --git a/Server/db/lockout_queries.go b/Server/db/lockout_queries.go index 80ac5f33..7b700978 100644 --- a/Server/db/lockout_queries.go +++ b/Server/db/lockout_queries.go @@ -1,14 +1,15 @@ package db import ( + "context" "time" "github.com/owncord/server/db/dbgen" ) // UpsertLockout inserts or replaces a rate-limit lockout entry. -func (d *DB) UpsertLockout(key string, expiresAt time.Time) error { - return d.q.UpsertLockout(dbCtx(), dbgen.UpsertLockoutParams{ +func (d *DB) UpsertLockout(ctx context.Context, key string, expiresAt time.Time) error { + return d.q.UpsertLockout(ctx, dbgen.UpsertLockoutParams{ Key: key, ExpiresAt: expiresAt.UTC().Format(time.RFC3339), }) @@ -16,8 +17,8 @@ func (d *DB) UpsertLockout(key string, expiresAt time.Time) error { // LoadActiveLockouts returns all lockouts that have not yet expired as // parallel slices of keys and expiry times. -func (d *DB) LoadActiveLockouts() (keys []string, expiresAt []time.Time, err error) { - rows, err := d.q.LoadActiveLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339)) +func (d *DB) LoadActiveLockouts(ctx context.Context) (keys []string, expiresAt []time.Time, err error) { + rows, err := d.q.LoadActiveLockouts(ctx, time.Now().UTC().Format(time.RFC3339)) if err != nil { return nil, nil, err } @@ -33,11 +34,11 @@ func (d *DB) LoadActiveLockouts() (keys []string, expiresAt []time.Time, err err } // CleanupExpiredLockouts removes lockout rows whose expiry has passed. -func (d *DB) CleanupExpiredLockouts() error { - return d.q.CleanupExpiredLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339)) +func (d *DB) CleanupExpiredLockouts(ctx context.Context) error { + return d.q.CleanupExpiredLockouts(ctx, time.Now().UTC().Format(time.RFC3339)) } // DeleteLockout removes a single lockout entry. -func (d *DB) DeleteLockout(key string) error { - return d.q.DeleteLockout(dbCtx(), key) +func (d *DB) DeleteLockout(ctx context.Context, key string) error { + return d.q.DeleteLockout(ctx, key) } diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index a6c99037..f0c0e7f8 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -46,8 +47,8 @@ func sanitizeFTSQuery(q string) string { // CreateMessage inserts a new message and returns the assigned ID. // Content should already be sanitized before calling this function. -func (d *DB) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { - res, err := d.q.CreateMessage(dbCtx(), dbgen.CreateMessageParams{ +func (d *DB) CreateMessage(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (int64, error) { + res, err := d.q.CreateMessage(ctx, dbgen.CreateMessageParams{ ChannelID: channelID, UserID: userID, Content: content, @@ -61,8 +62,8 @@ func (d *DB) CreateMessage(channelID, userID int64, content string, replyTo *int // GetMessage returns the message with the given ID, or nil if not found. // Soft-deleted messages are returned so callers can broadcast the deletion event. -func (d *DB) GetMessage(id int64) (*Message, error) { - m, err := d.q.GetMessage(dbCtx(), id) +func (d *DB) GetMessage(ctx context.Context, id int64) (*Message, error) { + m, err := d.q.GetMessage(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -74,13 +75,13 @@ func (d *DB) GetMessage(id int64) (*Message, error) { // GetMessages returns up to limit messages in a channel, ordered newest-first. // When before > 0 only messages with id < before are returned (pagination). -func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser, error) { +func (d *DB) GetMessages(ctx context.Context, channelID, before int64, limit int) ([]MessageWithUser, error) { var ( rows *sql.Rows err error ) if before > 0 { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, u.username, u.avatar @@ -90,7 +91,7 @@ func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser, channelID, before, limit, ) } else { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, u.username, u.avatar @@ -124,8 +125,8 @@ func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser, // EditMessage updates the content and sets edited_at on the message. // Returns an error if the message does not exist or userID does not match the owner. -func (d *DB) EditMessage(id, userID int64, content string) error { - msg, err := d.GetMessage(id) +func (d *DB) EditMessage(ctx context.Context, id, userID int64, content string) error { + msg, err := d.GetMessage(ctx, id) if err != nil { return err } @@ -136,7 +137,7 @@ func (d *DB) EditMessage(id, userID int64, content string) error { return fmt.Errorf("EditMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) } - if err := d.q.EditMessageContent(dbCtx(), dbgen.EditMessageContentParams{ + if err := d.q.EditMessageContent(ctx, dbgen.EditMessageContentParams{ Content: content, ID: id, }); err != nil { @@ -147,8 +148,8 @@ func (d *DB) EditMessage(id, userID int64, content string) error { // DeleteMessage performs a soft delete (sets deleted=1) on the message. // The calling user must be the message owner or ismod must be true. -func (d *DB) DeleteMessage(id, userID int64, ismod bool) error { - msg, err := d.GetMessage(id) +func (d *DB) DeleteMessage(ctx context.Context, id, userID int64, ismod bool) error { + msg, err := d.GetMessage(ctx, id) if err != nil { return err } @@ -159,15 +160,15 @@ func (d *DB) DeleteMessage(id, userID int64, ismod bool) error { return fmt.Errorf("DeleteMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) } - if err := d.q.SoftDeleteMessage(dbCtx(), id); err != nil { + if err := d.q.SoftDeleteMessage(ctx, id); err != nil { return fmt.Errorf("DeleteMessage: %w", err) } return nil } // AddReaction inserts a reaction. Returns an error on duplicate (same user+emoji+message). -func (d *DB) AddReaction(messageID, userID int64, emoji string) error { - if err := d.q.AddReaction(dbCtx(), dbgen.AddReactionParams{ +func (d *DB) AddReaction(ctx context.Context, messageID, userID int64, emoji string) error { + if err := d.q.AddReaction(ctx, dbgen.AddReactionParams{ MessageID: messageID, UserID: userID, Emoji: emoji, @@ -178,8 +179,8 @@ func (d *DB) AddReaction(messageID, userID int64, emoji string) error { } // RemoveReaction deletes a reaction. Returns an error if it does not exist. -func (d *DB) RemoveReaction(messageID, userID int64, emoji string) error { - res, err := d.q.RemoveReaction(dbCtx(), dbgen.RemoveReactionParams{ +func (d *DB) RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error { + res, err := d.q.RemoveReaction(ctx, dbgen.RemoveReactionParams{ MessageID: messageID, UserID: userID, Emoji: emoji, @@ -196,8 +197,8 @@ func (d *DB) RemoveReaction(messageID, userID int64, emoji string) error { // GetReactions returns aggregated reaction counts for a message. // MeReacted is always false here (caller passes requesting userID if needed). -func (d *DB) GetReactions(messageID int64) ([]ReactionCount, error) { - rows, err := d.q.GetReactionCounts(dbCtx(), messageID) +func (d *DB) GetReactions(ctx context.Context, messageID int64) ([]ReactionCount, error) { + rows, err := d.q.GetReactionCounts(ctx, messageID) if err != nil { return nil, fmt.Errorf("GetReactions: %w", err) } @@ -211,7 +212,7 @@ func (d *DB) GetReactions(messageID int64) ([]ReactionCount, error) { // SearchMessages performs a full-text search against the messages_fts virtual table. // When channelID is non-nil the search is scoped to that channel. // Deleted messages are excluded from results. -func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]MessageSearchResult, error) { +func (d *DB) SearchMessages(ctx context.Context, query string, channelID *int64, limit int) ([]MessageSearchResult, error) { if query == "" { return []MessageSearchResult{}, nil } @@ -229,7 +230,7 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag ) if channelID != nil { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f JOIN messages m ON f.rowid = m.id @@ -240,7 +241,7 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag query, *channelID, limit, ) } else { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f JOIN messages m ON f.rowid = m.id @@ -278,7 +279,7 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag // SearchMessagesInChannels performs a full-text search scoped to the given // channel IDs. This prevents information leakage by filtering at the DB level // rather than post-filtering in application code. -func (d *DB) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]MessageSearchResult, error) { +func (d *DB) SearchMessagesInChannels(ctx context.Context, query string, channelIDs []int64, limit int) ([]MessageSearchResult, error) { if query == "" || len(channelIDs) == 0 { return []MessageSearchResult{}, nil } @@ -300,7 +301,7 @@ func (d *DB) SearchMessagesInChannels(query string, channelIDs []int64, limit in } args = append(args, limit) - rows, err := d.sqlDB.Query( + rows, err := d.sqlDB.QueryContext(ctx, fmt.Sprintf( `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f @@ -338,13 +339,13 @@ func (d *DB) SearchMessagesInChannels(query string, channelIDs []int64, limit in // GetMessagesForAPI returns messages in the API.md response shape, including // user object, reactions (with me flag), and attachments. -func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]MessageAPIResponse, error) { +func (d *DB) GetMessagesForAPI(ctx context.Context, channelID, before int64, limit int, requestingUserID int64) ([]MessageAPIResponse, error) { var ( rows *sql.Rows err error ) if before > 0 { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -353,7 +354,7 @@ func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUse channelID, before, limit, ) } else { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -367,11 +368,11 @@ func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUse } defer rows.Close() //nolint:errcheck - return d.scanAndEnrichMessages(rows, requestingUserID) + return d.scanAndEnrichMessages(ctx, rows, requestingUserID) } // getReactionsBatch returns aggregated reactions for multiple messages. -func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int64][]ReactionInfo, error) { +func (d *DB) getReactionsBatch(ctx context.Context, msgIDs []int64, requestingUserID int64) (map[int64][]ReactionInfo, error) { if len(msgIDs) == 0 { return map[int64][]ReactionInfo{}, nil } @@ -399,7 +400,7 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6 ) args = append([]any{requestingUserID}, args...) - rows, err := d.sqlDB.Query(query, args...) + rows, err := d.sqlDB.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("getReactionsBatch: %w", err) } @@ -423,8 +424,8 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6 } // UpdateReadState upserts the read state for a user in a channel. -func (d *DB) UpdateReadState(userID, channelID, lastReadMessageID int64) error { - if err := d.q.UpdateReadState(dbCtx(), dbgen.UpdateReadStateParams{ +func (d *DB) UpdateReadState(ctx context.Context, userID, channelID, lastReadMessageID int64) error { + if err := d.q.UpdateReadState(ctx, dbgen.UpdateReadStateParams{ UserID: userID, ChannelID: channelID, LastMessageID: lastReadMessageID, @@ -436,8 +437,8 @@ func (d *DB) UpdateReadState(userID, channelID, lastReadMessageID int64) error { // GetChannelUnreadCounts returns per-channel unread counts and last message IDs // for a given user. Only text channels with at least one message are included. -func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, error) { - rows, err := d.sqlDB.Query( +func (d *DB) GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int64]ChannelUnread, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT c.id, COALESCE(MAX(m.id), 0) AS last_msg_id, COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = 0 THEN 1 END) AS unread @@ -469,9 +470,9 @@ func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, erro } // GetLatestMessageID returns the highest message ID in a channel, or 0 if empty. -func (d *DB) GetLatestMessageID(channelID int64) (int64, error) { +func (d *DB) GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) { var id int64 - err := d.sqlDB.QueryRow( + err := d.sqlDB.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM messages WHERE channel_id = ? AND deleted = 0`, channelID, ).Scan(&id) @@ -483,8 +484,8 @@ func (d *DB) GetLatestMessageID(channelID int64) (int64, error) { // GetPinnedMessages returns all pinned messages in a channel in the API response shape, // including user object, reactions (with me flag), and attachments. -func (d *DB) GetPinnedMessages(channelID int64, requestingUserID int64) ([]MessageAPIResponse, error) { - rows, err := d.sqlDB.Query( +func (d *DB) GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]MessageAPIResponse, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -497,12 +498,12 @@ func (d *DB) GetPinnedMessages(channelID int64, requestingUserID int64) ([]Messa } defer rows.Close() //nolint:errcheck - return d.scanAndEnrichMessages(rows, requestingUserID) + return d.scanAndEnrichMessages(ctx, rows, requestingUserID) } // scanAndEnrichMessages scans rows into MessageAPIResponse slice and // batch-fetches reactions and attachments. Caller must defer rows.Close(). -func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]MessageAPIResponse, error) { +func (d *DB) scanAndEnrichMessages(ctx context.Context, rows *sql.Rows, requestingUserID int64) ([]MessageAPIResponse, error) { var msgs []MessageAPIResponse var msgIDs []int64 for rows.Next() { @@ -529,7 +530,7 @@ func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]Me } // Batch-fetch reactions for all message IDs. - reactMap, err := d.getReactionsBatch(msgIDs, requestingUserID) + reactMap, err := d.getReactionsBatch(ctx, msgIDs, requestingUserID) if err != nil { return nil, fmt.Errorf("scanAndEnrichMessages reactions: %w", err) } @@ -540,7 +541,7 @@ func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]Me } // Batch-fetch attachments for all message IDs. - attMap, err := d.GetAttachmentsByMessageIDs(msgIDs) + attMap, err := d.GetAttachmentsByMessageIDs(ctx, msgIDs) if err != nil { return nil, fmt.Errorf("scanAndEnrichMessages attachments: %w", err) } @@ -555,8 +556,8 @@ func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]Me // SetMessagePinned updates the pinned column on a message. // Returns ErrNotFound if the message does not exist. -func (d *DB) SetMessagePinned(id int64, pinned bool) error { - res, err := d.q.SetMessagePinned(dbCtx(), dbgen.SetMessagePinnedParams{ +func (d *DB) SetMessagePinned(ctx context.Context, id int64, pinned bool) error { + res, err := d.q.SetMessagePinned(ctx, dbgen.SetMessagePinnedParams{ Pinned: b2i64(pinned), ID: id, }) diff --git a/Server/db/message_queries_test.go b/Server/db/message_queries_test.go index 0372d90c..12a8b8da 100644 --- a/Server/db/message_queries_test.go +++ b/Server/db/message_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "github.com/owncord/server/db" @@ -9,7 +10,7 @@ import ( // seedUser inserts a minimal test user and returns its ID. func seedUser(t *testing.T, database *db.DB, username string) int64 { t.Helper() - id, err := database.CreateUser(username, "hash", 4) + id, err := database.CreateUser(context.Background(), username, "hash", 4) if err != nil { t.Fatalf("seedUser(%q): %v", username, err) } @@ -19,7 +20,7 @@ func seedUser(t *testing.T, database *db.DB, username string) int64 { // seedChannel inserts a minimal test channel and returns its ID. func seedChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "text", "", "", 0) if err != nil { t.Fatalf("seedChannel(%q): %v", name, err) } @@ -33,7 +34,7 @@ func TestCreateMessage_ReturnsID(t *testing.T) { userID := seedUser(t, database, "alice") chID := seedChannel(t, database, "general") - id, err := database.CreateMessage(chID, userID, "hello", nil) + id, err := database.CreateMessage(context.Background(), chID, userID, "hello", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -47,13 +48,13 @@ func TestCreateMessage_WithReplyTo(t *testing.T) { userID := seedUser(t, database, "alice") chID := seedChannel(t, database, "general") - parentID, _ := database.CreateMessage(chID, userID, "parent", nil) - replyID, err := database.CreateMessage(chID, userID, "reply", &parentID) + parentID, _ := database.CreateMessage(context.Background(), chID, userID, "parent", nil) + replyID, err := database.CreateMessage(context.Background(), chID, userID, "reply", &parentID) if err != nil { t.Fatalf("CreateMessage with reply: %v", err) } - msg, _ := database.GetMessage(replyID) + msg, _ := database.GetMessage(context.Background(), replyID) if msg.ReplyTo == nil || *msg.ReplyTo != parentID { t.Errorf("ReplyTo = %v, want %d", msg.ReplyTo, parentID) } @@ -64,8 +65,8 @@ func TestCreateMessage_ContentPreserved(t *testing.T) { userID := seedUser(t, database, "bob") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "test content", nil) - msg, _ := database.GetMessage(id) + id, _ := database.CreateMessage(context.Background(), chID, userID, "test content", nil) + msg, _ := database.GetMessage(context.Background(), id) if msg.Content != "test content" { t.Errorf("Content = %q, want 'test content'", msg.Content) } @@ -76,7 +77,7 @@ func TestCreateMessage_ContentPreserved(t *testing.T) { func TestGetMessage_NotFound(t *testing.T) { database := openMigratedMemory(t) - msg, err := database.GetMessage(9999) + msg, err := database.GetMessage(context.Background(), 9999) if err != nil { t.Fatalf("GetMessage: %v", err) } @@ -90,9 +91,9 @@ func TestGetMessage_Fields(t *testing.T) { userID := seedUser(t, database, "carol") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "hello world", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "hello world", nil) - msg, err := database.GetMessage(id) + msg, err := database.GetMessage(context.Background(), id) if err != nil { t.Fatalf("GetMessage: %v", err) } @@ -122,7 +123,7 @@ func TestGetMessages_EmptyChannel(t *testing.T) { database := openMigratedMemory(t) chID := seedChannel(t, database, "empty") - msgs, err := database.GetMessages(chID, 0, 50) + msgs, err := database.GetMessages(context.Background(), chID, 0, 50) if err != nil { t.Fatalf("GetMessages: %v", err) } @@ -137,13 +138,13 @@ func TestGetMessages_ReturnsMessages(t *testing.T) { chID := seedChannel(t, database, "ch") for i := range 3 { - _, err := database.CreateMessage(chID, userID, "msg", nil) + _, err := database.CreateMessage(context.Background(), chID, userID, "msg", nil) if err != nil { t.Fatalf("CreateMessage %d: %v", i, err) } } - msgs, err := database.GetMessages(chID, 0, 50) + msgs, err := database.GetMessages(context.Background(), chID, 0, 50) if err != nil { t.Fatalf("GetMessages: %v", err) } @@ -158,10 +159,10 @@ func TestGetMessages_LimitRespected(t *testing.T) { chID := seedChannel(t, database, "ch") for range 10 { - _, _ = database.CreateMessage(chID, userID, "msg", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "msg", nil) } - msgs, _ := database.GetMessages(chID, 0, 5) + msgs, _ := database.GetMessages(context.Background(), chID, 0, 5) if len(msgs) != 5 { t.Errorf("expected 5 messages (limit), got %d", len(msgs)) } @@ -174,12 +175,12 @@ func TestGetMessages_BeforePagination(t *testing.T) { ids := make([]int64, 0, 5) for range 5 { - id, _ := database.CreateMessage(chID, userID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil) ids = append(ids, id) } // Get messages before the 4th message (should get 3 messages: ids 0,1,2). - msgs, _ := database.GetMessages(chID, ids[3], 50) + msgs, _ := database.GetMessages(context.Background(), chID, ids[3], 50) if len(msgs) != 3 { t.Errorf("expected 3 messages before id %d, got %d", ids[3], len(msgs)) } @@ -190,8 +191,8 @@ func TestGetMessages_IncludesUsername(t *testing.T) { userID := seedUser(t, database, "grace") chID := seedChannel(t, database, "ch") - _, _ = database.CreateMessage(chID, userID, "hi", nil) - msgs, _ := database.GetMessages(chID, 0, 50) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hi", nil) + msgs, _ := database.GetMessages(context.Background(), chID, 0, 50) if len(msgs) == 0 { t.Fatal("expected messages") @@ -208,13 +209,13 @@ func TestEditMessage_OwnerCanEdit(t *testing.T) { userID := seedUser(t, database, "henry") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "original", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "original", nil) - if err := database.EditMessage(id, userID, "updated"); err != nil { + if err := database.EditMessage(context.Background(), id, userID, "updated"); err != nil { t.Fatalf("EditMessage: %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg.Content != "updated" { t.Errorf("Content = %q, want 'updated'", msg.Content) } @@ -229,9 +230,9 @@ func TestEditMessage_NonOwnerCannotEdit(t *testing.T) { otherID := seedUser(t, database, "julia") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, ownerID, "original", nil) + id, _ := database.CreateMessage(context.Background(), chID, ownerID, "original", nil) - err := database.EditMessage(id, otherID, "hacked") + err := database.EditMessage(context.Background(), id, otherID, "hacked") if err == nil { t.Error("EditMessage by non-owner should return error") } @@ -241,7 +242,7 @@ func TestEditMessage_NotFound(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "kim") - err := database.EditMessage(9999, userID, "x") + err := database.EditMessage(context.Background(), 9999, userID, "x") if err == nil { t.Error("EditMessage non-existent should return error") } @@ -254,13 +255,13 @@ func TestDeleteMessage_OwnerCanDelete(t *testing.T) { userID := seedUser(t, database, "larry") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "bye", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "bye", nil) - if err := database.DeleteMessage(id, userID, false); err != nil { + if err := database.DeleteMessage(context.Background(), id, userID, false); err != nil { t.Fatalf("DeleteMessage: %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg == nil { t.Fatal("soft-deleted message should still exist in DB") } @@ -274,10 +275,10 @@ func TestDeleteMessage_ContentPreservedAfterSoftDelete(t *testing.T) { userID := seedUser(t, database, "mia") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "sensitive", nil) - _ = database.DeleteMessage(id, userID, false) + id, _ := database.CreateMessage(context.Background(), chID, userID, "sensitive", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) // Content preserved for broadcast (soft delete only flags deleted=1). if msg.Content == "" { t.Error("content should be preserved on soft delete for broadcast purposes") @@ -290,9 +291,9 @@ func TestDeleteMessage_NonOwnerBlockedWithoutMod(t *testing.T) { otherID := seedUser(t, database, "olivia") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, ownerID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, ownerID, "msg", nil) - err := database.DeleteMessage(id, otherID, false) + err := database.DeleteMessage(context.Background(), id, otherID, false) if err == nil { t.Error("DeleteMessage by non-owner non-mod should return error") } @@ -304,13 +305,13 @@ func TestDeleteMessage_ModCanDeleteAny(t *testing.T) { modID := seedUser(t, database, "quinn") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, ownerID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, ownerID, "msg", nil) - if err := database.DeleteMessage(id, modID, true); err != nil { + if err := database.DeleteMessage(context.Background(), id, modID, true); err != nil { t.Fatalf("DeleteMessage by mod: %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if !msg.Deleted { t.Error("expected Deleted=true after mod delete") } @@ -320,7 +321,7 @@ func TestDeleteMessage_NotFound(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "rachel") - err := database.DeleteMessage(9999, userID, true) + err := database.DeleteMessage(context.Background(), 9999, userID, true) if err == nil { t.Error("DeleteMessage non-existent should return error") } @@ -332,9 +333,9 @@ func TestAddReaction_Success(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "sam") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - if err := database.AddReaction(msgID, userID, "👍"); err != nil { + if err := database.AddReaction(context.Background(), msgID, userID, "👍"); err != nil { t.Fatalf("AddReaction: %v", err) } } @@ -343,10 +344,10 @@ func TestAddReaction_UniqueConstraint(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "tina") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - _ = database.AddReaction(msgID, userID, "❤️") - err := database.AddReaction(msgID, userID, "❤️") + _ = database.AddReaction(context.Background(), msgID, userID, "❤️") + err := database.AddReaction(context.Background(), msgID, userID, "❤️") if err == nil { t.Error("adding duplicate reaction should return error") } @@ -356,10 +357,10 @@ func TestRemoveReaction_Success(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "uma") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - _ = database.AddReaction(msgID, userID, "😂") - if err := database.RemoveReaction(msgID, userID, "😂"); err != nil { + _ = database.AddReaction(context.Background(), msgID, userID, "😂") + if err := database.RemoveReaction(context.Background(), msgID, userID, "😂"); err != nil { t.Fatalf("RemoveReaction: %v", err) } } @@ -368,9 +369,9 @@ func TestRemoveReaction_NotFound(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "victor") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - err := database.RemoveReaction(msgID, userID, "🔥") + err := database.RemoveReaction(context.Background(), msgID, userID, "🔥") if err == nil { t.Error("removing non-existent reaction should return error") } @@ -380,9 +381,9 @@ func TestGetReactions_Empty(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "wendy") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - counts, err := database.GetReactions(msgID) + counts, err := database.GetReactions(context.Background(), msgID) if err != nil { t.Fatalf("GetReactions: %v", err) } @@ -396,13 +397,13 @@ func TestGetReactions_Counts(t *testing.T) { u1 := seedUser(t, database, "xavier") u2 := seedUser(t, database, "yvonne") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, u1, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, u1, "hi", nil) - _ = database.AddReaction(msgID, u1, "👍") - _ = database.AddReaction(msgID, u2, "👍") - _ = database.AddReaction(msgID, u1, "❤️") + _ = database.AddReaction(context.Background(), msgID, u1, "👍") + _ = database.AddReaction(context.Background(), msgID, u2, "👍") + _ = database.AddReaction(context.Background(), msgID, u1, "❤️") - counts, _ := database.GetReactions(msgID) + counts, _ := database.GetReactions(context.Background(), msgID) if len(counts) != 2 { t.Fatalf("expected 2 emoji types, got %d", len(counts)) } @@ -429,10 +430,10 @@ func TestSearchMessages_FindsMatch(t *testing.T) { userID := seedUser(t, database, "zara") chID := seedChannel(t, database, "searchch") - _, _ = database.CreateMessage(chID, userID, "hello world fts test", nil) - _, _ = database.CreateMessage(chID, userID, "unrelated content here", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello world fts test", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "unrelated content here", nil) - results, err := database.SearchMessages("hello", nil, 10) + results, err := database.SearchMessages(context.Background(), "hello", nil, 10) if err != nil { t.Fatalf("SearchMessages: %v", err) } @@ -450,10 +451,10 @@ func TestSearchMessages_FilterByChannel(t *testing.T) { ch1 := seedChannel(t, database, "ch1") ch2 := seedChannel(t, database, "ch2") - _, _ = database.CreateMessage(ch1, userID, "needle in channel 1", nil) - _, _ = database.CreateMessage(ch2, userID, "needle in channel 2", nil) + _, _ = database.CreateMessage(context.Background(), ch1, userID, "needle in channel 1", nil) + _, _ = database.CreateMessage(context.Background(), ch2, userID, "needle in channel 2", nil) - results, _ := database.SearchMessages("needle", &ch1, 10) + results, _ := database.SearchMessages(context.Background(), "needle", &ch1, 10) if len(results) != 1 { t.Errorf("expected 1 result in ch1, got %d", len(results)) } @@ -466,9 +467,9 @@ func TestSearchMessages_NoResults(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "beth") chID := seedChannel(t, database, "ch") - _, _ = database.CreateMessage(chID, userID, "hello there", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello there", nil) - results, _ := database.SearchMessages("xyzzy", nil, 10) + results, _ := database.SearchMessages(context.Background(), "xyzzy", nil, 10) if len(results) != 0 { t.Errorf("expected 0 results, got %d", len(results)) } @@ -480,10 +481,10 @@ func TestSearchMessages_LimitRespected(t *testing.T) { chID := seedChannel(t, database, "ch") for range 5 { - _, _ = database.CreateMessage(chID, userID, "searchable keyword content", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "searchable keyword content", nil) } - results, _ := database.SearchMessages("keyword", nil, 3) + results, _ := database.SearchMessages(context.Background(), "keyword", nil, 3) if len(results) != 3 { t.Errorf("expected 3 results (limit), got %d", len(results)) } @@ -494,10 +495,10 @@ func TestSearchMessages_DeletedNotReturned(t *testing.T) { userID := seedUser(t, database, "diana") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "vanishing keyword message", nil) - _ = database.DeleteMessage(id, userID, false) + id, _ := database.CreateMessage(context.Background(), chID, userID, "vanishing keyword message", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) - results, _ := database.SearchMessages("vanishing", nil, 10) + results, _ := database.SearchMessages(context.Background(), "vanishing", nil, 10) if len(results) != 0 { t.Errorf("expected 0 results (deleted excluded), got %d", len(results)) } @@ -509,15 +510,15 @@ func TestUpdateReadState_Upsert(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "ella") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "msg", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil) - if err := database.UpdateReadState(userID, chID, msgID); err != nil { + if err := database.UpdateReadState(context.Background(), userID, chID, msgID); err != nil { t.Fatalf("UpdateReadState: %v", err) } // Update again with higher message ID — should not error. - msgID2, _ := database.CreateMessage(chID, userID, "msg2", nil) - if err := database.UpdateReadState(userID, chID, msgID2); err != nil { + msgID2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil) + if err := database.UpdateReadState(context.Background(), userID, chID, msgID2); err != nil { t.Fatalf("UpdateReadState second call: %v", err) } } @@ -529,7 +530,7 @@ func TestGetMessagesForAPI_Empty(t *testing.T) { chID := seedChannel(t, database, "apichan") userID := seedUser(t, database, "apiuser") - msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -543,9 +544,9 @@ func TestGetMessagesForAPI_ReturnsUserObject(t *testing.T) { userID := seedUser(t, database, "apiuser2") chID := seedChannel(t, database, "apichan2") - _, _ = database.CreateMessage(chID, userID, "hello api", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello api", nil) - msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -570,11 +571,11 @@ func TestGetMessagesForAPI_BeforePagination(t *testing.T) { ids := make([]int64, 0, 5) for range 5 { - id, _ := database.CreateMessage(chID, userID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil) ids = append(ids, id) } - msgs, err := database.GetMessagesForAPI(chID, ids[3], 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, ids[3], 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI with before: %v", err) } @@ -589,11 +590,11 @@ func TestGetMessagesForAPI_WithReactions(t *testing.T) { u2 := seedUser(t, database, "reactuser2") chID := seedChannel(t, database, "reactchan") - msgID, _ := database.CreateMessage(chID, u1, "react me", nil) - _ = database.AddReaction(msgID, u1, "👍") - _ = database.AddReaction(msgID, u2, "👍") + msgID, _ := database.CreateMessage(context.Background(), chID, u1, "react me", nil) + _ = database.AddReaction(context.Background(), msgID, u1, "👍") + _ = database.AddReaction(context.Background(), msgID, u2, "👍") - msgs, err := database.GetMessagesForAPI(chID, 0, 50, u1) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, u1) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -616,11 +617,11 @@ func TestGetMessagesForAPI_ExcludesDeleted(t *testing.T) { userID := seedUser(t, database, "apidel") chID := seedChannel(t, database, "apidelchan") - id, _ := database.CreateMessage(chID, userID, "deleted msg", nil) - _ = database.DeleteMessage(id, userID, false) - _, _ = database.CreateMessage(chID, userID, "visible msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "deleted msg", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) + _, _ = database.CreateMessage(context.Background(), chID, userID, "visible msg", nil) - msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -636,7 +637,7 @@ func TestGetChannelUnreadCounts_NoMessages(t *testing.T) { userID := seedUser(t, database, "unreaduser") _ = seedChannel(t, database, "unreadchan") - counts, err := database.GetChannelUnreadCounts(userID) + counts, err := database.GetChannelUnreadCounts(context.Background(), userID) if err != nil { t.Fatalf("GetChannelUnreadCounts: %v", err) } @@ -652,13 +653,13 @@ func TestGetChannelUnreadCounts_WithUnreadMessages(t *testing.T) { chID := seedChannel(t, database, "unreadchan2") // Create 3 messages, mark first as read. - msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) - _, _ = database.CreateMessage(chID, userID, "msg2", nil) - _, _ = database.CreateMessage(chID, userID, "msg3", nil) + msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "msg2", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "msg3", nil) - _ = database.UpdateReadState(userID, chID, msg1) + _ = database.UpdateReadState(context.Background(), userID, chID, msg1) - counts, err := database.GetChannelUnreadCounts(userID) + counts, err := database.GetChannelUnreadCounts(context.Background(), userID) if err != nil { t.Fatalf("GetChannelUnreadCounts: %v", err) } @@ -677,7 +678,7 @@ func TestGetLatestMessageID_Empty(t *testing.T) { database := openMigratedMemory(t) chID := seedChannel(t, database, "latestchan") - id, err := database.GetLatestMessageID(chID) + id, err := database.GetLatestMessageID(context.Background(), chID) if err != nil { t.Fatalf("GetLatestMessageID: %v", err) } @@ -691,11 +692,11 @@ func TestGetLatestMessageID_ReturnsHighest(t *testing.T) { userID := seedUser(t, database, "latestuser") chID := seedChannel(t, database, "latestchan2") - _, _ = database.CreateMessage(chID, userID, "first", nil) - _, _ = database.CreateMessage(chID, userID, "second", nil) - lastID, _ := database.CreateMessage(chID, userID, "third", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "first", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "second", nil) + lastID, _ := database.CreateMessage(context.Background(), chID, userID, "third", nil) - id, err := database.GetLatestMessageID(chID) + id, err := database.GetLatestMessageID(context.Background(), chID) if err != nil { t.Fatalf("GetLatestMessageID: %v", err) } @@ -709,11 +710,11 @@ func TestGetLatestMessageID_ExcludesDeleted(t *testing.T) { userID := seedUser(t, database, "latestdel") chID := seedChannel(t, database, "latestdelchan") - id1, _ := database.CreateMessage(chID, userID, "keep", nil) - id2, _ := database.CreateMessage(chID, userID, "delete me", nil) - _ = database.DeleteMessage(id2, userID, false) + id1, _ := database.CreateMessage(context.Background(), chID, userID, "keep", nil) + id2, _ := database.CreateMessage(context.Background(), chID, userID, "delete me", nil) + _ = database.DeleteMessage(context.Background(), id2, userID, false) - latestID, err := database.GetLatestMessageID(chID) + latestID, err := database.GetLatestMessageID(context.Background(), chID) if err != nil { t.Fatalf("GetLatestMessageID: %v", err) } diff --git a/Server/db/migrate_test.go b/Server/db/migrate_test.go index 508064a9..f3529284 100644 --- a/Server/db/migrate_test.go +++ b/Server/db/migrate_test.go @@ -21,6 +21,7 @@ package db_test // TestMigrate_AppliedAtIsISO8601 — applied_at timestamp format is valid import ( + "context" "database/sql" "fmt" "io/fs" @@ -58,7 +59,7 @@ func (badDirFile) ReadDir(int) ([]fs.DirEntry, error) { func countVersions(t *testing.T, database *db.DB) int { t.Helper() var n int - err := database.QueryRow("SELECT COUNT(*) FROM schema_versions").Scan(&n) + err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_versions").Scan(&n) if err != nil { t.Fatalf("counting schema_versions: %v", err) } @@ -69,7 +70,7 @@ func countVersions(t *testing.T, database *db.DB) int { func hasVersion(t *testing.T, database *db.DB, filename string) bool { t.Helper() var v string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT version FROM schema_versions WHERE version = ?", filename, ).Scan(&v) if err == sql.ErrNoRows { @@ -85,7 +86,7 @@ func hasVersion(t *testing.T, database *db.DB, filename string) bool { func tableExists(t *testing.T, database *db.DB, name string) bool { t.Helper() var n string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name=?", name, ).Scan(&n) if err == sql.ErrNoRows { @@ -175,7 +176,7 @@ func TestMigrate_SkipsAlreadyApplied(t *testing.T) { // Confirm the row exists exactly once. var count int - if err := database.QueryRow("SELECT COUNT(*) FROM unique_check WHERE val='singleton'").Scan(&count); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM unique_check WHERE val='singleton'").Scan(&count); err != nil { t.Fatalf("counting unique_check: %v", err) } if count != 1 { @@ -246,7 +247,7 @@ func TestMigrate_OrderIsLexicographic(t *testing.T) { } var label string - if err := database.QueryRow("SELECT label FROM order_check LIMIT 1").Scan(&label); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT label FROM order_check LIMIT 1").Scan(&label); err != nil { t.Fatalf("selecting from order_check: %v", err) } if label != "second" { @@ -263,7 +264,7 @@ func TestMigrate_SeedExistingDatabase(t *testing.T) { // Manually create a table to simulate a previously-migrated database // that does not yet have schema_versions. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup: creating users table: %v", err) @@ -289,7 +290,7 @@ func TestMigrate_SeedExistingDatabase(t *testing.T) { // The users table must still have its original schema (no 'name' column), // proving the DROP/CREATE did not run. - _, err := database.Exec("INSERT INTO users (id) VALUES (42)") + _, err := database.ExecContext(context.Background(), "INSERT INTO users (id) VALUES (42)") if err != nil { t.Errorf("users table appears to have been recreated (DROP ran): %v", err) } @@ -304,12 +305,12 @@ func TestMigrate_SeedDoesNotReRunMigrations(t *testing.T) { // Simulate an existing DB: create the "users" sentinel table so the seeding // heuristic fires, plus the table that the migration would modify. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup users: %v", err) } - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup existing: %v", err) @@ -335,7 +336,7 @@ func TestMigrate_SeedDoesNotReRunMigrations(t *testing.T) { // existing table should be empty — the INSERT was never executed (seeded only). var count int - if err := database.QueryRow("SELECT COUNT(*) FROM existing").Scan(&count); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM existing").Scan(&count); err != nil { t.Fatalf("counting existing: %v", err) } if count != 0 { @@ -357,7 +358,7 @@ func TestMigrate_SchemaVersionsAppliedAtRecorded(t *testing.T) { } var appliedAt string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT applied_at FROM schema_versions WHERE version = '001_ts.sql'", ).Scan(&appliedAt) if err != nil { @@ -381,7 +382,7 @@ func TestMigrate_AppliedAtIsISO8601(t *testing.T) { } var appliedAt string - if err := database.QueryRow( + if err := database.QueryRowContext(context.Background(), "SELECT applied_at FROM schema_versions WHERE version = '001_dt.sql'", ).Scan(&appliedAt); err != nil { t.Fatalf("querying applied_at: %v", err) @@ -538,7 +539,7 @@ func TestMigrate_SeedDetectionUsesKnownTable(t *testing.T) { database := openMemory(t) // Create only an unrelated table — not one of the known sentinel tables. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS unrelated (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup: %v", err) @@ -603,7 +604,7 @@ func TestMigrate_SchemaVersionsHasPrimaryKey(t *testing.T) { } // Attempting a duplicate insert must fail. - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), "INSERT INTO schema_versions (version, applied_at) VALUES ('001_pk.sql', datetime('now'))", ) if err == nil { @@ -617,7 +618,7 @@ func TestMigrate_SeedRecordsAllFilesFromFS(t *testing.T) { database := openMemory(t) // Create the users sentinel to trigger seeding on first call. - if _, err := database.Exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"); err != nil { + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"); err != nil { t.Fatalf("setup: %v", err) } diff --git a/Server/db/profile_queries.go b/Server/db/profile_queries.go index 027719e9..9310a9f0 100644 --- a/Server/db/profile_queries.go +++ b/Server/db/profile_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "fmt" "github.com/owncord/server/db/dbgen" @@ -9,8 +10,8 @@ import ( // UpdateUserProfile updates the username and avatar for the given user. // Returns ErrNotFound if the user does not exist. Returns an error wrapping // a UNIQUE constraint violation if the username is already taken. -func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) error { - result, err := d.q.UpdateUserProfile(dbCtx(), dbgen.UpdateUserProfileParams{ +func (d *DB) UpdateUserProfile(ctx context.Context, userID int64, username string, avatar *string) error { + result, err := d.q.UpdateUserProfile(ctx, dbgen.UpdateUserProfileParams{ Username: username, Avatar: avatar, ID: userID, @@ -29,8 +30,8 @@ func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) er } // UpdateUserPassword sets a new password hash for the given user. -func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error { - if err := d.q.UpdateUserPassword(dbCtx(), dbgen.UpdateUserPasswordParams{ +func (d *DB) UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error { + if err := d.q.UpdateUserPassword(ctx, dbgen.UpdateUserPasswordParams{ Password: newPasswordHash, ID: userID, }); err != nil { @@ -41,8 +42,8 @@ func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error { // ListUserSessions returns all sessions for the given user in a single query. // Results are ordered by created_at descending (newest first). -func (d *DB) ListUserSessions(userID int64) ([]Session, error) { - rows, err := d.q.ListUserSessions(dbCtx(), userID) +func (d *DB) ListUserSessions(ctx context.Context, userID int64) ([]Session, error) { + rows, err := d.q.ListUserSessions(ctx, userID) if err != nil { return nil, fmt.Errorf("ListUserSessions: %w", err) } @@ -56,8 +57,8 @@ func (d *DB) ListUserSessions(userID int64) ([]Session, error) { // DeleteSessionByID removes a session by its ID, but only if it belongs to // the specified user. Returns ErrNotFound if the session does not exist or // does not belong to the user. -func (d *DB) DeleteSessionByID(sessionID, userID int64) error { - result, err := d.q.DeleteSessionByID(dbCtx(), dbgen.DeleteSessionByIDParams{ +func (d *DB) DeleteSessionByID(ctx context.Context, sessionID, userID int64) error { + result, err := d.q.DeleteSessionByID(ctx, dbgen.DeleteSessionByIDParams{ ID: sessionID, UserID: userID, }) diff --git a/Server/db/profile_queries_test.go b/Server/db/profile_queries_test.go index 6dc6aed1..8ba2f669 100644 --- a/Server/db/profile_queries_test.go +++ b/Server/db/profile_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -8,17 +9,17 @@ import ( func TestUpdateUserProfile_UsernameAndAvatar(t *testing.T) { database := newTestDB(t) - id, err := database.CreateUser("profileuser", "hash", 4) + id, err := database.CreateUser(context.Background(), "profileuser", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } avatar := "https://example.com/avatar.png" - if err := database.UpdateUserProfile(id, "newname", &avatar); err != nil { + if err := database.UpdateUserProfile(context.Background(), id, "newname", &avatar); err != nil { t.Fatalf("UpdateUserProfile: %v", err) } - user, err := database.GetUserByID(id) + user, err := database.GetUserByID(context.Background(), id) if err != nil { t.Fatalf("GetUserByID: %v", err) } @@ -32,13 +33,13 @@ func TestUpdateUserProfile_UsernameAndAvatar(t *testing.T) { func TestUpdateUserProfile_UsernameOnly(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("keepavatar", "hash", 4) + id, _ := database.CreateUser(context.Background(), "keepavatar", "hash", 4) - if err := database.UpdateUserProfile(id, "renamed", nil); err != nil { + if err := database.UpdateUserProfile(context.Background(), id, "renamed", nil); err != nil { t.Fatalf("UpdateUserProfile: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if user.Username != "renamed" { t.Errorf("Username = %q, want %q", user.Username, "renamed") } @@ -49,10 +50,10 @@ func TestUpdateUserProfile_UsernameOnly(t *testing.T) { func TestUpdateUserProfile_DuplicateUsername(t *testing.T) { database := newTestDB(t) - database.CreateUser("existing", "hash", 4) - id2, _ := database.CreateUser("changeme", "hash", 4) + database.CreateUser(context.Background(), "existing", "hash", 4) + id2, _ := database.CreateUser(context.Background(), "changeme", "hash", 4) - err := database.UpdateUserProfile(id2, "existing", nil) + err := database.UpdateUserProfile(context.Background(), id2, "existing", nil) if err == nil { t.Error("UpdateUserProfile with duplicate username should return error") } @@ -60,7 +61,7 @@ func TestUpdateUserProfile_DuplicateUsername(t *testing.T) { func TestUpdateUserProfile_NonExistentUser(t *testing.T) { database := newTestDB(t) - err := database.UpdateUserProfile(99999, "ghost", nil) + err := database.UpdateUserProfile(context.Background(), 99999, "ghost", nil) if err == nil { t.Error("UpdateUserProfile for non-existent user should return error") } @@ -70,13 +71,13 @@ func TestUpdateUserProfile_NonExistentUser(t *testing.T) { func TestUpdateUserPassword_Success(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("pwuser", "oldhash", 4) + id, _ := database.CreateUser(context.Background(), "pwuser", "oldhash", 4) - if err := database.UpdateUserPassword(id, "newhash"); err != nil { + if err := database.UpdateUserPassword(context.Background(), id, "newhash"); err != nil { t.Fatalf("UpdateUserPassword: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if user.PasswordHash != "newhash" { t.Errorf("PasswordHash = %q, want %q", user.PasswordHash, "newhash") } @@ -86,12 +87,12 @@ func TestUpdateUserPassword_Success(t *testing.T) { func TestListUserSessions_ReturnsSessions(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("sessuser", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "sessuser", "hash", 4) - database.CreateSession(uid, "tok1", "Chrome", "1.2.3.4") - database.CreateSession(uid, "tok2", "Firefox", "5.6.7.8") + database.CreateSession(context.Background(), uid, "tok1", "Chrome", "1.2.3.4") + database.CreateSession(context.Background(), uid, "tok2", "Firefox", "5.6.7.8") - sessions, err := database.ListUserSessions(uid) + sessions, err := database.ListUserSessions(context.Background(), uid) if err != nil { t.Fatalf("ListUserSessions: %v", err) } @@ -102,9 +103,9 @@ func TestListUserSessions_ReturnsSessions(t *testing.T) { func TestListUserSessions_EmptyArray(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("nosess", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "nosess", "hash", 4) - sessions, err := database.ListUserSessions(uid) + sessions, err := database.ListUserSessions(context.Background(), uid) if err != nil { t.Fatalf("ListUserSessions: %v", err) } @@ -118,13 +119,13 @@ func TestListUserSessions_EmptyArray(t *testing.T) { func TestListUserSessions_DoesNotReturnOtherUsers(t *testing.T) { database := newTestDB(t) - uid1, _ := database.CreateUser("user1", "hash", 4) - uid2, _ := database.CreateUser("user2", "hash", 4) + uid1, _ := database.CreateUser(context.Background(), "user1", "hash", 4) + uid2, _ := database.CreateUser(context.Background(), "user2", "hash", 4) - database.CreateSession(uid1, "tok-u1", "Chrome", "1.2.3.4") - database.CreateSession(uid2, "tok-u2", "Firefox", "5.6.7.8") + database.CreateSession(context.Background(), uid1, "tok-u1", "Chrome", "1.2.3.4") + database.CreateSession(context.Background(), uid2, "tok-u2", "Firefox", "5.6.7.8") - sessions, _ := database.ListUserSessions(uid1) + sessions, _ := database.ListUserSessions(context.Background(), uid1) if len(sessions) != 1 { t.Errorf("len(sessions) = %d, want 1", len(sessions)) } @@ -134,16 +135,16 @@ func TestListUserSessions_DoesNotReturnOtherUsers(t *testing.T) { func TestDeleteSessionByID_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("delsess", "hash", 4) - sessID, _ := database.CreateSession(uid, "deltok", "Chrome", "1.2.3.4") + uid, _ := database.CreateUser(context.Background(), "delsess", "hash", 4) + sessID, _ := database.CreateSession(context.Background(), uid, "deltok", "Chrome", "1.2.3.4") - err := database.DeleteSessionByID(sessID, uid) + err := database.DeleteSessionByID(context.Background(), sessID, uid) if err != nil { t.Fatalf("DeleteSessionByID: %v", err) } // Session should be gone. - sess, _ := database.GetSessionByTokenHash("deltok") + sess, _ := database.GetSessionByTokenHash(context.Background(), "deltok") if sess != nil { t.Error("session should have been deleted") } @@ -151,11 +152,11 @@ func TestDeleteSessionByID_Success(t *testing.T) { func TestDeleteSessionByID_WrongOwner(t *testing.T) { database := newTestDB(t) - uid1, _ := database.CreateUser("owner1", "hash", 4) - uid2, _ := database.CreateUser("owner2", "hash", 4) - sessID, _ := database.CreateSession(uid1, "ownertok", "Chrome", "1.2.3.4") + uid1, _ := database.CreateUser(context.Background(), "owner1", "hash", 4) + uid2, _ := database.CreateUser(context.Background(), "owner2", "hash", 4) + sessID, _ := database.CreateSession(context.Background(), uid1, "ownertok", "Chrome", "1.2.3.4") - err := database.DeleteSessionByID(sessID, uid2) + err := database.DeleteSessionByID(context.Background(), sessID, uid2) if err == nil { t.Error("DeleteSessionByID should fail when user does not own the session") } @@ -163,9 +164,9 @@ func TestDeleteSessionByID_WrongOwner(t *testing.T) { func TestDeleteSessionByID_NotFound(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("delnf", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "delnf", "hash", 4) - err := database.DeleteSessionByID(99999, uid) + err := database.DeleteSessionByID(context.Background(), 99999, uid) if err == nil { t.Error("DeleteSessionByID should fail for non-existent session") } diff --git a/Server/db/role_invite_queries_test.go b/Server/db/role_invite_queries_test.go index 24f04f66..8b38a063 100644 --- a/Server/db/role_invite_queries_test.go +++ b/Server/db/role_invite_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -9,7 +10,7 @@ import ( func TestGetRoleByID_Found(t *testing.T) { database := newTestDB(t) - role, err := database.GetRoleByID(4) // Member — inserted by migration + role, err := database.GetRoleByID(context.Background(), 4) // Member — inserted by migration if err != nil { t.Fatalf("GetRoleByID: %v", err) } @@ -27,7 +28,7 @@ func TestGetRoleByID_Found(t *testing.T) { func TestGetRoleByID_NotFound(t *testing.T) { database := newTestDB(t) - role, err := database.GetRoleByID(9999) + role, err := database.GetRoleByID(context.Background(), 9999) if err != nil { t.Fatalf("GetRoleByID(not found): %v", err) } @@ -39,7 +40,7 @@ func TestGetRoleByID_NotFound(t *testing.T) { func TestGetRoleByID_OwnerHasAllPermissions(t *testing.T) { database := newTestDB(t) - role, err := database.GetRoleByID(1) // Owner + role, err := database.GetRoleByID(context.Background(), 1) // Owner if err != nil { t.Fatalf("GetRoleByID Owner: %v", err) } @@ -55,8 +56,8 @@ func TestGetRoleByID_OwnerHasAllPermissions(t *testing.T) { func TestGetRoleByID_IsDefaultField(t *testing.T) { database := newTestDB(t) - owner, _ := database.GetRoleByID(1) - member, _ := database.GetRoleByID(4) + owner, _ := database.GetRoleByID(context.Background(), 1) + member, _ := database.GetRoleByID(context.Background(), 4) if owner.IsDefault { t.Error("Owner.IsDefault = true, want false") @@ -72,7 +73,7 @@ func TestGetRoleByID_IsDefaultField(t *testing.T) { func TestListRoles_ReturnsFourDefaultRoles(t *testing.T) { database := newTestDB(t) - roles, err := database.ListRoles() + roles, err := database.ListRoles(context.Background()) if err != nil { t.Fatalf("ListRoles: %v", err) } @@ -84,7 +85,7 @@ func TestListRoles_ReturnsFourDefaultRoles(t *testing.T) { func TestListRoles_OrderedByPositionDesc(t *testing.T) { database := newTestDB(t) - roles, err := database.ListRoles() + roles, err := database.ListRoles(context.Background()) if err != nil { t.Fatalf("ListRoles: %v", err) } @@ -101,12 +102,12 @@ func TestListRoles_OrderedByPositionDesc(t *testing.T) { func TestGetUserWithRole_Found(t *testing.T) { database := newTestDB(t) - uid, err := database.CreateUser("joinuser", "hash", 4) // Member role + uid, err := database.CreateUser(context.Background(), "joinuser", "hash", 4) // Member role if err != nil { t.Fatalf("CreateUser: %v", err) } - user, role, err := database.GetUserWithRole(uid) + user, role, err := database.GetUserWithRole(context.Background(), uid) if err != nil { t.Fatalf("GetUserWithRole: %v", err) } @@ -133,7 +134,7 @@ func TestGetUserWithRole_Found(t *testing.T) { func TestGetUserWithRole_NotFound(t *testing.T) { database := newTestDB(t) - user, role, err := database.GetUserWithRole(9999) + user, role, err := database.GetUserWithRole(context.Background(), 9999) if err != nil { t.Fatalf("GetUserWithRole(not found): %v", err) } @@ -144,9 +145,9 @@ func TestGetUserWithRole_NotFound(t *testing.T) { func TestGetUserWithRole_BoolConversions(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("booluser", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "booluser", "hash", 4) - user, role, err := database.GetUserWithRole(uid) + user, role, err := database.GetUserWithRole(context.Background(), uid) if err != nil { t.Fatalf("GetUserWithRole: %v", err) } @@ -165,7 +166,7 @@ func TestGetUserWithRole_BoolConversions(t *testing.T) { func TestListInvites_Empty(t *testing.T) { database := newTestDB(t) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites empty: %v", err) } @@ -176,13 +177,13 @@ func TestListInvites_Empty(t *testing.T) { func TestListInvites_Multiple(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("listowner", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "listowner", "hash", 4) - _, _ = database.CreateInvite(uid, 1, nil) - _, _ = database.CreateInvite(uid, 5, nil) - _, _ = database.CreateInvite(uid, 0, nil) + _, _ = database.CreateInvite(context.Background(), uid, 1, nil) + _, _ = database.CreateInvite(context.Background(), uid, 5, nil) + _, _ = database.CreateInvite(context.Background(), uid, 0, nil) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites multiple: %v", err) } @@ -193,13 +194,13 @@ func TestListInvites_Multiple(t *testing.T) { func TestListInvites_IncludesRevokedInvites(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("revokelistowner", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "revokelistowner", "hash", 4) - code, _ := database.CreateInvite(uid, 1, nil) - _ = database.RevokeInvite(code) - _, _ = database.CreateInvite(uid, 0, nil) // active + code, _ := database.CreateInvite(context.Background(), uid, 1, nil) + _ = database.RevokeInvite(context.Background(), code) + _, _ = database.CreateInvite(context.Background(), uid, 0, nil) // active - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites with revoked: %v", err) } diff --git a/Server/db/role_queries.go b/Server/db/role_queries.go index 5c63c376..3b05637f 100644 --- a/Server/db/role_queries.go +++ b/Server/db/role_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -23,8 +24,8 @@ func roleFromGen(r dbgen.Role) *Role { } // GetRoleByID returns the role with the given ID, or nil if not found. -func (d *DB) GetRoleByID(id int64) (*Role, error) { - r, err := d.q.GetRoleByID(dbCtx(), id) +func (d *DB) GetRoleByID(ctx context.Context, id int64) (*Role, error) { + r, err := d.q.GetRoleByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -35,8 +36,8 @@ func (d *DB) GetRoleByID(id int64) (*Role, error) { } // ListRoles returns all roles ordered by position descending. -func (d *DB) ListRoles() ([]*Role, error) { - rows, err := d.q.ListRoles(dbCtx()) +func (d *DB) ListRoles(ctx context.Context) ([]*Role, error) { + rows, err := d.q.ListRoles(ctx) if err != nil { return nil, fmt.Errorf("ListRoles: %w", err) } @@ -51,8 +52,8 @@ func (d *DB) ListRoles() ([]*Role, error) { // Unlike GetUserWithRole, this does not fetch sensitive user columns (password, // TOTP secret). Use this on hot paths like permission checks. // Returns (nil, nil) when the user is not found. -func (d *DB) GetRoleForUser(userID int64) (*Role, error) { - r, err := d.q.GetRoleForUser(dbCtx(), userID) +func (d *DB) GetRoleForUser(ctx context.Context, userID int64) (*Role, error) { + r, err := d.q.GetRoleForUser(ctx, userID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -64,8 +65,8 @@ func (d *DB) GetRoleForUser(userID int64) (*Role, error) { // GetUserWithRole returns the user and their role in a single query. // Returns (nil, nil, nil) when the user is not found. -func (d *DB) GetUserWithRole(userID int64) (*User, *Role, error) { - row := d.sqlDB.QueryRow( +func (d *DB) GetUserWithRole(ctx context.Context, userID int64) (*User, *Role, error) { + row := d.sqlDB.QueryRowContext(ctx, `SELECT u.id, u.username, u.password, u.avatar, u.role_id, u.totp_secret, u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go index 7054cec8..775a8efa 100644 --- a/Server/db/voice_queries.go +++ b/Server/db/voice_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -27,8 +28,8 @@ func newVoiceJoinToken() string { // joined_at doubles as an opaque join-instance token so stale cleanup can // target one specific voice session even if the user later rejoins the same // channel. -func (d *DB) JoinVoiceChannel(userID, channelID int64) error { - if err := d.q.JoinVoiceChannel(dbCtx(), dbgen.JoinVoiceChannelParams{ +func (d *DB) JoinVoiceChannel(ctx context.Context, userID, channelID int64) error { + if err := d.q.JoinVoiceChannel(ctx, dbgen.JoinVoiceChannelParams{ UserID: userID, ChannelID: channelID, JoinedAt: newVoiceJoinToken(), @@ -42,8 +43,8 @@ func (d *DB) JoinVoiceChannel(userID, channelID int64) error { // channel has fewer than maxUsers participants. Returns ErrChannelFull when // the channel is at capacity. This prevents the TOCTOU race where two // concurrent joins both observe capacity and both succeed. -func (d *DB) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error { - res, err := d.q.JoinVoiceChannelIfCapacity(dbCtx(), dbgen.JoinVoiceChannelIfCapacityParams{ +func (d *DB) JoinVoiceChannelIfCapacity(ctx context.Context, userID, channelID int64, maxUsers int) error { + res, err := d.q.JoinVoiceChannelIfCapacity(ctx, dbgen.JoinVoiceChannelIfCapacityParams{ UserID: userID, ChannelID: channelID, JoinedAt: newVoiceJoinToken(), @@ -62,8 +63,8 @@ func (d *DB) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) e // LeaveVoiceChannel removes the user's voice state entirely. // It is safe to call when the user is not in any voice channel. -func (d *DB) LeaveVoiceChannel(userID int64) error { - if err := d.q.LeaveVoiceChannel(dbCtx(), userID); err != nil { +func (d *DB) LeaveVoiceChannel(ctx context.Context, userID int64) error { + if err := d.q.LeaveVoiceChannel(ctx, userID); err != nil { return fmt.Errorf("LeaveVoiceChannel: %w", err) } return nil @@ -72,8 +73,8 @@ func (d *DB) LeaveVoiceChannel(userID int64) error { // LeaveVoiceChannelIfMatch removes the user's voice state only if the row // still points at expectedChannelID and matches the expected join token. // Returns true if a row was deleted. -func (d *DB) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { - result, err := d.q.LeaveVoiceChannelIfMatch(dbCtx(), dbgen.LeaveVoiceChannelIfMatchParams{ +func (d *DB) LeaveVoiceChannelIfMatch(ctx context.Context, userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { + result, err := d.q.LeaveVoiceChannelIfMatch(ctx, dbgen.LeaveVoiceChannelIfMatchParams{ UserID: userID, ChannelID: expectedChannelID, JoinedAt: expectedJoinedAt, @@ -87,8 +88,8 @@ func (d *DB) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJ // GetVoiceState returns the current voice state for the given user, // or nil if the user is not in any voice channel. -func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { - r, err := d.q.GetUserVoiceState(dbCtx(), userID) +func (d *DB) GetVoiceState(ctx context.Context, userID int64) (*VoiceState, error) { + r, err := d.q.GetUserVoiceState(ctx, userID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -111,8 +112,8 @@ func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { // GetChannelVoiceStates returns all voice states for users currently in the // given voice channel. -func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { - rows, err := d.q.GetChannelVoiceStates(dbCtx(), channelID) +func (d *DB) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]VoiceState, error) { + rows, err := d.q.GetChannelVoiceStates(ctx, channelID) if err != nil { return nil, fmt.Errorf("GetChannelVoiceStates: %w", err) } @@ -135,8 +136,8 @@ func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { // GetAllVoiceStates returns voice states across all voice channels in a single // query. Used at startup to build the ready payload without N+1 per-channel queries. -func (d *DB) GetAllVoiceStates() ([]VoiceState, error) { - rows, err := d.q.GetAllVoiceStates(dbCtx()) +func (d *DB) GetAllVoiceStates(ctx context.Context) ([]VoiceState, error) { + rows, err := d.q.GetAllVoiceStates(ctx) if err != nil { return nil, fmt.Errorf("GetAllVoiceStates: %w", err) } @@ -159,8 +160,8 @@ func (d *DB) GetAllVoiceStates() ([]VoiceState, error) { // UpdateVoiceMute sets the muted field for the given user's voice state. // It is safe to call when the user is not in any channel (no-op). -func (d *DB) UpdateVoiceMute(userID int64, muted bool) error { - if err := d.q.UpdateVoiceMute(dbCtx(), dbgen.UpdateVoiceMuteParams{ +func (d *DB) UpdateVoiceMute(ctx context.Context, userID int64, muted bool) error { + if err := d.q.UpdateVoiceMute(ctx, dbgen.UpdateVoiceMuteParams{ Muted: b2i64(muted), UserID: userID, }); err != nil { @@ -171,8 +172,8 @@ func (d *DB) UpdateVoiceMute(userID int64, muted bool) error { // UpdateVoiceDeafen sets the deafened field for the given user's voice state. // It is safe to call when the user is not in any channel (no-op). -func (d *DB) UpdateVoiceDeafen(userID int64, deafened bool) error { - if err := d.q.UpdateVoiceDeafen(dbCtx(), dbgen.UpdateVoiceDeafenParams{ +func (d *DB) UpdateVoiceDeafen(ctx context.Context, userID int64, deafened bool) error { + if err := d.q.UpdateVoiceDeafen(ctx, dbgen.UpdateVoiceDeafenParams{ Deafened: b2i64(deafened), UserID: userID, }); err != nil { @@ -183,8 +184,8 @@ func (d *DB) UpdateVoiceDeafen(userID int64, deafened bool) error { // ClearVoiceState removes a user's voice state on disconnect. // Equivalent to LeaveVoiceChannel but named to clarify the disconnect use case. -func (d *DB) ClearVoiceState(userID int64) error { - if err := d.q.ClearVoiceState(dbCtx(), userID); err != nil { +func (d *DB) ClearVoiceState(ctx context.Context, userID int64) error { + if err := d.q.ClearVoiceState(ctx, userID); err != nil { return fmt.Errorf("ClearVoiceState: %w", err) } return nil @@ -192,8 +193,8 @@ func (d *DB) ClearVoiceState(userID int64) error { // ClearAllVoiceStates removes all voice state rows. Called on server startup // to clear stale state from a previous run. -func (d *DB) ClearAllVoiceStates() error { - if err := d.q.ClearAllVoiceStates(dbCtx()); err != nil { +func (d *DB) ClearAllVoiceStates(ctx context.Context) error { + if err := d.q.ClearAllVoiceStates(ctx); err != nil { return fmt.Errorf("ClearAllVoiceStates: %w", err) } return nil @@ -202,8 +203,8 @@ func (d *DB) ClearAllVoiceStates() error { // CountActiveCameras returns the number of users with camera enabled in the // given voice channel. Uses the DB as source of truth (race-free via SQLite // serialization) rather than querying LiveKit. -func (d *DB) CountActiveCameras(channelID int64) (int, error) { - count, err := d.q.CountActiveCameras(dbCtx(), channelID) +func (d *DB) CountActiveCameras(ctx context.Context, channelID int64) (int, error) { + count, err := d.q.CountActiveCameras(ctx, channelID) if err != nil { return 0, fmt.Errorf("CountActiveCameras: %w", err) } @@ -211,8 +212,8 @@ func (d *DB) CountActiveCameras(channelID int64) (int, error) { } // UpdateVoiceCamera sets the camera field for the given user's voice state. -func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error { - if err := d.q.UpdateVoiceCamera(dbCtx(), dbgen.UpdateVoiceCameraParams{ +func (d *DB) UpdateVoiceCamera(ctx context.Context, userID int64, camera bool) error { + if err := d.q.UpdateVoiceCamera(ctx, dbgen.UpdateVoiceCameraParams{ Camera: b2i64(camera), UserID: userID, }); err != nil { @@ -224,8 +225,8 @@ func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error { // EnableCameraIfUnderLimit atomically enables a user's camera only if the // channel has not yet reached maxVideo active cameras. Returns true if the // camera was enabled, false if the limit was already reached. -func (d *DB) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { - res, err := d.q.EnableCameraIfUnderLimit(dbCtx(), dbgen.EnableCameraIfUnderLimitParams{ +func (d *DB) EnableCameraIfUnderLimit(ctx context.Context, userID, channelID int64, maxVideo int) (bool, error) { + res, err := d.q.EnableCameraIfUnderLimit(ctx, dbgen.EnableCameraIfUnderLimitParams{ UserID: userID, ChannelID: channelID, ChannelID_2: channelID, @@ -242,8 +243,8 @@ func (d *DB) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bo } // UpdateVoiceScreenshare sets the screenshare field for the given user's voice state. -func (d *DB) UpdateVoiceScreenshare(userID int64, screenshare bool) error { - if err := d.q.UpdateVoiceScreenshare(dbCtx(), dbgen.UpdateVoiceScreenshareParams{ +func (d *DB) UpdateVoiceScreenshare(ctx context.Context, userID int64, screenshare bool) error { + if err := d.q.UpdateVoiceScreenshare(ctx, dbgen.UpdateVoiceScreenshareParams{ Screenshare: b2i64(screenshare), UserID: userID, }); err != nil { @@ -254,9 +255,9 @@ func (d *DB) UpdateVoiceScreenshare(userID int64, screenshare bool) error { // CountChannelVoiceUsers returns the number of users currently in the given // voice channel. -func (d *DB) CountChannelVoiceUsers(channelID int64) (int, error) { +func (d *DB) CountChannelVoiceUsers(ctx context.Context, channelID int64) (int, error) { var count int - err := d.sqlDB.QueryRow( + err := d.sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM voice_states WHERE channel_id = ?`, channelID, ).Scan(&count) diff --git a/Server/db/voice_queries_test.go b/Server/db/voice_queries_test.go index 159e4c88..7fc94d3b 100644 --- a/Server/db/voice_queries_test.go +++ b/Server/db/voice_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "testing/fstest" @@ -60,7 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); // seedVoiceUser creates a user and returns its ID. func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 { t.Helper() - id, err := database.CreateUser(username, "hash", 4) + id, err := database.CreateUser(context.Background(), username, "hash", 4) if err != nil { t.Fatalf("seedVoiceUser: %v", err) } @@ -70,7 +71,7 @@ func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 { // seedVoiceChannel creates a voice-type channel and returns its ID. func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("seedVoiceChannel: %v", err) } @@ -84,11 +85,11 @@ func TestVoice_JoinVoiceChannel_Success(t *testing.T) { userID := seedVoiceUser(t, database, "alice") chanID := seedVoiceChannel(t, database, "general-voice") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -115,15 +116,15 @@ func TestVoice_JoinVoiceChannel_ReplacesExistingState(t *testing.T) { chan1 := seedVoiceChannel(t, database, "voice-1") chan2 := seedVoiceChannel(t, database, "voice-2") - if err := database.JoinVoiceChannel(userID, chan1); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan1); err != nil { t.Fatalf("first JoinVoiceChannel: %v", err) } // Join a different channel — should replace the old state. - if err := database.JoinVoiceChannel(userID, chan2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan2); err != nil { t.Fatalf("second JoinVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -140,11 +141,11 @@ func TestVoice_JoinVoiceChannel_SameChannel_Idempotent(t *testing.T) { userID := seedVoiceUser(t, database, "carol") chanID := seedVoiceChannel(t, database, "voice-same") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("first join: %v", err) } // Joining same channel again should not error. - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("second join same channel: %v", err) } } @@ -156,14 +157,14 @@ func TestVoice_LeaveVoiceChannel_ClearsState(t *testing.T) { userID := seedVoiceUser(t, database, "dave") chanID := seedVoiceChannel(t, database, "voice-leave") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.LeaveVoiceChannel(userID); err != nil { + if err := database.LeaveVoiceChannel(context.Background(), userID); err != nil { t.Fatalf("LeaveVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState after leave: %v", err) } @@ -177,7 +178,7 @@ func TestVoice_LeaveVoiceChannel_NoState_NoError(t *testing.T) { userID := seedVoiceUser(t, database, "eve") // Leaving when not in any channel should not error. - if err := database.LeaveVoiceChannel(userID); err != nil { + if err := database.LeaveVoiceChannel(context.Background(), userID); err != nil { t.Fatalf("LeaveVoiceChannel (not in channel): %v", err) } } @@ -188,7 +189,7 @@ func TestVoice_GetVoiceState_NotFound(t *testing.T) { database := newVoiceTestDB(t) userID := seedVoiceUser(t, database, "frank") - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(not found): %v", err) } @@ -202,11 +203,11 @@ func TestVoice_GetVoiceState_IncludesUsername(t *testing.T) { userID := seedVoiceUser(t, database, "grace") chanID := seedVoiceChannel(t, database, "voice-username") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -224,7 +225,7 @@ func TestVoice_GetChannelVoiceStates_Empty(t *testing.T) { database := newVoiceTestDB(t) chanID := seedVoiceChannel(t, database, "empty-voice") - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -241,18 +242,18 @@ func TestVoice_GetChannelVoiceStates_MultipleUsers(t *testing.T) { chanID := seedVoiceChannel(t, database, "multi-voice") otherChan := seedVoiceChannel(t, database, "other-voice") - if err := database.JoinVoiceChannel(u1, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil { t.Fatalf("join u1: %v", err) } - if err := database.JoinVoiceChannel(u2, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2, chanID); err != nil { t.Fatalf("join u2: %v", err) } // u3 joins a different channel — should not appear. - if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u3, otherChan); err != nil { t.Fatalf("join u3: %v", err) } - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -275,14 +276,14 @@ func TestVoice_UpdateVoiceMute_True(t *testing.T) { userID := seedVoiceUser(t, database, "kate") chanID := seedVoiceChannel(t, database, "voice-mute") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceMute(userID, true); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceMute(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Muted { t.Error("Muted = false after UpdateVoiceMute(true)") } @@ -293,17 +294,17 @@ func TestVoice_UpdateVoiceMute_False(t *testing.T) { userID := seedVoiceUser(t, database, "leo") chanID := seedVoiceChannel(t, database, "voice-unmute") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceMute(userID, true); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceMute(true): %v", err) } - if err := database.UpdateVoiceMute(userID, false); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceMute(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Muted { t.Error("Muted = true after UpdateVoiceMute(false), want false") } @@ -314,7 +315,7 @@ func TestVoice_UpdateVoiceMute_NotInChannel_NoError(t *testing.T) { userID := seedVoiceUser(t, database, "mia") // Muting when not in a channel should not error. - if err := database.UpdateVoiceMute(userID, true); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceMute for non-member: %v", err) } } @@ -326,14 +327,14 @@ func TestVoice_UpdateVoiceDeafen_True(t *testing.T) { userID := seedVoiceUser(t, database, "noah") chanID := seedVoiceChannel(t, database, "voice-deafen") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceDeafen(userID, true); err != nil { + if err := database.UpdateVoiceDeafen(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceDeafen(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Deafened { t.Error("Deafened = false after UpdateVoiceDeafen(true)") } @@ -344,17 +345,17 @@ func TestVoice_UpdateVoiceDeafen_False(t *testing.T) { userID := seedVoiceUser(t, database, "olivia") chanID := seedVoiceChannel(t, database, "voice-undeafen") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceDeafen(userID, true); err != nil { + if err := database.UpdateVoiceDeafen(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceDeafen(true): %v", err) } - if err := database.UpdateVoiceDeafen(userID, false); err != nil { + if err := database.UpdateVoiceDeafen(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceDeafen(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Deafened { t.Error("Deafened = true after UpdateVoiceDeafen(false), want false") } @@ -367,14 +368,14 @@ func TestVoice_ClearVoiceState_RemovesState(t *testing.T) { userID := seedVoiceUser(t, database, "pedro") chanID := seedVoiceChannel(t, database, "voice-clear") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.ClearVoiceState(userID); err != nil { + if err := database.ClearVoiceState(context.Background(), userID); err != nil { t.Fatalf("ClearVoiceState: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState after clear: %v", err) } @@ -387,7 +388,7 @@ func TestVoice_ClearVoiceState_NotInChannel_NoError(t *testing.T) { database := newVoiceTestDB(t) userID := seedVoiceUser(t, database, "quinn") - if err := database.ClearVoiceState(userID); err != nil { + if err := database.ClearVoiceState(context.Background(), userID); err != nil { t.Fatalf("ClearVoiceState for non-member: %v", err) } } @@ -399,11 +400,11 @@ func TestVoice_GetChannelVoiceStates_IncludesUsername(t *testing.T) { u1 := seedVoiceUser(t, database, "rachel") chanID := seedVoiceChannel(t, database, "voice-name-check") - if err := database.JoinVoiceChannel(u1, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -422,14 +423,14 @@ func TestVoice_UpdateVoiceCamera_True(t *testing.T) { userID := seedVoiceUser(t, database, "cam-on") chanID := seedVoiceChannel(t, database, "voice-camera") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Camera { t.Error("Camera = false after UpdateVoiceCamera(true)") } @@ -440,17 +441,17 @@ func TestVoice_UpdateVoiceCamera_False(t *testing.T) { userID := seedVoiceUser(t, database, "cam-off") chanID := seedVoiceChannel(t, database, "voice-camera-off") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera(true): %v", err) } - if err := database.UpdateVoiceCamera(userID, false); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceCamera(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Camera { t.Error("Camera = true after UpdateVoiceCamera(false), want false") } @@ -460,7 +461,7 @@ func TestVoice_UpdateVoiceCamera_NotInChannel_NoError(t *testing.T) { database := newVoiceTestDB(t) userID := seedVoiceUser(t, database, "cam-noop") - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera for non-member: %v", err) } } @@ -472,14 +473,14 @@ func TestVoice_UpdateVoiceScreenshare_True(t *testing.T) { userID := seedVoiceUser(t, database, "share-on") chanID := seedVoiceChannel(t, database, "voice-screen") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceScreenshare(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Screenshare { t.Error("Screenshare = false after UpdateVoiceScreenshare(true)") } @@ -490,17 +491,17 @@ func TestVoice_UpdateVoiceScreenshare_False(t *testing.T) { userID := seedVoiceUser(t, database, "share-off") chanID := seedVoiceChannel(t, database, "voice-screen-off") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceScreenshare(true): %v", err) } - if err := database.UpdateVoiceScreenshare(userID, false); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceScreenshare(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Screenshare { t.Error("Screenshare = true after UpdateVoiceScreenshare(false), want false") } @@ -512,7 +513,7 @@ func TestVoice_CountChannelVoiceUsers_Empty(t *testing.T) { database := newVoiceTestDB(t) chanID := seedVoiceChannel(t, database, "count-empty") - count, err := database.CountChannelVoiceUsers(chanID) + count, err := database.CountChannelVoiceUsers(context.Background(), chanID) if err != nil { t.Fatalf("CountChannelVoiceUsers: %v", err) } @@ -529,18 +530,18 @@ func TestVoice_CountChannelVoiceUsers_Multiple(t *testing.T) { chanID := seedVoiceChannel(t, database, "count-multi") otherChan := seedVoiceChannel(t, database, "count-other") - if err := database.JoinVoiceChannel(u1, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil { t.Fatalf("join u1: %v", err) } - if err := database.JoinVoiceChannel(u2, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2, chanID); err != nil { t.Fatalf("join u2: %v", err) } // u3 joins a different channel — should not be counted. - if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u3, otherChan); err != nil { t.Fatalf("join u3: %v", err) } - count, err := database.CountChannelVoiceUsers(chanID) + count, err := database.CountChannelVoiceUsers(context.Background(), chanID) if err != nil { t.Fatalf("CountChannelVoiceUsers: %v", err) } @@ -558,19 +559,19 @@ func TestVoice_ClearAllVoiceStates_RemovesAll(t *testing.T) { chan1 := seedVoiceChannel(t, database, "clear-ch1") chan2 := seedVoiceChannel(t, database, "clear-ch2") - if err := database.JoinVoiceChannel(u1, chan1); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chan1); err != nil { t.Fatalf("join u1: %v", err) } - if err := database.JoinVoiceChannel(u2, chan2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2, chan2); err != nil { t.Fatalf("join u2: %v", err) } - if err := database.ClearAllVoiceStates(); err != nil { + if err := database.ClearAllVoiceStates(context.Background()); err != nil { t.Fatalf("ClearAllVoiceStates: %v", err) } - s1, _ := database.GetVoiceState(u1) - s2, _ := database.GetVoiceState(u2) + s1, _ := database.GetVoiceState(context.Background(), u1) + s2, _ := database.GetVoiceState(context.Background(), u2) if s1 != nil || s2 != nil { t.Error("voice states still exist after ClearAllVoiceStates") } @@ -579,7 +580,7 @@ func TestVoice_ClearAllVoiceStates_RemovesAll(t *testing.T) { func TestVoice_ClearAllVoiceStates_EmptyTable_NoError(t *testing.T) { database := newVoiceTestDB(t) - if err := database.ClearAllVoiceStates(); err != nil { + if err := database.ClearAllVoiceStates(context.Background()); err != nil { t.Fatalf("ClearAllVoiceStates on empty table: %v", err) } } @@ -593,22 +594,22 @@ func TestVoice_JoinVoiceChannel_ResetsCameraAndScreenshare(t *testing.T) { chan2 := seedVoiceChannel(t, database, "voice-reset2") // Join, enable camera and screenshare. - if err := database.JoinVoiceChannel(userID, chan1); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan1); err != nil { t.Fatalf("first join: %v", err) } - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera: %v", err) } - if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceScreenshare: %v", err) } // Join a different channel — camera and screenshare should be reset. - if err := database.JoinVoiceChannel(userID, chan2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan2); err != nil { t.Fatalf("second join: %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil { t.Fatal("GetVoiceState returned nil after re-join") } @@ -627,12 +628,12 @@ func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) { userID := seedVoiceUser(t, database, "av-fields") chanID := seedVoiceChannel(t, database, "voice-av-fields") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } // Initially both should be false. - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil { t.Fatal("GetVoiceState returned nil") } @@ -644,10 +645,10 @@ func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) { } // Enable both. - _ = database.UpdateVoiceCamera(userID, true) - _ = database.UpdateVoiceScreenshare(userID, true) + _ = database.UpdateVoiceCamera(context.Background(), userID, true) + _ = database.UpdateVoiceScreenshare(context.Background(), userID, true) - state, _ = database.GetVoiceState(userID) + state, _ = database.GetVoiceState(context.Background(), userID) if state == nil { t.Fatal("GetVoiceState returned nil after update") } @@ -666,12 +667,12 @@ func TestVoice_GetChannelVoiceStates_IncludesCameraAndScreenshare(t *testing.T) userID := seedVoiceUser(t, database, "chan-av") chanID := seedVoiceChannel(t, database, "voice-chan-av") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - _ = database.UpdateVoiceCamera(userID, true) + _ = database.UpdateVoiceCamera(context.Background(), userID, true) - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -691,10 +692,10 @@ func TestVoice_JoinVoiceChannel_SameChannel_RefreshesJoinToken(t *testing.T) { userID := seedVoiceUser(t, database, "same-channel-token") chanID := seedVoiceChannel(t, database, "voice-same-token") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("first JoinVoiceChannel: %v", err) } - first, err := database.GetVoiceState(userID) + first, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(first): %v", err) } @@ -702,10 +703,10 @@ func TestVoice_JoinVoiceChannel_SameChannel_RefreshesJoinToken(t *testing.T) { t.Fatal("first join token missing") } - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("second JoinVoiceChannel: %v", err) } - second, err := database.GetVoiceState(userID) + second, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(second): %v", err) } @@ -722,10 +723,10 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin userID := seedVoiceUser(t, database, "stale-delete") chanID := seedVoiceChannel(t, database, "voice-stale-delete") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("first JoinVoiceChannel: %v", err) } - first, err := database.GetVoiceState(userID) + first, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(first): %v", err) } @@ -733,10 +734,10 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin t.Fatal("GetVoiceState(first) returned nil") } - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("second JoinVoiceChannel: %v", err) } - second, err := database.GetVoiceState(userID) + second, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(second): %v", err) } @@ -744,7 +745,7 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin t.Fatal("GetVoiceState(second) returned nil") } - deleted, err := database.LeaveVoiceChannelIfMatch(userID, chanID, first.JoinedAt) + deleted, err := database.LeaveVoiceChannelIfMatch(context.Background(), userID, chanID, first.JoinedAt) if err != nil { t.Fatalf("LeaveVoiceChannelIfMatch: %v", err) } @@ -752,7 +753,7 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin t.Fatal("stale join token deleted the replacement same-channel row") } - current, err := database.GetVoiceState(userID) + current, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(current): %v", err) } diff --git a/Server/main.go b/Server/main.go index 05f75400..f55821a0 100644 --- a/Server/main.go +++ b/Server/main.go @@ -51,9 +51,9 @@ func main() { // run is the real entrypoint — separated for testability. func run(log *slog.Logger, logBuf *admin.RingBuffer) error { // bgCtx is a cancellable context shared by all background goroutines - // (event persister, event pruner, plugin loader). It is cancelled - // early in the shutdown sequence so in-flight DB operations do not - // block after the database is being torn down. + // (event persister, event pruner, plugin loader, maintenance loop). + // It is cancelled early in the shutdown sequence so in-flight DB + // operations do not block after the database is being torn down. bgCtx, bgCancel := context.WithCancel(context.Background()) defer bgCancel() @@ -111,13 +111,14 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { return fmt.Errorf("running migrations: %w", err) } - // Clear stale state from a previous run or crash. - if err := database.ResetAllUserStatuses(); err != nil { + // Clear stale state from a previous run or crash. Startup work — nothing + // to inherit a context from yet. + if err := database.ResetAllUserStatuses(context.Background()); err != nil { log.Warn("failed to reset stale user statuses", "error", err) } else { log.Info("reset all user statuses to offline") } - if err := database.ClearAllVoiceStates(); err != nil { + if err := database.ClearAllVoiceStates(context.Background()); err != nil { log.Warn("failed to clear stale voice states", "error", err) } else { log.Info("cleared stale voice states") @@ -262,14 +263,14 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { } tickFailed := false - if err := database.DeleteExpiredSessions(); err != nil { + if err := database.DeleteExpiredSessions(bgCtx); err != nil { log.Warn("failed to delete expired sessions", "error", err) tickFailed = true } // Clean up orphaned attachments (uploaded but never linked to a message). cutoff := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) - orphanFiles, orphanErr := database.DeleteOrphanedAttachments(cutoff) + orphanFiles, orphanErr := database.DeleteOrphanedAttachments(bgCtx, cutoff) if orphanErr != nil { log.Warn("failed to delete orphaned attachments", "error", orphanErr) tickFailed = true diff --git a/Server/permissions/checker.go b/Server/permissions/checker.go index 7e1d21e5..7c174781 100644 --- a/Server/permissions/checker.go +++ b/Server/permissions/checker.go @@ -1,6 +1,7 @@ package permissions import ( + "context" "errors" "fmt" ) @@ -32,8 +33,8 @@ type ChannelRef struct { // DB is the minimal database interface the Checker needs. // Defined at the consumer (per Go convention: accept interfaces, return structs). type DB interface { - GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) - IsDMParticipant(userID, channelID int64) (bool, error) + GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) + IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) } // ─── Checker ──────────────────────────────────────────────────────────────── @@ -53,11 +54,11 @@ func NewChecker(db DB) *Checker { // has all the given permission bits on the specified channel. Administrator // roles bypass all checks. Channel overrides (allow/deny) are fetched from the // database per call. -func (ck *Checker) HasChannelPerm(rolePerms int64, roleID, channelID, perm int64) bool { +func (ck *Checker) HasChannelPerm(ctx context.Context, rolePerms int64, roleID, channelID, perm int64) bool { if HasAdmin(rolePerms) { return true } - allow, deny, err := ck.db.GetChannelPermissions(channelID, roleID) + allow, deny, err := ck.db.GetChannelPermissions(ctx, channelID, roleID) if err != nil { return false } @@ -105,9 +106,9 @@ func (ck *Checker) VisibleChannelIDs(rolePerms int64, channels []ChannelRef, ove // role-based permissions via HasChannelPerm. // // Returns nil on success, or a descriptive error on failure. -func (ck *Checker) RequireChannelAccess(userID, rolePerms, roleID int64, channelType string, channelID, perm int64) error { +func (ck *Checker) RequireChannelAccess(ctx context.Context, userID, rolePerms, roleID int64, channelType string, channelID, perm int64) error { if channelType == "dm" { - ok, err := ck.db.IsDMParticipant(userID, channelID) + ok, err := ck.db.IsDMParticipant(ctx, userID, channelID) if err != nil { return fmt.Errorf("checking DM participation: %w", err) } @@ -117,7 +118,7 @@ func (ck *Checker) RequireChannelAccess(userID, rolePerms, roleID int64, channel return nil } - if !ck.HasChannelPerm(rolePerms, roleID, channelID, perm) { + if !ck.HasChannelPerm(ctx, rolePerms, roleID, channelID, perm) { return ErrPermissionDenied } return nil diff --git a/Server/permissions/checker_test.go b/Server/permissions/checker_test.go index ab566433..da78fdd0 100644 --- a/Server/permissions/checker_test.go +++ b/Server/permissions/checker_test.go @@ -1,6 +1,7 @@ package permissions import ( + "context" "errors" "testing" ) @@ -27,7 +28,7 @@ func newMockDB() *mockDB { } } -func (m *mockDB) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { +func (m *mockDB) GetChannelPermissions(_ context.Context, channelID, roleID int64) (int64, int64, error) { if m.chanErr != nil { return 0, 0, m.chanErr } @@ -39,7 +40,7 @@ func (m *mockDB) GetChannelPermissions(channelID, roleID int64) (int64, int64, e return p.allow, p.deny, nil } -func (m *mockDB) IsDMParticipant(userID, channelID int64) (bool, error) { +func (m *mockDB) IsDMParticipant(_ context.Context, userID, channelID int64) (bool, error) { if m.dmErr != nil { return false, m.dmErr } @@ -125,7 +126,7 @@ func TestHasChannelPerm(t *testing.T) { } ck := NewChecker(db) - got := ck.HasChannelPerm(tt.rolePerms, tt.roleID, tt.channelID, tt.perm) + got := ck.HasChannelPerm(context.Background(), tt.rolePerms, tt.roleID, tt.channelID, tt.perm) if got != tt.want { t.Errorf("HasChannelPerm() = %v, want %v", got, tt.want) } @@ -379,7 +380,7 @@ func TestRequireChannelAccess(t *testing.T) { } ck := NewChecker(db) - err := ck.RequireChannelAccess(tt.userID, tt.rolePerms, tt.roleID, tt.channelType, tt.channelID, tt.perm) + err := ck.RequireChannelAccess(context.Background(), tt.userID, tt.rolePerms, tt.roleID, tt.channelType, tt.channelID, tt.perm) if tt.dmErr != nil { // Expect wrapped error. diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index 6cf708df..0ea86bb9 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -112,7 +112,7 @@ func NewRegistry(cfg Config) (*Registry, error) { func (r *Registry) Close(ctx context.Context) error { r.mu.Lock() for _, inst := range r.plugins { - r.platformDeactivate(inst) + r.platformDeactivate(ctx, inst) } for id := range r.plugins { delete(r.plugins, id) @@ -192,7 +192,7 @@ func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error // blocked the fresh instance from re-registering its own commands and // kept dispatch routing into the orphaned old module until restart. if old := r.byName[found.Manifest.Name]; old != nil { - r.platformDeactivate(old) + r.platformDeactivate(ctx, old) for cmd, owner := range r.commands { if owner == old { delete(r.commands, cmd) @@ -495,7 +495,7 @@ func (r *Registry) DisablePlugin(ctx context.Context, id int64) error { // Free the wazero module so memory is returned to the runtime // immediately rather than waiting for registry Close. Safe to call // on an instance that was never activated. - r.platformDeactivate(inst) + r.platformDeactivate(ctx, inst) } return nil } diff --git a/Server/plugin/sandbox_default.go b/Server/plugin/sandbox_default.go index be4a0f67..acc9c2df 100644 --- a/Server/plugin/sandbox_default.go +++ b/Server/plugin/sandbox_default.go @@ -24,7 +24,7 @@ func (r *Registry) activateWithRuntime(_ context.Context, _ any, _ *Instance) er } // platformDeactivate is called from Close on each plugin; a no-op here. -func (r *Registry) platformDeactivate(_ *Instance) {} +func (r *Registry) platformDeactivate(_ context.Context, _ *Instance) {} // invokeCommand returns an error result instructing the operator to enable // the wazero build tag. Default build only. diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index fc73637e..4decc103 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -159,13 +159,14 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * // platformDeactivate closes the wazero module held by inst without touching // the shared runtime. Safe to call on an instance that was never activated. -// Called from DisablePlugin and Close. -func (r *Registry) platformDeactivate(inst *Instance) { +// Called from DisablePlugin and Close. Module teardown must run to completion +// once started, so the caller's cancellation is detached (WithoutCancel). +func (r *Registry) platformDeactivate(ctx context.Context, inst *Instance) { if inst == nil || inst.module == nil { return } if mod, ok := inst.module.(api.Module); ok { - _ = mod.Close(context.Background()) + _ = mod.Close(context.WithoutCancel(ctx)) } inst.module = nil } diff --git a/Server/scripts/seed.go b/Server/scripts/seed.go index fd23c502..a74651a0 100644 --- a/Server/scripts/seed.go +++ b/Server/scripts/seed.go @@ -9,6 +9,7 @@ package main import ( + "context" "flag" "fmt" "log" @@ -190,7 +191,7 @@ func createUsers(database *db.DB) ([]int64, error) { ids := make([]int64, len(seedUsers)) for i, su := range seedUsers { - existing, err := database.GetUserByUsername(su.Username) + existing, err := database.GetUserByUsername(context.Background(), su.Username) if err != nil { return nil, fmt.Errorf("checking user %q: %w", su.Username, err) } @@ -205,7 +206,7 @@ func createUsers(database *db.DB) ([]int64, error) { return nil, fmt.Errorf("hashing password for %q: %w", su.Username, err) } - id, err := database.CreateUser(su.Username, hash, su.RoleID) + id, err := database.CreateUser(context.Background(), su.Username, hash, su.RoleID) if err != nil { return nil, fmt.Errorf("creating user %q: %w", su.Username, err) } @@ -240,7 +241,7 @@ func createChannels(database *db.DB) ([]int64, error) { ids := make([]int64, len(seedChannels)) // Fetch existing channels once to check for duplicates. - existing, err := database.ListChannels() + existing, err := database.ListChannels(context.Background()) if err != nil { return nil, fmt.Errorf("listing channels: %w", err) } @@ -256,7 +257,7 @@ func createChannels(database *db.DB) ([]int64, error) { continue } - id, err := database.CreateChannel(sc.Name, sc.Type, sc.Category, sc.Topic, sc.Position) + id, err := database.CreateChannel(context.Background(), sc.Name, sc.Type, sc.Category, sc.Topic, sc.Position) if err != nil { return nil, fmt.Errorf("creating channel %q: %w", sc.Name, err) } @@ -286,7 +287,7 @@ func createMessages(database *db.DB, channelIDs, userIDs []int64) (int, error) { continue } - if _, err := database.CreateMessage(channelID, userID, sm.Content, nil); err != nil { + if _, err := database.CreateMessage(context.Background(), channelID, userID, sm.Content, nil); err != nil { return 0, fmt.Errorf("creating message in channel %d: %w", channelID, err) } created++ @@ -305,7 +306,7 @@ func createMessages(database *db.DB, channelIDs, userIDs []int64) (int, error) { // user already exists in the channel. Used for idempotency. func messageExists(database *db.DB, channelID, userID int64, content string) (bool, error) { var count int - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM messages WHERE channel_id = ? AND user_id = ? AND content = ? AND deleted = 0`, channelID, userID, content, ).Scan(&count) @@ -321,7 +322,7 @@ func createDMConversation(database *db.DB, userIDs []int64) (int, error) { adminID := userIDs[uAdmin] aliceID := userIDs[uAlice] - ch, isNew, err := database.GetOrCreateDMChannel(adminID, aliceID) + ch, isNew, err := database.GetOrCreateDMChannel(context.Background(), adminID, aliceID) if err != nil { return 0, fmt.Errorf("creating DM channel: %w", err) } @@ -344,7 +345,7 @@ func createDMConversation(database *db.DB, userIDs []int64) (int, error) { continue } - if _, err := database.CreateMessage(ch.ID, senderID, dm.Content, nil); err != nil { + if _, err := database.CreateMessage(context.Background(), ch.ID, senderID, dm.Content, nil); err != nil { return 0, fmt.Errorf("creating DM message: %w", err) } created++ diff --git a/Server/service/block.go b/Server/service/block.go index f57988d1..24c4eef9 100644 --- a/Server/service/block.go +++ b/Server/service/block.go @@ -40,12 +40,12 @@ func (s *BlockService) BlockUser(ctx context.Context, blockerID, targetID int64) return fmt.Errorf("%w: cannot block yourself", ErrBadRequest) } - target, err := s.st.GetUserByID(targetID) + target, err := s.st.GetUserByID(ctx, targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - if err := s.st.BlockUser(blockerID, targetID); err != nil { + if err := s.st.BlockUser(ctx, blockerID, targetID); err != nil { return fmt.Errorf("%w: failed to block user", ErrInternal) } @@ -54,11 +54,11 @@ func (s *BlockService) BlockUser(ctx context.Context, blockerID, targetID int64) } // UnblockUser removes a block on a target user. -func (s *BlockService) UnblockUser(blockerID, targetID int64) error { +func (s *BlockService) UnblockUser(ctx context.Context, blockerID, targetID int64) error { if targetID <= 0 { return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } - if err := s.st.UnblockUser(blockerID, targetID); err != nil { + if err := s.st.UnblockUser(ctx, blockerID, targetID); err != nil { return fmt.Errorf("%w: failed to unblock user", ErrInternal) } slog.Info("user unblocked", "blocker_id", blockerID, "target_id", targetID) @@ -66,8 +66,8 @@ func (s *BlockService) UnblockUser(blockerID, targetID int64) error { } // ListBlocked returns all user IDs blocked by the given user. -func (s *BlockService) ListBlocked(blockerID int64) ([]int64, error) { - ids, err := s.st.ListBlockedUsers(blockerID) +func (s *BlockService) ListBlocked(ctx context.Context, blockerID int64) ([]int64, error) { + ids, err := s.st.ListBlockedUsers(ctx, blockerID) if err != nil { return nil, fmt.Errorf("%w: failed to list blocked users", ErrInternal) } diff --git a/Server/service/channel.go b/Server/service/channel.go index 5692091e..03f39320 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -40,13 +40,13 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) telemetry.String("method", "ListVisibleChannels")) span.End() }() - all, err := s.st.ListChannels() + 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(userID) + 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) @@ -55,7 +55,7 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) // Admins skip the override fetch (they bypass all channel checks anyway). var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - overrides, err = s.st.GetAllChannelPermissionsForRole(role.ID) + overrides, err = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) if err != nil { // Fail closed — an empty map would return every denied channel. slog.Error("ChannelService.ListVisibleChannels GetAllChannelPermissionsForRole", "err", err, "user_id", userID, "role_id", role.ID) @@ -96,7 +96,7 @@ func permOverrides(overrides map[int64]db.ChannelOverride) map[int64]permissions // 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(userID, channelID int64, limiter interface { +func (s *ChannelService) HandleTyping(ctx context.Context, userID, channelID int64, limiter interface { Allow(key string, limit int, window time.Duration) bool }, ) (*db.Channel, error) { @@ -110,17 +110,17 @@ func (s *ChannelService) HandleTyping(userID, channelID int64, limiter interface return nil, nil } - ch, err := s.st.GetChannel(channelID) + 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(userID, channelID) + 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 } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, nil // silent drop } @@ -129,12 +129,12 @@ func (s *ChannelService) HandleTyping(userID, channelID int64, limiter interface // GetDMParticipantIDs returns the participant IDs for a DM channel. // Convenience method for handlers building DM events. -func (s *ChannelService) GetDMParticipantIDs(channelID int64) ([]int64, error) { - return s.st.GetDMParticipantIDs(channelID) +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. -func (s *ChannelService) HandlePresenceUpdate(userID int64, status string, limiter interface { +func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, status string, limiter interface { Allow(key string, limit int, window time.Duration) bool }, ) error { @@ -151,7 +151,7 @@ func (s *ChannelService) HandlePresenceUpdate(userID int64, status string, limit return fmt.Errorf("%w: invalid status", ErrBadRequest) } - if err := s.st.UpdateUserStatus(userID, status); err != nil { + if err := s.st.UpdateUserStatus(ctx, userID, status); err != nil { slog.Error("ChannelService.HandlePresenceUpdate", "err", err, "user_id", userID) return fmt.Errorf("%w: failed to update status", ErrInternal) } @@ -161,29 +161,29 @@ func (s *ChannelService) HandlePresenceUpdate(userID int64, status string, limit // HandleChannelFocus processes a channel focus event and updates read state. // Returns the channel for callers to set client state. -func (s *ChannelService) HandleChannelFocus(userID, channelID int64) (*db.Channel, error) { +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(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + 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(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } // Mark channel as read. - latestID, err := s.st.GetLatestMessageID(channelID) + latestID, err := s.st.GetLatestMessageID(ctx, channelID) if err == nil && latestID > 0 { - _ = s.st.UpdateReadState(userID, channelID, latestID) + _ = s.st.UpdateReadState(ctx, userID, channelID, latestID) } slog.Debug("channel_focus", "user_id", userID, "channel_id", channelID) diff --git a/Server/service/datastore.go b/Server/service/datastore.go index f5a8f203..f9458663 100644 --- a/Server/service/datastore.go +++ b/Server/service/datastore.go @@ -18,135 +18,135 @@ import ( // which *db.DB and this Store both satisfy. type Store interface { // ── Messages / reactions / read-state ── - CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) - GetMessage(id int64) (*db.Message, error) - GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) - GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) - EditMessage(id, userID int64, content string) error - DeleteMessage(id, userID int64, isMod bool) error - SearchMessages(query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) - SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) - GetPinnedMessages(channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) - SetMessagePinned(id int64, pinned bool) error - AddReaction(messageID, userID int64, emoji string) error - RemoveReaction(messageID, userID int64, emoji string) error - GetReactions(messageID int64) ([]db.ReactionCount, error) - UpdateReadState(userID, channelID, lastReadMessageID int64) error - GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) - GetLatestMessageID(channelID int64) (int64, error) - LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) - GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) + CreateMessage(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (int64, error) + GetMessage(ctx context.Context, id int64) (*db.Message, error) + GetMessages(ctx context.Context, channelID, before int64, limit int) ([]db.MessageWithUser, error) + GetMessagesForAPI(ctx context.Context, channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) + EditMessage(ctx context.Context, id, userID int64, content string) error + DeleteMessage(ctx context.Context, id, userID int64, isMod bool) error + SearchMessages(ctx context.Context, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) + SearchMessagesInChannels(ctx context.Context, query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) + GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) + SetMessagePinned(ctx context.Context, id int64, pinned bool) error + AddReaction(ctx context.Context, messageID, userID int64, emoji string) error + RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error + GetReactions(ctx context.Context, messageID int64) ([]db.ReactionCount, error) + UpdateReadState(ctx context.Context, userID, channelID, lastReadMessageID int64) error + GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int64]db.ChannelUnread, error) + GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) + LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID int64, attachmentIDs []string) (int64, error) + GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]db.AttachmentInfo, error) // ── Channels ── - ListChannels() ([]db.Channel, error) - GetChannel(id int64) (*db.Channel, error) - CreateChannel(name, chanType, category, topic string, position int) (int64, error) - UpdateChannel(id int64, name, topic string, slowMode int) error - DeleteChannel(id int64) error - SetChannelSlowMode(id int64, slowMode int) error - SetChannelVoiceMaxUsers(id int64, maxUsers int) error - GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) - GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) - GetChannelTypes(ids []int64) (map[int64]string, error) + ListChannels(ctx context.Context) ([]db.Channel, error) + GetChannel(ctx context.Context, id int64) (*db.Channel, error) + CreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) + UpdateChannel(ctx context.Context, id int64, name, topic string, slowMode int) error + DeleteChannel(ctx context.Context, id int64) error + SetChannelSlowMode(ctx context.Context, id int64, slowMode int) error + SetChannelVoiceMaxUsers(ctx context.Context, id int64, maxUsers int) error + GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) + GetAllChannelPermissionsForRole(ctx context.Context, roleID int64) (map[int64]db.ChannelOverride, error) + GetChannelTypes(ctx context.Context, ids []int64) (map[int64]string, error) // ── Users ── - GetUserByID(id int64) (*db.User, error) - GetUserByUsername(username string) (*db.User, error) - CreateUser(username, passwordHash string, roleID int) (int64, error) - CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) - CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) - UpdateUserProfile(userID int64, username string, avatar *string) error - UpdateUserPassword(userID int64, newPasswordHash string) error - UpdateUserStatus(id int64, status string) error - UpdateUserTOTPSecret(id int64, secret *string) error - UpdateUserRole(userID, roleID int64) error - ResetAllUserStatuses() error + GetUserByID(ctx context.Context, id int64) (*db.User, error) + GetUserByUsername(ctx context.Context, username string) (*db.User, error) + CreateUser(ctx context.Context, username, passwordHash string, roleID int) (int64, error) + CreateOwnerIfEmpty(ctx context.Context, username, passwordHash string, roleID int) (int64, error) + CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error) + UpdateUserProfile(ctx context.Context, userID int64, username string, avatar *string) error + UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error + UpdateUserStatus(ctx context.Context, id int64, status string) error + UpdateUserTOTPSecret(ctx context.Context, id int64, secret *string) error + UpdateUserRole(ctx context.Context, userID, roleID int64) error + ResetAllUserStatuses(ctx context.Context) error DeleteAccount(ctx context.Context, userID int64) error - ListMembers() ([]db.MemberSummary, error) + ListMembers(ctx context.Context) ([]db.MemberSummary, error) // ── Sessions ── - CreateSession(userID int64, tokenHash, device, ip string) (int64, error) - GetSessionByTokenHash(tokenHash string) (*db.Session, error) - GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) - DeleteSession(tokenHash string) error - DeleteOtherSessions(userID, keepSessionID int64) (int64, error) - DeleteExpiredSessions() error - DeleteSessionByID(sessionID, userID int64) error - TouchSession(tokenHash string) error - ListUserSessions(userID int64) ([]db.Session, error) - ForceLogoutUser(userID int64) error - GetUserSessions(userID int64) ([]db.Session, error) + CreateSession(ctx context.Context, userID int64, tokenHash, device, ip string) (int64, error) + GetSessionByTokenHash(ctx context.Context, tokenHash string) (*db.Session, error) + GetSessionWithBanStatus(ctx context.Context, tokenHash string) (*db.SessionWithBanStatus, error) + DeleteSession(ctx context.Context, tokenHash string) error + DeleteOtherSessions(ctx context.Context, userID, keepSessionID int64) (int64, error) + DeleteExpiredSessions(ctx context.Context) error + DeleteSessionByID(ctx context.Context, sessionID, userID int64) error + TouchSession(ctx context.Context, tokenHash string) error + ListUserSessions(ctx context.Context, userID int64) ([]db.Session, error) + ForceLogoutUser(ctx context.Context, userID int64) error + GetUserSessions(ctx context.Context, userID int64) ([]db.Session, error) // ── Roles ── - GetRoleByID(id int64) (*db.Role, error) - GetRoleForUser(userID int64) (*db.Role, error) - GetUserWithRole(userID int64) (*db.User, *db.Role, error) - ListRoles() ([]*db.Role, error) + GetRoleByID(ctx context.Context, id int64) (*db.Role, error) + GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) + GetUserWithRole(ctx context.Context, userID int64) (*db.User, *db.Role, error) + ListRoles(ctx context.Context) ([]*db.Role, error) // ── Invites ── - CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) - GetInvite(code string) (*db.Invite, error) - ListInvites() ([]*db.Invite, error) - UseInviteAtomic(code string) error - RevokeInvite(code string) error + CreateInvite(ctx context.Context, createdBy int64, maxUses int, expiresAt *time.Time) (string, error) + GetInvite(ctx context.Context, code string) (*db.Invite, error) + ListInvites(ctx context.Context) ([]*db.Invite, error) + UseInviteAtomic(ctx context.Context, code string) error + RevokeInvite(ctx context.Context, code string) error // ── Voice ── - JoinVoiceChannel(userID, channelID int64) error - JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error - LeaveVoiceChannel(userID int64) error - LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) - GetVoiceState(userID int64) (*db.VoiceState, error) - GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) - GetAllVoiceStates() ([]db.VoiceState, error) - UpdateVoiceMute(userID int64, muted bool) error - UpdateVoiceDeafen(userID int64, deafened bool) error - ClearVoiceState(userID int64) error - ClearAllVoiceStates() error - CountActiveCameras(channelID int64) (int, error) - UpdateVoiceCamera(userID int64, camera bool) error - EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) - UpdateVoiceScreenshare(userID int64, screenshare bool) error - CountChannelVoiceUsers(channelID int64) (int, error) + JoinVoiceChannel(ctx context.Context, userID, channelID int64) error + JoinVoiceChannelIfCapacity(ctx context.Context, userID, channelID int64, maxUsers int) error + LeaveVoiceChannel(ctx context.Context, userID int64) error + LeaveVoiceChannelIfMatch(ctx context.Context, userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) + GetVoiceState(ctx context.Context, userID int64) (*db.VoiceState, error) + GetChannelVoiceStates(ctx context.Context, channelID int64) ([]db.VoiceState, error) + GetAllVoiceStates(ctx context.Context) ([]db.VoiceState, error) + UpdateVoiceMute(ctx context.Context, userID int64, muted bool) error + UpdateVoiceDeafen(ctx context.Context, userID int64, deafened bool) error + ClearVoiceState(ctx context.Context, userID int64) error + ClearAllVoiceStates(ctx context.Context) error + CountActiveCameras(ctx context.Context, channelID int64) (int, error) + UpdateVoiceCamera(ctx context.Context, userID int64, camera bool) error + EnableCameraIfUnderLimit(ctx context.Context, userID, channelID int64, maxVideo int) (bool, error) + UpdateVoiceScreenshare(ctx context.Context, userID int64, screenshare bool) error + CountChannelVoiceUsers(ctx context.Context, channelID int64) (int, error) // ── Direct messages ── - GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) - GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) - OpenDM(userID, channelID int64) error - CloseDM(userID, channelID int64) error - IsDMParticipant(userID, channelID int64) (bool, error) - GetDMParticipantIDs(channelID int64) ([]int64, error) - GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) + GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*db.Channel, bool, error) + GetUserDMChannels(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) + OpenDM(ctx context.Context, userID, channelID int64) error + CloseDM(ctx context.Context, userID, channelID int64) error + IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) + GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) + GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*db.User, error) // ── Blocks ── - BlockUser(blockerID, blockedID int64) error - UnblockUser(blockerID, blockedID int64) error - IsBlocked(blockerID, blockedID int64) (bool, error) - IsEitherBlocked(userA, userB int64) (bool, error) - ListBlockedUsers(blockerID int64) ([]int64, error) + BlockUser(ctx context.Context, blockerID, blockedID int64) error + UnblockUser(ctx context.Context, blockerID, blockedID int64) error + IsBlocked(ctx context.Context, blockerID, blockedID int64) (bool, error) + IsEitherBlocked(ctx context.Context, userA, userB int64) (bool, error) + ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) // ── Attachments ── - CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error - GetAttachmentByID(id string) (*db.Attachment, error) - GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) - DeleteOrphanedAttachments(cutoff string) ([]string, error) + CreateAttachment(ctx context.Context, id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error + GetAttachmentByID(ctx context.Context, id string) (*db.Attachment, error) + GetAttachmentWithChannel(ctx context.Context, id string) (*db.AttachmentAccess, error) + DeleteOrphanedAttachments(ctx context.Context, cutoff string) ([]string, error) // ── Admin ── - UserCount() (int64, error) - GetServerStats() (*db.ServerStats, error) - ListAllUsers(limit, offset int) ([]db.UserWithRole, error) - BanUser(id int64, reason string, expires *time.Time) error - UnbanUser(id int64) error - LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error - GetAuditLog(limit, offset int) ([]db.AuditEntry, error) - AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) - AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error - AdminDeleteChannel(id int64) error - BackupTo(path string) error - BackupToSafe(path, safeRoot string) error - CountUsersWithoutTOTP() (int, error) + UserCount(ctx context.Context) (int64, error) + GetServerStats(ctx context.Context) (*db.ServerStats, error) + ListAllUsers(ctx context.Context, limit, offset int) ([]db.UserWithRole, error) + BanUser(ctx context.Context, id int64, reason string, expires *time.Time) error + UnbanUser(ctx context.Context, id int64) error + LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error + GetAuditLog(ctx context.Context, limit, offset int) ([]db.AuditEntry, error) + AdminCreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) + AdminUpdateChannel(ctx context.Context, id int64, name, topic string, slowMode, position int, archived bool) error + AdminDeleteChannel(ctx context.Context, id int64) error + BackupTo(ctx context.Context, path string) error + BackupToSafe(ctx context.Context, path, safeRoot string) error + CountUsersWithoutTOTP(ctx context.Context) (int, error) // ── Settings ── - GetSetting(key string) (string, error) - SetSetting(key, value string) error - GetAllSettings() (map[string]string, error) + GetSetting(ctx context.Context, key string) (string, error) + SetSetting(ctx context.Context, key, value string) error + GetAllSettings(ctx context.Context) (map[string]string, error) } diff --git a/Server/service/dm.go b/Server/service/dm.go index 89a7d993..526e48f0 100644 --- a/Server/service/dm.go +++ b/Server/service/dm.go @@ -48,12 +48,12 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C return nil, fmt.Errorf("%w: cannot create DM with yourself", ErrBadRequest) } - recipient, err := s.st.GetUserByID(recipientID) + recipient, err := s.st.GetUserByID(ctx, recipientID) if err != nil || recipient == nil { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } - blocked, err := s.st.IsEitherBlocked(userID, recipientID) + blocked, err := s.st.IsEitherBlocked(ctx, userID, recipientID) if err != nil { return nil, fmt.Errorf("%w: failed to check block status", ErrInternal) } @@ -61,7 +61,7 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C return nil, fmt.Errorf("%w: cannot create DM — user is blocked", ErrForbidden) } - ch, created, err := s.st.GetOrCreateDMChannel(userID, recipientID) + ch, created, err := s.st.GetOrCreateDMChannel(ctx, userID, recipientID) if err != nil { slog.Error("DMService.CreateDM", "err", err) return nil, fmt.Errorf("%w: failed to create DM channel", ErrInternal) @@ -75,8 +75,8 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C } // ListDMs returns all open DM channels for a user. -func (s *DMService) ListDMs(userID int64) ([]db.DMChannelInfo, error) { - dms, err := s.st.GetUserDMChannels(userID) +func (s *DMService) ListDMs(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) { + dms, err := s.st.GetUserDMChannels(ctx, userID) if err != nil { return nil, fmt.Errorf("%w: failed to list DMs", ErrInternal) } @@ -84,17 +84,17 @@ func (s *DMService) ListDMs(userID int64) ([]db.DMChannelInfo, error) { } // CloseDM closes a DM channel for a user. -func (s *DMService) CloseDM(userID, channelID int64) error { +func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) error { if channelID <= 0 { return fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return fmt.Errorf("%w: not a participant in this DM", ErrNotFound) } - if err := s.st.CloseDM(userID, channelID); err != nil { + if err := s.st.CloseDM(ctx, userID, channelID); err != nil { return fmt.Errorf("%w: failed to close DM", ErrInternal) } diff --git a/Server/service/invite.go b/Server/service/invite.go index 88a2b87a..6f453d5d 100644 --- a/Server/service/invite.go +++ b/Server/service/invite.go @@ -48,12 +48,12 @@ func (s *InviteService) CreateInvite(ctx context.Context, createdBy int64, maxUs expiresAt = &t } - code, err := s.st.CreateInvite(createdBy, maxUses, expiresAt) + code, err := s.st.CreateInvite(ctx, createdBy, maxUses, expiresAt) if err != nil { return nil, fmt.Errorf("%w: failed to create invite", ErrInternal) } - invite, err := s.st.GetInvite(code) + invite, err := s.st.GetInvite(ctx, code) if err != nil || invite == nil { return nil, fmt.Errorf("%w: failed to retrieve invite", ErrInternal) } @@ -61,8 +61,8 @@ func (s *InviteService) CreateInvite(ctx context.Context, createdBy int64, maxUs } // ListInvites returns all invites. -func (s *InviteService) ListInvites() ([]*db.Invite, error) { - invites, err := s.st.ListInvites() +func (s *InviteService) ListInvites(ctx context.Context) ([]*db.Invite, error) { + invites, err := s.st.ListInvites(ctx) if err != nil { return nil, fmt.Errorf("%w: failed to list invites", ErrInternal) } @@ -70,12 +70,12 @@ func (s *InviteService) ListInvites() ([]*db.Invite, error) { } // RevokeInvite revokes an invite by code. -func (s *InviteService) RevokeInvite(code string) error { - invite, err := s.st.GetInvite(code) +func (s *InviteService) RevokeInvite(ctx context.Context, code string) error { + invite, err := s.st.GetInvite(ctx, code) if err != nil || invite == nil { return fmt.Errorf("%w: invite not found", ErrNotFound) } - if err := s.st.RevokeInvite(code); err != nil { + if err := s.st.RevokeInvite(ctx, code); err != nil { return fmt.Errorf("%w: failed to revoke invite", ErrInternal) } return nil diff --git a/Server/service/message.go b/Server/service/message.go index 443beb11..8ee6272c 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -139,7 +139,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest) } - ch, err := s.st.GetChannel(p.ChannelID) + ch, err := s.st.GetChannel(ctx, p.ChannelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } @@ -147,12 +147,12 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( isDM := ch.Type == "dm" // Permission check. - if err := s.checkSendPermission(p.UserID, p.ChannelID, ch.Type); err != nil { + if err := s.checkSendPermission(ctx, p.UserID, p.ChannelID, ch.Type); err != nil { return nil, err } // Slow mode (non-DM only). - if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(p.UserID, p.ChannelID, permissions.ManageMessages) { + if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) { slowKey := fmt.Sprintf("slow:%d:%d", p.UserID, p.ChannelID) if s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { return nil, fmt.Errorf("%w: channel has %ds slow mode", ErrSlowMode, ch.SlowMode) @@ -167,13 +167,13 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( // Attachment permission (non-DM). if !isDM && len(p.AttachmentIDs) > 0 { - if !s.perms.HasChannelPerm(p.UserID, p.ChannelID, permissions.AttachFiles) { + if !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.AttachFiles) { return nil, fmt.Errorf("%w: missing ATTACH_FILES permission", ErrForbidden) } } // Persist message. - msgID, err := s.st.CreateMessage(p.ChannelID, p.UserID, content, p.ReplyTo) + msgID, err := s.st.CreateMessage(ctx, p.ChannelID, p.UserID, content, p.ReplyTo) if err != nil { slog.Error("MessageService.SendMessage CreateMessage", "err", err) return nil, fmt.Errorf("%w: failed to save message", ErrInternal) @@ -185,11 +185,12 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( // the statement — no check-then-link race and no N+1 pre-verification. var attachments []db.AttachmentInfo if len(p.AttachmentIDs) > 0 { - linked, linkErr := s.st.LinkAttachmentsToMessage(msgID, p.UserID, p.AttachmentIDs) + linked, linkErr := s.st.LinkAttachmentsToMessage(ctx, msgID, p.UserID, p.AttachmentIDs) if linkErr != nil { slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID) - // Cleanup: soft-delete the message. - if delErr := s.st.DeleteMessage(msgID, p.UserID, true); delErr != nil { + // Cleanup: soft-delete the message. The compensating delete must run + // even when the link failed because the request ctx was canceled. + if delErr := s.st.DeleteMessage(context.WithoutCancel(ctx), msgID, p.UserID, true); delErr != nil { slog.Error("MessageService.SendMessage DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID) } return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal) @@ -199,7 +200,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( "msg_id", msgID, "user_id", p.UserID, "requested", len(p.AttachmentIDs), "linked", linked) } if linked > 0 { - attMap, attErr := s.st.GetAttachmentsByMessageIDs([]int64{msgID}) + attMap, attErr := s.st.GetAttachmentsByMessageIDs(ctx, []int64{msgID}) if attErr != nil { slog.Error("MessageService.SendMessage GetAttachments", "err", attErr) } else { @@ -208,8 +209,10 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } } - // Fetch message for timestamp. - msg, err := s.st.GetMessage(msgID) + // Fetch message for timestamp. Post-commit: the message exists whether or + // not the sender is still connected, so the refetch that feeds the fan-out + // must not die with the sender's ctx. + msg, err := s.st.GetMessage(context.WithoutCancel(ctx), msgID) if err != nil || msg == nil { slog.Error("MessageService.SendMessage GetMessage after create", "err", err) return nil, fmt.Errorf("%w: failed to retrieve message", ErrInternal) @@ -226,21 +229,21 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( // DM path: open DM for recipients. if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(p.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, p.ChannelID) if pErr != nil { slog.Error("MessageService.SendMessage GetDMParticipantIDs", "err", pErr, "channel_id", p.ChannelID) return result, nil // Message saved, skip DM side effects. } result.ParticipantIDs = participantIDs - sender, _ := s.st.GetUserByID(p.UserID) + sender, _ := s.st.GetUserByID(ctx, p.UserID) result.SenderUser = sender for _, pid := range participantIDs { if pid == p.UserID { continue } - if openErr := s.st.OpenDM(pid, p.ChannelID); openErr != nil { + if openErr := s.st.OpenDM(ctx, pid, p.ChannelID); openErr != nil { slog.Error("MessageService.SendMessage OpenDM", "err", openErr, "recipient_id", pid, "channel_id", p.ChannelID) continue } @@ -253,7 +256,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } // EditMessage validates and persists a message edit. -func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*EditMessageResult, error) { +func (s *MessageService) EditMessage(ctx context.Context, userID, msgID int64, rawContent string) (*EditMessageResult, error) { // Rate limit. ratKey := fmt.Sprintf("chat_edit:%d", userID) if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { @@ -270,7 +273,7 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } // Fetch message. - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } @@ -279,25 +282,26 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } // Channel type for DM-aware permissions. - ch, chErr := s.st.GetChannel(msg.ChannelID) + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) isDM := chErr == nil && ch != nil && ch.Type == "dm" if isDM { - ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) if dmErr != nil || !ok { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } - } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.SendMessages) { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } // EditMessage checks ownership internally. - if err := s.st.EditMessage(msgID, userID, content); err != nil { + if err := s.st.EditMessage(ctx, msgID, userID, content); err != nil { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } - // Re-fetch for updated edited_at timestamp. - msg, err = s.st.GetMessage(msgID) + // Re-fetch for updated edited_at timestamp. Post-commit: must not die with + // the editor's ctx or the committed edit is never broadcast. + msg, err = s.st.GetMessage(context.WithoutCancel(ctx), msgID) if err != nil || msg == nil { slog.Error("MessageService.EditMessage GetMessage after edit", "err", err, "msg_id", msgID) return nil, fmt.Errorf("%w: edit saved but broadcast failed", ErrInternal) @@ -317,7 +321,7 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(msg.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) if pErr != nil { slog.Error("MessageService.EditMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) } else { @@ -330,7 +334,7 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } // DeleteMessage validates and soft-deletes a message. -func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResult, error) { +func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64) (*DeleteMessageResult, error) { // Rate limit. ratKey := fmt.Sprintf("chat_delete:%d", userID) if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { @@ -341,35 +345,36 @@ func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResul return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest) } - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } - ch, chErr := s.st.GetChannel(msg.ChannelID) + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) isDM := chErr == nil && ch != nil && ch.Type == "dm" if isDM { - ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) if dmErr != nil || !ok { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } } else { isMsgOwner := msg.UserID == userID - canManage := s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages) - canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages)) + canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ManageMessages) + canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.SendMessages)) if !canDelete { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } } - isMod := !isDM && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages) - if err := s.st.DeleteMessage(msgID, userID, isMod); err != nil { + isMod := !isDM && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ManageMessages) + if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) - db.WriteAudit(s.st, userID, "message_delete", "message", msgID, + // Audit rows must survive a request canceled after the delete committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "message_delete", "message", msgID, fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) result := &DeleteMessageResult{ @@ -380,7 +385,7 @@ func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResul } if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(msg.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) if pErr != nil { slog.Error("MessageService.DeleteMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) } else { @@ -392,16 +397,16 @@ func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResul } // AddReaction adds a reaction to a message. -func (s *MessageService) AddReaction(userID, msgID int64, emoji string) (*ReactionResult, error) { - return s.handleReaction(userID, msgID, emoji, true) +func (s *MessageService) AddReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { + return s.handleReaction(ctx, userID, msgID, emoji, true) } // RemoveReaction removes a reaction from a message. -func (s *MessageService) RemoveReaction(userID, msgID int64, emoji string) (*ReactionResult, error) { - return s.handleReaction(userID, msgID, emoji, false) +func (s *MessageService) RemoveReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { + return s.handleReaction(ctx, userID, msgID, emoji, false) } -func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add bool) (*ReactionResult, error) { +func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64, emoji string, add bool) (*ReactionResult, error) { // Rate limit. ratKey := fmt.Sprintf("reaction:%d", userID) if s.limiter != nil && !s.limiter.Allow(ratKey, 5, time.Second) { @@ -425,7 +430,7 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b return nil, fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest) } - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil { return nil, fmt.Errorf("%w: message not found", ErrBadRequest) } @@ -433,15 +438,15 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b return nil, fmt.Errorf("%w: cannot react to deleted message", ErrBadRequest) } - ch, chErr := s.st.GetChannel(msg.ChannelID) + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) isDM := chErr == nil && ch != nil && ch.Type == "dm" if isDM { - ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) if dmErr != nil || !ok { return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest) } - } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { + } else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { // Require READ_MESSAGES in addition to ADD_REACTIONS so a user cannot // react in a channel they cannot read. Mirrors checkSendPermission, // which requires ReadMessages|SendMessages for non-DM sends. @@ -450,13 +455,13 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b action := "add" if add { - if err := s.st.AddReaction(msgID, userID, emoji); err != nil { + if err := s.st.AddReaction(ctx, msgID, userID, emoji); err != nil { slog.Warn("MessageService.AddReaction", "err", err, "msg_id", msgID, "user_id", userID) return nil, fmt.Errorf("%w: reaction already exists", ErrConflict) } } else { action = "remove" - if err := s.st.RemoveReaction(msgID, userID, emoji); err != nil { + if err := s.st.RemoveReaction(ctx, msgID, userID, emoji); err != nil { slog.Warn("MessageService.RemoveReaction", "err", err, "msg_id", msgID, "user_id", userID) return nil, fmt.Errorf("%w: reaction not found", ErrBadRequest) } @@ -472,7 +477,7 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b } if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(msg.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) if pErr != nil { slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) } else { @@ -484,23 +489,23 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b } // GetMessages retrieves paginated messages for a channel with permission checks. -func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { +func (s *MessageService) GetMessages(ctx context.Context, userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { if channelID <= 0 { return nil, false, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, false, fmt.Errorf("%w: channel not found", ErrNotFound) } // Permission check. if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return nil, false, fmt.Errorf("%w: access denied", ErrNotFound) } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, false, fmt.Errorf("%w: access denied", ErrForbidden) } @@ -512,7 +517,7 @@ func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) } // Fetch one extra to detect has_more. - msgs, err := s.st.GetMessagesForAPI(channelID, before, limit+1, userID) + msgs, err := s.st.GetMessagesForAPI(ctx, channelID, before, limit+1, userID) if err != nil { slog.Error("MessageService.GetMessages", "err", err, "channel_id", channelID) return nil, false, fmt.Errorf("%w: failed to fetch messages", ErrInternal) @@ -527,7 +532,7 @@ func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) } // SearchMessages performs full-text search across accessible channels. -func (s *MessageService) SearchMessages(userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { +func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { if query == "" { return nil, fmt.Errorf("%w: query cannot be empty", ErrBadRequest) } @@ -540,19 +545,19 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i // Single-channel search. if channelID != nil && *channelID > 0 { - ch, err := s.st.GetChannel(*channelID) + ch, err := s.st.GetChannel(ctx, *channelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, *channelID) + 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(userID, *channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, *channelID, permissions.ReadMessages) { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - results, err := s.st.SearchMessages(query, channelID, limit) + results, err := s.st.SearchMessages(ctx, query, channelID, limit) if err != nil { return nil, fmt.Errorf("%w: search failed", ErrInternal) } @@ -560,7 +565,7 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i } // Global search: build accessible channel list. - accessibleIDs, err := s.GetAccessibleChannelIDs(userID) + accessibleIDs, err := s.GetAccessibleChannelIDs(ctx, userID) if err != nil { return nil, err } @@ -568,7 +573,7 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i return nil, nil } - results, err := s.st.SearchMessagesInChannels(query, accessibleIDs, limit) + results, err := s.st.SearchMessagesInChannels(ctx, query, accessibleIDs, limit) if err != nil { return nil, fmt.Errorf("%w: search failed", ErrInternal) } @@ -576,23 +581,23 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i } // GetPinnedMessages retrieves pinned messages for a channel. -func (s *MessageService) GetPinnedMessages(userID, channelID int64) ([]db.MessageAPIResponse, error) { +func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelID int64) ([]db.MessageAPIResponse, error) { if channelID <= 0 { return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return nil, fmt.Errorf("%w: access denied", ErrNotFound) } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - msgs, err := s.st.GetPinnedMessages(channelID, userID) + msgs, err := s.st.GetPinnedMessages(ctx, channelID, userID) if err != nil { return nil, fmt.Errorf("%w: failed to fetch pinned messages", ErrInternal) } @@ -600,38 +605,38 @@ func (s *MessageService) GetPinnedMessages(userID, channelID int64) ([]db.Messag } // SetMessagePinned pins or unpins a message. -func (s *MessageService) SetMessagePinned(userID, channelID, msgID int64, pinned bool) error { +func (s *MessageService) SetMessagePinned(ctx context.Context, userID, channelID, msgID int64, pinned bool) error { if channelID <= 0 || msgID <= 0 { return fmt.Errorf("%w: invalid IDs", ErrBadRequest) } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return fmt.Errorf("%w: access denied", ErrNotFound) } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) { return fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden) } // Verify message belongs to this channel. - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil || msg.ChannelID != channelID { return fmt.Errorf("%w: message not found in this channel", ErrNotFound) } - return s.st.SetMessagePinned(msgID, pinned) + return s.st.SetMessagePinned(ctx, msgID, pinned) } // GetAccessibleChannelIDs returns all channel IDs the user can read. -func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) { - channels, err := s.st.ListChannels() +func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int64) ([]int64, error) { + channels, err := s.st.ListChannels(ctx) if err != nil { return nil, fmt.Errorf("%w: failed to list channels", ErrInternal) } - role, err := s.perms.GetRoleForUser(userID) + role, err := s.perms.GetRoleForUser(ctx, userID) if err != nil || role == nil { return nil, fmt.Errorf("%w: failed to get role", ErrInternal) } @@ -639,7 +644,7 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { var overrideErr error - overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(role.ID) + overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) if overrideErr != nil { return nil, fmt.Errorf("%w: failed to fetch channel overrides", ErrInternal) } @@ -656,7 +661,7 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) } // Also include DM channels the user participates in. - dmChannels, err := s.st.GetUserDMChannels(userID) + dmChannels, err := s.st.GetUserDMChannels(ctx, userID) if err == nil { for _, dmc := range dmChannels { ids = append(ids, dmc.ChannelID) @@ -671,31 +676,31 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) // for regular channels; participant membership AND block status for DMs. // Exists so gates outside the send flow (the plugin broadcast path) share // exactly this policy instead of hand-rolling a weaker copy. -func (s *MessageService) CanPost(userID, channelID int64) error { - ch, err := s.st.GetChannel(channelID) +func (s *MessageService) CanPost(ctx context.Context, userID, channelID int64) error { + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return fmt.Errorf("%w: channel not found", ErrNotFound) } - return s.checkSendPermission(userID, channelID, ch.Type) + return s.checkSendPermission(ctx, userID, channelID, ch.Type) } // checkSendPermission validates send permission for a channel of the given // type. Announcement channels are readable by anyone with READ_MESSAGES but // only postable by users with MANAGE_MESSAGES (posting is restricted to // moderators/admins); all other non-DM channels require SEND_MESSAGES. -func (s *MessageService) checkSendPermission(userID, channelID int64, chanType string) error { +func (s *MessageService) checkSendPermission(ctx context.Context, userID, channelID int64, chanType string) error { isDM := chanType == "dm" if isDM { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil { return fmt.Errorf("%w: failed to check DM participation", ErrInternal) } if !ok { return fmt.Errorf("%w: not a participant in this DM", ErrForbidden) } - recipient, err := s.st.GetDMRecipient(channelID, userID) + recipient, err := s.st.GetDMRecipient(ctx, channelID, userID) if err == nil && recipient != nil { - blocked, blkErr := s.st.IsEitherBlocked(userID, recipient.ID) + blocked, blkErr := s.st.IsEitherBlocked(ctx, userID, recipient.ID) if blkErr != nil { return fmt.Errorf("%w: failed to check block status", ErrInternal) } @@ -705,12 +710,12 @@ func (s *MessageService) checkSendPermission(userID, channelID int64, chanType s } return nil } - if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages|permissions.SendMessages) { + if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.SendMessages) { return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden) } // Announcement channels: posting is restricted to users who can manage // messages, even though everyone with READ_MESSAGES can view them. - if chanType == "announcement" && !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) { + if chanType == "announcement" && !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) { return fmt.Errorf("%w: announcement channels require MANAGE_MESSAGES to post", ErrForbidden) } return nil diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 594d0faf..ca61b003 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -76,17 +76,17 @@ func TestCanPost_DMBlockEnforced(t *testing.T) { checker := permissions.NewChecker(database) svc := NewMessageService(database, NewPermissionService(database, checker), nil) - if err := svc.CanPost(1, 50); err != nil { + if err := svc.CanPost(context.Background(), 1, 50); err != nil { t.Fatalf("unblocked DM participant should be allowed: %v", err) } seedBlock(t, database, 2, 1) // bob blocks alice - if err := svc.CanPost(1, 50); !errors.Is(err, ErrBlocked) { + if err := svc.CanPost(context.Background(), 1, 50); !errors.Is(err, ErrBlocked) { t.Fatalf("blocked user must be refused: got %v", err) } - if err := svc.CanPost(3, 50); !errors.Is(err, ErrForbidden) { + if err := svc.CanPost(context.Background(), 3, 50); !errors.Is(err, ErrForbidden) { t.Fatalf("non-participant must be refused: got %v", err) } - if err := svc.CanPost(1, 999); !errors.Is(err, ErrNotFound) { + if err := svc.CanPost(context.Background(), 1, 999); !errors.Is(err, ErrNotFound) { t.Fatalf("missing channel must be NotFound: got %v", err) } } @@ -105,7 +105,7 @@ func TestCanPost_ChannelPermissionRequired(t *testing.T) { checker := permissions.NewChecker(database) svc := NewMessageService(database, NewPermissionService(database, checker), nil) - if err := svc.CanPost(1, 10); !errors.Is(err, ErrForbidden) { + if err := svc.CanPost(context.Background(), 1, 10); !errors.Is(err, ErrForbidden) { t.Fatalf("missing SEND_MESSAGES must refuse: got %v", err) } } @@ -132,11 +132,11 @@ func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) { svc := NewMessageService(database, NewPermissionService(database, checker), nil) // Member has READ|SEND but not MANAGE_MESSAGES → refused in an announcement channel. - if err := svc.CanPost(1, 20); !errors.Is(err, ErrForbidden) { + if err := svc.CanPost(context.Background(), 1, 20); !errors.Is(err, ErrForbidden) { t.Fatalf("member without MANAGE_MESSAGES must be refused in announcement channel: got %v", err) } // Moderator with MANAGE_MESSAGES → allowed. - if err := svc.CanPost(2, 20); err != nil { + if err := svc.CanPost(context.Background(), 2, 20); err != nil { t.Fatalf("moderator with MANAGE_MESSAGES must post in announcement channel: got %v", err) } } @@ -161,10 +161,10 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { checker := permissions.NewChecker(database) svc := NewMessageService(database, NewPermissionService(database, checker), nil) - if err := database.CreateAttachment("att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil { t.Fatal(err) } - if err := database.CreateAttachment("att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { t.Fatal(err) } @@ -180,11 +180,11 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { t.Fatal("message should persist even when some attachments are skipped") } - own, _ := database.GetAttachmentByID("att-own") + own, _ := database.GetAttachmentByID(context.Background(), "att-own") if own.MessageID == nil || *own.MessageID != result.MessageID { t.Error("sender's own attachment should be linked to the new message") } - foreign, _ := database.GetAttachmentByID("att-foreign") + foreign, _ := database.GetAttachmentByID(context.Background(), "att-foreign") if foreign.MessageID != nil { t.Error("another user's attachment must never be linked (IDOR guard)") } @@ -201,7 +201,7 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { if retry.MessageID <= 0 { t.Fatal("retry should persist a message") } - own2, _ := database.GetAttachmentByID("att-own") + own2, _ := database.GetAttachmentByID(context.Background(), "att-own") if own2.MessageID == nil || *own2.MessageID != result.MessageID { t.Error("already-linked attachment must stay linked to the original message") } @@ -325,7 +325,7 @@ func TestEditMessage_OwnerCanEdit(t *testing.T) { t.Fatalf("send: %v", err) } - editResult, err := svc.EditMessage(1, result.MessageID, "edited content") + editResult, err := svc.EditMessage(context.Background(), 1, result.MessageID, "edited content") if err != nil { t.Fatalf("edit: %v", err) } @@ -355,7 +355,7 @@ func TestEditMessage_NonOwnerFails(t *testing.T) { } // User 2 tries to edit it. - _, err = svc.EditMessage(2, result.MessageID, "hacked") + _, err = svc.EditMessage(context.Background(), 2, result.MessageID, "hacked") if err == nil { t.Fatal("expected error when non-owner edits message") } @@ -377,7 +377,7 @@ func TestEditMessage_EmptyContentFails(t *testing.T) { t.Fatalf("send: %v", err) } - _, err = svc.EditMessage(1, result.MessageID, "") + _, err = svc.EditMessage(context.Background(), 1, result.MessageID, "") if err == nil { t.Fatal("expected error for empty edit content") } @@ -399,7 +399,7 @@ func TestDeleteMessage_OwnerCanDelete(t *testing.T) { t.Fatalf("send: %v", err) } - delResult, err := svc.DeleteMessage(1, result.MessageID) + delResult, err := svc.DeleteMessage(context.Background(), 1, result.MessageID) if err != nil { t.Fatalf("delete: %v", err) } @@ -428,7 +428,7 @@ func TestDeleteMessage_NonOwnerWithoutModFails(t *testing.T) { } // User 2 (no ManageMessages) tries to delete user 1's message. - _, err = svc.DeleteMessage(2, result.MessageID) + _, err = svc.DeleteMessage(context.Background(), 2, result.MessageID) if err == nil { t.Fatal("expected error when non-owner without mod perms deletes message") } @@ -475,7 +475,7 @@ func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) { } // Mod (user 2) deletes it. - delResult, err := svc.DeleteMessage(2, result.MessageID) + delResult, err := svc.DeleteMessage(context.Background(), 2, result.MessageID) if err != nil { t.Fatalf("mod delete: %v", err) } @@ -487,7 +487,7 @@ func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) { func TestDeleteMessage_InvalidMessageID(t *testing.T) { svc, _ := newTestMessageService(t) - _, err := svc.DeleteMessage(1, 0) + _, err := svc.DeleteMessage(context.Background(), 1, 0) if err == nil { t.Fatal("expected error for zero message ID") } diff --git a/Server/service/moderation.go b/Server/service/moderation.go index e6cce0c9..03198e93 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -26,12 +26,12 @@ func NewModerationService(st Store, perms *PermissionService) *ModerationService // Administrator bypass). It deliberately takes no target: it runs before any // target lookup so an actor without ban authority always sees Forbidden and // never NotFound — the ban path cannot be used to enumerate user ids. -func (s *ModerationService) requireBanPermission(actorID int64) error { +func (s *ModerationService) requireBanPermission(ctx context.Context, actorID int64) error { if s.perms == nil { // No permission service wired — fail closed rather than allow unchecked bans. return fmt.Errorf("%w: permission service unavailable", ErrForbidden) } - actorRole, err := s.perms.GetRoleForUser(actorID) + actorRole, err := s.perms.GetRoleForUser(ctx, actorID) if err != nil || actorRole == nil { return fmt.Errorf("%w: failed to load actor role", ErrForbidden) } @@ -46,12 +46,12 @@ func (s *ModerationService) requireBanPermission(actorID int64) error { // (e.g. the owner) — mirroring the position-based hierarchy used elsewhere. // Runs after requireBanPermission and the existence check, so only callers // that already hold ban authority reach it. -func (s *ModerationService) requireOutranks(actorID, targetID int64) error { - actorRole, err := s.perms.GetRoleForUser(actorID) +func (s *ModerationService) requireOutranks(ctx context.Context, actorID, targetID int64) error { + actorRole, err := s.perms.GetRoleForUser(ctx, actorID) if err != nil || actorRole == nil { return fmt.Errorf("%w: failed to load actor role", ErrForbidden) } - targetRole, err := s.perms.GetRoleForUser(targetID) + targetRole, err := s.perms.GetRoleForUser(ctx, targetID) if err != nil || targetRole == nil { return fmt.Errorf("%w: failed to load target role", ErrForbidden) } @@ -84,50 +84,52 @@ func (s *ModerationService) BanUser(ctx context.Context, actorID, targetID int64 // Authorization before existence: an actor without ban authority learns // nothing about which user ids exist. - if err := s.requireBanPermission(actorID); err != nil { + if err := s.requireBanPermission(ctx, actorID); err != nil { return err } - target, err := s.st.GetUserByID(targetID) + target, err := s.st.GetUserByID(ctx, targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - if err := s.requireOutranks(actorID, targetID); err != nil { + if err := s.requireOutranks(ctx, actorID, targetID); err != nil { return err } - if err := s.st.BanUser(targetID, reason, expires); err != nil { + if err := s.st.BanUser(ctx, targetID, reason, expires); err != nil { return fmt.Errorf("%w: failed to ban user", ErrInternal) } - db.WriteAudit(s.st, actorID, "user_ban", "user", targetID, reason) + // Audit rows must survive a request canceled after the ban committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "user_ban", "user", targetID, reason) slog.Info("user banned", "actor_id", actorID, "target_id", targetID, "reason", reason) return nil } // UnbanUser removes a ban on a target user. -func (s *ModerationService) UnbanUser(_ context.Context, actorID, targetID int64) error { +func (s *ModerationService) UnbanUser(ctx context.Context, actorID, targetID int64) error { if targetID <= 0 { return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } // Authorization before existence — see BanUser. - if err := s.requireBanPermission(actorID); err != nil { + if err := s.requireBanPermission(ctx, actorID); err != nil { return err } - target, err := s.st.GetUserByID(targetID) + target, err := s.st.GetUserByID(ctx, targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - if err := s.requireOutranks(actorID, targetID); err != nil { + if err := s.requireOutranks(ctx, actorID, targetID); err != nil { return err } - if err := s.st.UnbanUser(targetID); err != nil { + if err := s.st.UnbanUser(ctx, targetID); err != nil { return fmt.Errorf("%w: failed to unban user", ErrInternal) } - db.WriteAudit(s.st, actorID, "user_unban", "user", targetID, "") + // Audit rows must survive a request canceled after the unban committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "user_unban", "user", targetID, "") slog.Info("user unbanned", "actor_id", actorID, "target_id", targetID) return nil diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go index 4b008e9c..2f0cf36b 100644 --- a/Server/service/moderation_test.go +++ b/Server/service/moderation_test.go @@ -52,7 +52,7 @@ func TestBanUser_HierarchyEnforced(t *testing.T) { if err := svc.BanUser(context.Background(), 2, 1, "coup", nil); !errors.Is(err, ErrForbidden) { t.Fatalf("ban owner: want ErrForbidden, got %v", err) } - owner, _ := database.GetUserByID(1) + owner, _ := database.GetUserByID(context.Background(), 1) if owner.Banned { t.Fatal("owner must not be banned") } @@ -64,7 +64,7 @@ func TestBanUser_AuthorizedSucceeds(t *testing.T) { if err := svc.BanUser(context.Background(), 2, 3, "spam", nil); err != nil { t.Fatalf("authorized ban: %v", err) } - target, _ := database.GetUserByID(3) + target, _ := database.GetUserByID(context.Background(), 3) if !target.Banned { t.Fatal("target should be banned") } @@ -101,7 +101,7 @@ func TestUnbanUser_AuthorizationMatrix(t *testing.T) { if err := svc.UnbanUser(context.Background(), 2, 3); err != nil { t.Fatalf("authorized unban: %v", err) } - target, _ := database.GetUserByID(3) + target, _ := database.GetUserByID(context.Background(), 3) if target.Banned { t.Fatal("target should be unbanned") } diff --git a/Server/service/permission.go b/Server/service/permission.go index 498e1a9d..97b05cd8 100644 --- a/Server/service/permission.go +++ b/Server/service/permission.go @@ -49,17 +49,18 @@ func NewPermissionService(st Store, checker *permissions.Checker) *PermissionSer // HasChannelPerm reports whether the user has the required permission bits // on the given channel. Uses cached role/override data when available. -func (s *PermissionService) HasChannelPerm(userID, channelID, perm int64) bool { +// Cancellation of ctx reaches the underlying store reads. +func (s *PermissionService) HasChannelPerm(ctx context.Context, userID, channelID, perm int64) bool { // Phase B Step 8 — span the perm check so traces show how many permission // lookups a single REST/WS request triggers. The cache hit path is fast, // but knowing how often it misses is the whole point of having metrics. - _, span := telemetry.GlobalTracer("service/permission").Start(context.Background(), + ctx, span := telemetry.GlobalTracer("service/permission").Start(ctx, "PermissionService.HasChannelPerm", telemetry.Int64("user_id", userID), telemetry.Int64("channel_id", channelID), ) defer span.End() - cp := s.getOrPopulate(userID) + cp := s.getOrPopulate(ctx, userID) if cp == nil { return false } @@ -69,9 +70,9 @@ func (s *PermissionService) HasChannelPerm(userID, channelID, perm int64) bool { // RequireChannelAccess checks whether the user can access the channel with // the given permission. For DM channels it verifies participant membership. // For regular channels it uses cached role-based permission checks. -func (s *PermissionService) RequireChannelAccess(userID int64, channelType string, channelID, perm int64) error { +func (s *PermissionService) RequireChannelAccess(ctx context.Context, userID int64, channelType string, channelID, perm int64) error { if channelType == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil { return err } @@ -80,20 +81,20 @@ func (s *PermissionService) RequireChannelAccess(userID int64, channelType strin } return nil } - if !s.HasChannelPerm(userID, channelID, perm) { + if !s.HasChannelPerm(ctx, userID, channelID, perm) { return permissions.ErrPermissionDenied } return nil } // GetRoleForUser returns the user's role, using the cache when available. -func (s *PermissionService) GetRoleForUser(userID int64) (*db.Role, error) { - cp := s.getOrPopulate(userID) +func (s *PermissionService) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) { + cp := s.getOrPopulate(ctx, userID) if cp == nil { // Cache miss, fall back to direct DB query. - return s.st.GetRoleForUser(userID) + return s.st.GetRoleForUser(ctx, userID) } - return s.st.GetRoleByID(cp.roleID) + return s.st.GetRoleByID(ctx, cp.roleID) } // InvalidateUser removes cached permissions for a specific user. @@ -131,7 +132,7 @@ func (s *PermissionService) Checker() *permissions.Checker { // getOrPopulate returns cached perms for the user, populating the cache // on miss or staleness. Returns nil if the user's role can't be loaded. -func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { +func (s *PermissionService) getOrPopulate(ctx context.Context, userID int64) *cachedPerms { s.mu.RLock() cp, ok := s.cache[userID] if ok && time.Since(cp.populatedAt) < permCacheTTL { @@ -142,7 +143,7 @@ func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { s.mu.RUnlock() // Populate. - role, err := s.st.GetRoleForUser(userID) + role, err := s.st.GetRoleForUser(ctx, userID) if err != nil || role == nil { return nil } @@ -150,7 +151,7 @@ func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { // ChannelService.ListVisibleChannels and ws.buildReady). var overrides map[int64]permissions.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - raw, oErr := s.st.GetAllChannelPermissionsForRole(role.ID) + raw, oErr := s.st.GetAllChannelPermissionsForRole(ctx, role.ID) if oErr != nil { // Fail closed: an empty map would silently drop every deny bit, // and caching it would keep doing so for permCacheTTL. diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go index f584a3b8..1c56ae7a 100644 --- a/Server/service/permission_test.go +++ b/Server/service/permission_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "testing" "time" @@ -17,7 +18,7 @@ type errOverrideStore struct { *db.DB } -func (errOverrideStore) GetAllChannelPermissionsForRole(int64) (map[int64]db.ChannelOverride, error) { +func (errOverrideStore) GetAllChannelPermissionsForRole(context.Context, int64) (map[int64]db.ChannelOverride, error) { return nil, errors.New("boom") } @@ -39,7 +40,7 @@ func TestHasChannelPerm_OverrideFetchErrorDenies(t *testing.T) { svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database)) - if svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) { t.Fatal("override fetch failure must deny, not fall back to the base role bits") } } @@ -65,10 +66,10 @@ func TestHasChannelPerm_Allowed(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Member has SendMessages | ReadMessages; no overrides exist, so base role perms apply. - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected user to have SendMessages permission") } - if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) { t.Fatal("expected user to have ReadMessages permission") } } @@ -78,7 +79,7 @@ func TestHasChannelPerm_Denied(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // ManageMessages is NOT in the member role. - if svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("expected user to NOT have ManageMessages permission") } } @@ -91,11 +92,11 @@ func TestHasChannelPerm_OverrideDeny(t *testing.T) { // Invalidate so next check re-populates cache. svc.InvalidateAll() - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected SendMessages to be denied via channel override") } // ReadMessages should still be allowed. - if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) { t.Fatal("expected ReadMessages to remain allowed") } } @@ -107,7 +108,7 @@ func TestHasChannelPerm_OverrideAllow(t *testing.T) { seedChannelOverride(t, database, permissions.MemberRoleID, 10, permissions.ManageMessages, 0) svc.InvalidateAll() - if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("expected ManageMessages to be allowed via channel override") } } @@ -128,10 +129,10 @@ func TestHasChannelPerm_AdminBypass(t *testing.T) { // Deny everything via override; admin should still bypass. seedChannelOverride(t, database, permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages) - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("admin should bypass all permission checks") } - if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("admin should bypass all permission checks") } } @@ -153,7 +154,7 @@ func TestHasChannelPerm_AdminSkipsOverrideFetch(t *testing.T) { svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database)) - if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("admin must not be denied by an override-fetch outage; the fetch is skipped for admins") } } @@ -163,19 +164,19 @@ func TestInvalidateUser_ClearsCacheForUser(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache. - svc.HasChannelPerm(1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) // Now add a deny override. seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Without invalidation, cache still says allowed. - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected cached value to still allow SendMessages") } // After invalidation, should pick up the override. svc.InvalidateUser(1) - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected SendMessages to be denied after cache invalidation") } } @@ -187,27 +188,27 @@ func TestInvalidateAll_ClearsEntireCache(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache for both users. - svc.HasChannelPerm(1, 10, permissions.SendMessages) - svc.HasChannelPerm(2, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) // Add deny override. seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Both still cached as allowed. - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected cached allow for user 1") } - if !svc.HasChannelPerm(2, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) { t.Fatal("expected cached allow for user 2") } svc.InvalidateAll() // Both should now see the deny. - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected deny for user 1 after InvalidateAll") } - if svc.HasChannelPerm(2, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) { t.Fatal("expected deny for user 2 after InvalidateAll") } } @@ -221,7 +222,7 @@ func TestPermCacheTTLExpiry(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache. - svc.HasChannelPerm(1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) // Add deny override. seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) @@ -234,7 +235,7 @@ func TestPermCacheTTLExpiry(t *testing.T) { svc.mu.Unlock() // The next call should re-populate and pick up the deny. - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected cache TTL expiry to cause re-population with deny override") } } @@ -244,7 +245,7 @@ func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // User 999 has no role assigned. - if svc.HasChannelPerm(999, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 999, 10, permissions.SendMessages) { t.Fatal("expected false for unknown user") } } @@ -257,8 +258,8 @@ type raceHookStore struct { onGetRole func() } -func (s *raceHookStore) GetRoleForUser(userID int64) (*db.Role, error) { - r, err := s.DB.GetRoleForUser(userID) +func (s *raceHookStore) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) { + r, err := s.DB.GetRoleForUser(ctx, userID) if s.onGetRole != nil { s.onGetRole() } @@ -302,7 +303,7 @@ func TestGetOrPopulate_InvalidationDuringPopulateNotLost(t *testing.T) { fired = true // Admin demotes the role (removes SendMessages) and invalidates, // racing this populate between its role read and its cache store. - if _, err := database.Exec(`UPDATE roles SET permissions = ? WHERE id = ?`, + if _, err := database.ExecContext(context.Background(), `UPDATE roles SET permissions = ? WHERE id = ?`, permissions.ReadMessages, permissions.MemberRoleID); err != nil { t.Errorf("demote role: %v", err) } @@ -311,10 +312,10 @@ func TestGetOrPopulate_InvalidationDuringPopulateNotLost(t *testing.T) { // This populate reads the pre-demotion perms; the racing invalidation // must stop that stale snapshot from being cached. - svc.HasChannelPerm(1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) // A fresh check must re-read the DB and see the revoked permission. - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("revoked SendMessages served from a stale snapshot; a populate that races an invalidation must not be cached") } }) diff --git a/Server/service/seed_test.go b/Server/service/seed_test.go index 547064e6..163c2432 100644 --- a/Server/service/seed_test.go +++ b/Server/service/seed_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "strconv" "testing" @@ -34,7 +35,7 @@ func newTestDB(t *testing.T) *db.DB { // collides with one of the migration-seeded defaults). func seedRole(t *testing.T, database *db.DB, r *db.Role) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES (?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET @@ -54,7 +55,7 @@ func seedRole(t *testing.T, database *db.DB, r *db.Role) { // seedUser call and only writes role_id. func seedUserRole(t *testing.T, database *db.DB, userID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO users (id, username, password, role_id) VALUES (?, ?, '', ?) ON CONFLICT(id) DO UPDATE SET role_id=excluded.role_id`, @@ -89,7 +90,7 @@ func seedUser(t *testing.T, database *db.DB, u *db.User) { if u.Banned { banned = 1 } - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO users (id, username, password, avatar, status, banned, ban_reason) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -113,7 +114,7 @@ func seedChannel(t *testing.T, database *db.DB, ch *db.Channel) { if ctype == "" { ctype = "text" } - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type, category, topic, position) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -132,7 +133,7 @@ func seedChannel(t *testing.T, database *db.DB, ch *db.Channel) { // seedChannelOverride sets a per-channel permission override for a role. func seedChannelOverride(t *testing.T, database *db.DB, roleID, channelID, allow, deny int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?) ON CONFLICT(channel_id, role_id) DO UPDATE SET @@ -148,7 +149,7 @@ func seedChannelOverride(t *testing.T, database *db.DB, roleID, channelID, allow // seedDMParticipant adds a user as a participant of a DM channel. func seedDMParticipant(t *testing.T, database *db.DB, channelID, userID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT OR IGNORE INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, channelID, userID, ) @@ -160,7 +161,7 @@ func seedDMParticipant(t *testing.T, database *db.DB, channelID, userID int64) { // seedBlock records that blockerID has blocked blockedID. func seedBlock(t *testing.T, database *db.DB, blockerID, blockedID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`, blockerID, blockedID, ) diff --git a/Server/service/user.go b/Server/service/user.go index 70590c9a..0c51c646 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -34,17 +34,18 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username span.End() }() - if err := s.st.UpdateUserProfile(userID, username, avatar); err != nil { + if err := s.st.UpdateUserProfile(ctx, userID, username, avatar); err != nil { if db.IsUniqueConstraintError(err) { return nil, fmt.Errorf("%w: username is already taken", ErrConflict) } return nil, fmt.Errorf("%w: failed to update profile", ErrInternal) } - user, err := s.st.GetUserByID(userID) + user, err := s.st.GetUserByID(ctx, userID) if err != nil { return nil, fmt.Errorf("%w: failed to fetch updated user", ErrInternal) } - db.WriteAudit(s.st, userID, "profile_update", "user", userID, + // Audit rows must survive a request canceled after the write committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "profile_update", "user", userID, fmt.Sprintf("username=%s", username)) slog.Info("profile updated", "user_id", userID, "username", username) return user, nil @@ -62,36 +63,39 @@ type ChangePasswordResult struct { } // ChangePassword updates the user's password and revokes other sessions. -func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) { - if err := s.st.UpdateUserPassword(userID, newPasswordHash); err != nil { +func (s *UserService) ChangePassword(ctx context.Context, userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) { + if err := s.st.UpdateUserPassword(ctx, userID, newPasswordHash); err != nil { return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password", ErrInternal) } // The password is committed from here on: every path below reports - // success and writes the audit row. + // success and writes the audit row — even if the request ctx has been + // canceled, revocation and audit are the security tail of the change. + tailCtx := context.WithoutCancel(ctx) + var res ChangePasswordResult - revoked, err := s.st.DeleteOtherSessions(userID, keepSessionID) + revoked, err := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID) res.SessionsRevoked = revoked if err != nil { slog.Error("UserService.ChangePassword DeleteOtherSessions", "err", err, "user_id", userID) // One bounded compensating retry: revocation is the security tail of // the change and a single immediate retry covers transient write-lock // contention. ponytail: one retry, add backoff only if logs show it. - if revokedRetry, retryErr := s.st.DeleteOtherSessions(userID, keepSessionID); retryErr == nil { + if revokedRetry, retryErr := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID); retryErr == nil { res.SessionsRevoked += revokedRetry } else { res.RevokeFailed = true } } - db.WriteAudit(s.st, userID, "password_change", "user", userID, "password changed") + db.WriteAudit(tailCtx, s.st, userID, "password_change", "user", userID, "password changed") slog.Info("password changed", "user_id", userID, "sessions_revoked", res.SessionsRevoked, "revoke_failed", res.RevokeFailed) return res, nil } // ListSessions returns all active sessions for a user. -func (s *UserService) ListSessions(userID int64) ([]db.Session, error) { - sessions, err := s.st.ListUserSessions(userID) +func (s *UserService) ListSessions(ctx context.Context, userID int64) ([]db.Session, error) { + sessions, err := s.st.ListUserSessions(ctx, userID) if err != nil { return nil, fmt.Errorf("%w: failed to list sessions", ErrInternal) } @@ -99,14 +103,15 @@ func (s *UserService) ListSessions(userID int64) ([]db.Session, error) { } // RevokeSession deletes a specific session owned by the user. -func (s *UserService) RevokeSession(userID, sessionID int64) error { - if err := s.st.DeleteSessionByID(sessionID, userID); err != nil { +func (s *UserService) RevokeSession(ctx context.Context, userID, sessionID int64) error { + if err := s.st.DeleteSessionByID(ctx, sessionID, userID); err != nil { if errors.Is(err, db.ErrNotFound) { return fmt.Errorf("%w: session not found", ErrNotFound) } return fmt.Errorf("%w: failed to revoke session", ErrInternal) } - db.WriteAudit(s.st, userID, "session_revoke", "session", sessionID, "session revoked") + // Audit rows must survive a request canceled after the delete committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "session_revoke", "session", sessionID, "session revoked") slog.Info("session revoked", "user_id", userID, "session_id", sessionID) return nil } diff --git a/Server/service/user_test.go b/Server/service/user_test.go index 13864de6..4052b4b6 100644 --- a/Server/service/user_test.go +++ b/Server/service/user_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "slices" "testing" @@ -20,7 +21,7 @@ type pwStore struct { audits []string } -func (f *pwStore) DeleteOtherSessions(_, _ int64) (int64, error) { +func (f *pwStore) DeleteOtherSessions(_ context.Context, _, _ int64) (int64, error) { f.revokeCalls++ if f.revokeCalls <= f.failRevokes { return 0, errors.New("session table locked") @@ -28,7 +29,7 @@ func (f *pwStore) DeleteOtherSessions(_, _ int64) (int64, error) { return 2, nil } -func (f *pwStore) LogAudit(_ int64, action, _ string, _ int64, _ string) error { +func (f *pwStore) LogAudit(_ context.Context, _ int64, action, _ string, _ int64, _ string) error { f.audits = append(f.audits, action) return nil } @@ -43,14 +44,14 @@ func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) { fs := &pwStore{DB: database, failRevokes: 99} svc := NewUserService(fs) - res, err := svc.ChangePassword(7, "newhash", 1) + res, err := svc.ChangePassword(context.Background(), 7, "newhash", 1) if err != nil { t.Fatalf("committed password change must not return an error: %v", err) } if !res.RevokeFailed { t.Fatal("RevokeFailed should be set when revocation keeps failing") } - if u, _ := database.GetUserByID(7); u.PasswordHash != "newhash" { + if u, _ := database.GetUserByID(context.Background(), 7); u.PasswordHash != "newhash" { t.Fatal("password should be committed") } if !slices.Contains(fs.audits, "password_change") { @@ -66,7 +67,7 @@ func TestChangePassword_RetryRecoversRevocation(t *testing.T) { fs := &pwStore{DB: database, failRevokes: 1} svc := NewUserService(fs) - res, err := svc.ChangePassword(7, "newhash", 1) + res, err := svc.ChangePassword(context.Background(), 7, "newhash", 1) if err != nil { t.Fatalf("ChangePassword: %v", err) } diff --git a/Server/telemetry/telemetry.go b/Server/telemetry/telemetry.go index 6db5da3e..ea8d363b 100644 --- a/Server/telemetry/telemetry.go +++ b/Server/telemetry/telemetry.go @@ -58,6 +58,9 @@ func String(k, v string) Attr { return Attr{Key: k, Value: v} } // Int64 constructs an int64 attribute. func Int64(k string, v int64) Attr { return Attr{Key: k, Value: v} } +// Float64 constructs a float64 attribute. +func Float64(k string, v float64) Attr { return Attr{Key: k, Value: v} } + // Span is a single tracing span. type Span interface { End() diff --git a/Server/ws/authz_test.go b/Server/ws/authz_test.go index c628132f..ef157e7d 100644 --- a/Server/ws/authz_test.go +++ b/Server/ws/authz_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "time" @@ -29,7 +30,7 @@ func channelFocusMsg(channelID int64) []byte { // specific role on a specific channel. func denyReadOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, channelID, roleID, permissions.ReadMessages, ) diff --git a/Server/ws/can_send_ready_test.go b/Server/ws/can_send_ready_test.go index a527437e..bcffdcc4 100644 --- a/Server/ws/can_send_ready_test.go +++ b/Server/ws/can_send_ready_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" ) @@ -10,11 +11,11 @@ import ( func TestBuildReady_IncludesCanSend(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "cansend-user") - role, err := database.GetRoleByID(1) + role, err := database.GetRoleByID(context.Background(), 1) if err != nil || role == nil { t.Fatalf("GetRoleByID: %v", err) } - if _, err := database.CreateChannel("general", "text", "", "", 0); err != nil { + if _, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0); err != nil { t.Fatalf("CreateChannel: %v", err) } msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) diff --git a/Server/ws/channel_visibility_agreement_test.go b/Server/ws/channel_visibility_agreement_test.go index 603aa20e..c66eeea8 100644 --- a/Server/ws/channel_visibility_agreement_test.go +++ b/Server/ws/channel_visibility_agreement_test.go @@ -21,10 +21,10 @@ import ( func seedVisibilityUser(t *testing.T, database *db.DB, username string, roleID int) *db.User { t.Helper() - if _, err := database.CreateUser(username, "hash", roleID); err != nil { + if _, err := database.CreateUser(context.Background(), username, "hash", roleID); err != nil { t.Fatalf("CreateUser(%s): %v", username, err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("GetUserByUsername(%s): %v", username, err) } @@ -55,19 +55,19 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) { svc := service.New(database, limiter) // Seed one channel of each server type plus a dm channel (never visible). - textID, err := database.CreateChannel("general", "text", "", "", 0) + textID, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel text: %v", err) } - annID, err := database.CreateChannel("announce", "announcement", "", "", 1) + annID, err := database.CreateChannel(context.Background(), "announce", "announcement", "", "", 1) if err != nil { t.Fatalf("CreateChannel announcement: %v", err) } - voiceID, err := database.CreateChannel("voice", "voice", "", "", 2) + voiceID, err := database.CreateChannel(context.Background(), "voice", "voice", "", "", 2) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } - dmID, err := database.CreateChannel("dm", "dm", "", "", 3) + dmID, err := database.CreateChannel(context.Background(), "dm", "dm", "", "", 3) if err != nil { t.Fatalf("CreateChannel dm: %v", err) } @@ -81,12 +81,12 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) { ) // Member is denied READ on the announcement channel only. - if err := database.UpsertChannelOverride(annID, roleMember, 0, permissions.ReadMessages); err != nil { + if err := database.UpsertChannelOverride(context.Background(), annID, roleMember, 0, permissions.ReadMessages); err != nil { t.Fatalf("UpsertChannelOverride member/announcement: %v", err) } // Moderator is denied READ on every server channel → sees nothing. for _, chID := range []int64{textID, annID, voiceID} { - if err := database.UpsertChannelOverride(chID, roleModerator, 0, permissions.ReadMessages); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, roleModerator, 0, permissions.ReadMessages); err != nil { t.Fatalf("UpsertChannelOverride moderator/%d: %v", chID, err) } } @@ -104,7 +104,7 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { user := seedVisibilityUser(t, database, "vis-"+tc.name, tc.roleID) - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(context.Background(), user.RoleID) if err != nil || role == nil { t.Fatalf("GetRoleByID: %v", err) } diff --git a/Server/ws/coverage_boost2_test.go b/Server/ws/coverage_boost2_test.go index ebbea0cc..14877dfc 100644 --- a/Server/ws/coverage_boost2_test.go +++ b/Server/ws/coverage_boost2_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "time" @@ -291,8 +292,8 @@ func TestHandleVoiceScreenshare_NotInVoice2(t *testing.T) { func TestHandleVoiceMute_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "mute-bad-payload") - chanID, _ := database.CreateChannel("mute-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "mute-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) @@ -322,8 +323,8 @@ func TestHandleVoiceMute_BadPayload(t *testing.T) { func TestHandleVoiceDeafen_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "deafen-bad-payload") - chanID, _ := database.CreateChannel("deafen-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "deafen-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) @@ -355,8 +356,8 @@ func TestHandleVoiceDeafen_BadPayload(t *testing.T) { func TestHandleVoiceCamera_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "cam-bad-payload") - chanID, _ := database.CreateChannel("cam-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "cam-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) @@ -389,8 +390,8 @@ func TestHandleVoiceCamera_BadPayload(t *testing.T) { func TestHandleVoiceScreenshare_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "share-bad-payload") - chanID, _ := database.CreateChannel("share-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "share-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index 691aa621..f93112c3 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -4,6 +4,7 @@ package ws_test // to push the ws package above 80%. import ( + "context" "encoding/json" "math" "strings" @@ -100,11 +101,11 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) { func seedCoverageOwner(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) + _, err := database.CreateUser(context.Background(), username, "hash", 1) if err != nil { t.Fatalf("seedCoverageOwner CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedCoverageOwner GetUserByUsername: %v", err) } @@ -488,20 +489,20 @@ func TestHandleMessage_Ping_ReturnsPong(t *testing.T) { func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "ready-voice-user") - role, rErr := database.GetRoleByID(1) + role, rErr := database.GetRoleByID(context.Background(), 1) if rErr != nil || role == nil { t.Fatalf("GetRoleByID: %v", rErr) } // Create a voice channel. - vcID, err := database.CreateChannel("voice-room", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } // Create another user and join them to voice. other := seedCoverageOwner(t, database, "ready-voice-other") - if err := database.JoinVoiceChannel(other.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), other.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } @@ -529,17 +530,17 @@ func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { func TestBuildReady_MultipleChannelTypes(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "ready-multi-user") - role, rErr := database.GetRoleByID(1) + role, rErr := database.GetRoleByID(context.Background(), 1) if rErr != nil || role == nil { t.Fatalf("GetRoleByID: %v", rErr) } // Create text and voice channels. - _, err := database.CreateChannel("text-chan", "text", "General", "", 0) + _, err := database.CreateChannel(context.Background(), "text-chan", "text", "General", "", 0) if err != nil { t.Fatalf("CreateChannel text: %v", err) } - _, err = database.CreateChannel("voice-chan", "voice", "General", "", 1) + _, err = database.CreateChannel(context.Background(), "voice-chan", "voice", "General", "", 1) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } @@ -695,7 +696,7 @@ func TestHandleVoiceCamera_NotInVoice(t *testing.T) { func TestHandleVoiceCamera_InvalidPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vc-bad-payload") - vcID, err := database.CreateChannel("cam-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "cam-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -746,7 +747,7 @@ func TestHandleVoiceScreenshare_NotInVoice(t *testing.T) { func TestHandleVoiceScreenshare_InvalidPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vs-bad-payload") - vcID, err := database.CreateChannel("screen-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "screen-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -947,11 +948,11 @@ func TestSendToUser_FullBuffer_ReturnsFalse(t *testing.T) { func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) { hub, database := newCoverageHub(t) // Use a member user. - _, err := database.CreateUser("attach-noperm-user", "hash", 4) + _, err := database.CreateUser(context.Background(), "attach-noperm-user", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByUsername("attach-noperm-user") + user, err := database.GetUserByUsername(context.Background(), "attach-noperm-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -959,7 +960,7 @@ func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) { chID := seedTestChannel(t, database, "attach-noperm-chan") // Deny ATTACH_FILES (0x0020) on this channel for Member role (id=4). - _, err = database.Exec("INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID) + _, err = database.ExecContext(context.Background(), "INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID) if err != nil { t.Fatalf("INSERT channel_overrides: %v", err) } @@ -1026,21 +1027,21 @@ func TestHandleChatSend_WithAttachments_Success(t *testing.T) { func TestHandleChatSend_SlowMode_EnforcedForMember(t *testing.T) { hub, database := newCoverageHub(t) - _, err := database.CreateUser("slow-member-user", "hash", 4) + _, err := database.CreateUser(context.Background(), "slow-member-user", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByUsername("slow-member-user") + user, err := database.GetUserByUsername(context.Background(), "slow-member-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } // Create channel with slow mode. - chID, err := database.CreateChannel("slow-chan", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "slow-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - if err := database.SetChannelSlowMode(chID, 60); err != nil { + if err := database.SetChannelSlowMode(context.Background(), chID, 60); err != nil { t.Fatalf("SetChannelSlowMode: %v", err) } @@ -1323,7 +1324,7 @@ func TestHandleChannelFocus_UpdatesReadState(t *testing.T) { chID := seedTestChannel(t, database, "cf-readstate-chan") // Insert a message so there's a latest_message_id. - _, err := database.CreateMessage(chID, user.ID, "test message", nil) + _, err := database.CreateMessage(context.Background(), chID, user.ID, "test message", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -1404,7 +1405,7 @@ func drainChanTimeout(ch <-chan []byte, d time.Duration) [][]byte { func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } @@ -1532,11 +1533,11 @@ func TestHandleVoiceJoin_SwitchChannels(t *testing.T) { func TestHandleVoiceJoin_ChannelFull(t *testing.T) { hub, database := newCoverageHub(t) - vcID, err := database.CreateChannel("full-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "full-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - _, err = database.Exec("UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID) + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID) if err != nil { t.Fatalf("UPDATE channels: %v", err) } @@ -1740,11 +1741,11 @@ func TestHandleVoiceJoin_WithQualityOverride(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vj-quality-user") - vcID, err := database.CreateChannel("quality-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "quality-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - _, err = database.Exec("UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID) + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID) if err != nil { t.Fatalf("UPDATE: %v", err) } @@ -2043,11 +2044,11 @@ func TestBuildAuthOK_NonNilAvatar(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "authok-avatar-user") // Set a non-nil avatar. - _, err := database.Exec("UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID) + _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID) if err != nil { t.Fatalf("UPDATE avatar: %v", err) } - user, err = database.GetUserByUsername("authok-avatar-user") + user, err = database.GetUserByUsername(context.Background(), "authok-avatar-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -2072,12 +2073,12 @@ func TestHandleChatSend_WithNonNilAvatar(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "avatar-user") // Set a non-nil avatar on the user. - _, err := database.Exec("UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID) + _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID) if err != nil { t.Fatalf("UPDATE avatar: %v", err) } // Reload user to get updated avatar. - user, err = database.GetUserByUsername("avatar-user") + user, err = database.GetUserByUsername(context.Background(), "avatar-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -2218,11 +2219,11 @@ func TestHandleVoiceJoin_InvalidQualityFallsBackToMedium(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vj-badquality-user") - vcID, err := database.CreateChannel("badquality-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "badquality-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - _, err = database.Exec("UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID) + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID) if err != nil { t.Fatalf("UPDATE: %v", err) } @@ -2434,7 +2435,7 @@ func TestRollbackVoiceJoin_ClearsVoiceStateAndBroadcasts(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } ws.SetVoiceChIDForTest(c, vcID) @@ -2446,7 +2447,7 @@ func TestRollbackVoiceJoin_ClearsVoiceStateAndBroadcasts(t *testing.T) { t.Fatalf("voiceChID after rollback = %d, want 0", got) } - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Fatal("voice state should be nil after rollback") } @@ -2489,11 +2490,11 @@ func TestLeaveVoiceChannelWithRetry_SuccessOnFirstAttempt(t *testing.T) { user := seedCoverageOwner(t, database, "lvcr-ok") vcID := seedVoiceChannel(t, database, "lvcr-ok-vc") - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state == nil { t.Fatal("voice state should exist before leave") } @@ -2503,7 +2504,7 @@ func TestLeaveVoiceChannelWithRetry_SuccessOnFirstAttempt(t *testing.T) { t.Fatalf("leaveVoiceChannelWithRetry returned error: %v", err) } - state, _ = database.GetVoiceState(user.ID) + state, _ = database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Fatal("voice state should be nil after successful leave") } @@ -2535,10 +2536,10 @@ func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { hub.Register(c2) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user1.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user1.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel u1: %v", err) } - if err := database.JoinVoiceChannel(user2.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user2.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel u2: %v", err) } ws.SetVoiceChIDForTest(c1, vcID) @@ -2554,7 +2555,7 @@ func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { t.Errorf("c2 voiceChID = %d, want 0", got) } - states, _ := database.GetChannelVoiceStates(vcID) + states, _ := database.GetChannelVoiceStates(context.Background(), vcID) if len(states) != 0 { t.Errorf("expected 0 voice states after cleanup, got %d", len(states)) } @@ -2567,7 +2568,7 @@ func TestCleanupVoiceForChannel_EmptyChannel(t *testing.T) { time.Sleep(20 * time.Millisecond) // After cleanup of an empty channel, voice states should still be empty. - states, err := database.GetChannelVoiceStates(vcID) + states, err := database.GetChannelVoiceStates(context.Background(), vcID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -2581,14 +2582,14 @@ func TestCleanupVoiceForChannel_DBStateButNoClient(t *testing.T) { user := seedCoverageOwner(t, database, "cvfc-noclient") vcID := seedVoiceChannel(t, database, "cvfc-noclient-vc") - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } hub.CleanupVoiceForChannel(vcID) time.Sleep(50 * time.Millisecond) - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Error("voice state should be nil after cleanup") } @@ -2602,12 +2603,12 @@ func TestSweepStaleVoiceStates_RemovesGhostState(t *testing.T) { vcID := seedVoiceChannel(t, database, "sweep-ghost-vc") // Put user in voice in DB but don't register a client — ghost state. - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } // Verify it exists. - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state == nil { t.Fatal("voice state should exist before sweep") } @@ -2616,7 +2617,7 @@ func TestSweepStaleVoiceStates_RemovesGhostState(t *testing.T) { time.Sleep(100 * time.Millisecond) // Ghost state should be removed. - state, _ = database.GetVoiceState(user.ID) + state, _ = database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Error("ghost voice state should be nil after sweep") } @@ -2633,7 +2634,7 @@ func TestSweepStaleVoiceStates_PreservesActiveClientState(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } ws.SetVoiceChIDForTest(c, vcID) @@ -2642,7 +2643,7 @@ func TestSweepStaleVoiceStates_PreservesActiveClientState(t *testing.T) { time.Sleep(100 * time.Millisecond) // Active client's state should be preserved. - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state == nil { t.Error("active client's voice state should be preserved after sweep") } @@ -2656,7 +2657,7 @@ func TestSweepStaleVoiceStates_NoStatesNoPanic(t *testing.T) { // With no voice states in the DB, sweep should leave the system clean. // Verify by checking a known user has no voice state. user := seedCoverageOwner(t, database, "sweep-no-states") - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -2677,7 +2678,7 @@ func TestSweepStaleVoiceStates_MismatchedChannelIsGhost(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user.ID, vc2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vc2); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } ws.SetVoiceChIDForTest(c, vc1) // Client thinks vc1, DB says vc2 — mismatch. @@ -2686,7 +2687,7 @@ func TestSweepStaleVoiceStates_MismatchedChannelIsGhost(t *testing.T) { time.Sleep(100 * time.Millisecond) // Mismatched state should be removed from DB. - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Error("mismatched voice state should be removed after sweep") } diff --git a/Server/ws/deps.go b/Server/ws/deps.go index 19afa147..f99a17e9 100644 --- a/Server/ws/deps.go +++ b/Server/ws/deps.go @@ -88,7 +88,7 @@ type VoiceDeps struct { // permission bit is genuinely absent from the user's role). Previously // every branch returned FORBIDDEN, which hid operator-visible failures // behind a user-facing permission denial. -func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, perm int64, label string) *Result { +func requirePerm(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64, label string) *Result { if database == nil || perms == nil { // Missing dependency is a server bug, not a user ACL outcome. Log // here so operators see something even when the client surfaces a @@ -98,7 +98,7 @@ func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, r := Result{Error: ClientError{Code: ErrCodeInternal, Message: "permission check unavailable"}} return &r } - role, err := database.GetRoleForUser(userID) + role, err := database.GetRoleForUser(ctx, userID) if err != nil { slog.Error("ws: requirePerm GetRoleForUser failed", "user_id", userID, "channel_id", channelID, "err", err) @@ -110,7 +110,7 @@ func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}} return &r } - if !perms.HasChannelPerm(role.Permissions, role.ID, channelID, perm) { + if !perms.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) { r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}} return &r } @@ -118,15 +118,15 @@ func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, } // hasPerm checks a channel permission via DB lookups. Returns true if allowed. -func hasPerm(database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool { +func hasPerm(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool { if database == nil || perms == nil { return false } - role, err := database.GetRoleForUser(userID) + role, err := database.GetRoleForUser(ctx, userID) if err != nil || role == nil { return false } - return perms.HasChannelPerm(role.Permissions, role.ID, channelID, perm) + return perms.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) } // ── V2 handler type ───────────────────────────────────────────────────────── diff --git a/Server/ws/dm_handlers_test.go b/Server/ws/dm_handlers_test.go index cdbf6153..e2b40f9e 100644 --- a/Server/ws/dm_handlers_test.go +++ b/Server/ws/dm_handlers_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "fmt" "testing" @@ -15,7 +16,7 @@ import ( // seedDMChannel creates a DM channel between two users and returns the channel ID. func seedDMChannel(t *testing.T, database *db.DB, user1ID, user2ID int64) int64 { t.Helper() - ch, _, err := database.GetOrCreateDMChannel(user1ID, user2ID) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1ID, user2ID) if err != nil { t.Fatalf("seedDMChannel: %v", err) } @@ -244,7 +245,7 @@ func TestDM_ChatSend_AutoReopenForRecipient(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Bob closes the DM. - if err := database.CloseDM(bob.ID, dmChID); err != nil { + if err := database.CloseDM(context.Background(), bob.ID, dmChID); err != nil { t.Fatalf("CloseDM: %v", err) } @@ -281,7 +282,7 @@ func TestDM_ChatEdit_ParticipantCanEdit(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Create a message directly in the DB. - msgID, err := database.CreateMessage(dmChID, alice.ID, "original", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "original", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -310,7 +311,7 @@ func TestDM_ChatEdit_NonParticipantForbidden(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Alice creates a message. - msgID, err := database.CreateMessage(dmChID, alice.ID, "private", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "private", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -338,7 +339,7 @@ func TestDM_ChatDelete_ParticipantCanDeleteOwn(t *testing.T) { bob := seedMemberUser(t, database, "dm-del-bob") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "to delete", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "to delete", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -372,7 +373,7 @@ func TestDM_ChatDelete_NonParticipantForbidden(t *testing.T) { charlie := seedMemberUser(t, database, "dm-delforbid-charlie") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "protected", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "protected", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -400,7 +401,7 @@ func TestDM_ChatDelete_NoModeratorOverride(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Bob's message. - msgID, err := database.CreateMessage(dmChID, bob.ID, "bob says hi", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, bob.ID, "bob says hi", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -540,7 +541,7 @@ func TestDM_ReactionAdd_ParticipantSuccess(t *testing.T) { bob := seedMemberUser(t, database, "dm-react-bob") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "react to me", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "react to me", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -574,7 +575,7 @@ func TestDM_ReactionAdd_NonParticipantError(t *testing.T) { charlie := seedMemberUser(t, database, "dm-reactforbid-charlie") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "private msg", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "private msg", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -601,7 +602,7 @@ func TestDM_ReactionRemove_ParticipantSuccess(t *testing.T) { bob := seedMemberUser(t, database, "dm-reactrm-bob") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, bob.ID, "remove reaction", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, bob.ID, "remove reaction", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 9a115869..16ba1aaf 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -147,7 +147,7 @@ func TouchForTest(c *Client) { // RollbackVoiceJoinForTest exposes Hub.rollbackVoiceJoin for external tests. func (h *Hub) RollbackVoiceJoinForTest(c *Client, channelID int64) { - h.rollbackVoiceJoin(c, channelID, true) + h.rollbackVoiceJoin(context.Background(), c, channelID, true) } // LeaveVoiceChannelWithRetryForTest exposes leaveVoiceChannelWithRetry for external tests. @@ -194,29 +194,29 @@ func (h *Hub) PubSubForTest() *PubSub { // Defaults to replay_source="none" since most callers test the fresh-connect // path; tests that care about the resume tier can call buildAuthOK directly. func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte { - return h.buildAuthOK(user, roleName, "none") + return h.buildAuthOK(context.Background(), user, roleName, "none") } // BuildReadyForTest exposes Hub.buildReady for external tests. // Passes nil role so no channels are visible (fail-closed, BUG-094). func (h *Hub) BuildReadyForTest(database *db.DB, userID int64) ([]byte, error) { - return h.buildReady(database, userID, nil) + return h.buildReady(context.Background(), database, userID, nil) } // BuildReadyWithRoleForTest exposes Hub.buildReady with a role for external tests. func (h *Hub) BuildReadyWithRoleForTest(database *db.DB, userID int64, role *db.Role) ([]byte, error) { - return h.buildReady(database, userID, role) + return h.buildReady(context.Background(), database, userID, role) } // ComputeAllowedChannelsForTest exposes Hub.computeAllowedChannels for external // tests (the REST/WS channel-visibility agreement test). func (h *Hub) ComputeAllowedChannelsForTest(database *db.DB, user *db.User) (map[int64]bool, error) { - return h.computeAllowedChannels(database, user) + return h.computeAllowedChannels(context.Background(), database, user) } // GetCachedSettingsForTest exposes Hub.getCachedSettings for external tests. func (h *Hub) GetCachedSettingsForTest() (string, string) { - return h.getCachedSettings() + return h.getCachedSettings(context.Background()) } // GetClientVoiceChIDForTest exposes Client.getVoiceChID for external tests. @@ -316,5 +316,5 @@ func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool { // HasChannelPermForTest exposes Hub.hasChannelPerm for external tests. func (h *Hub) HasChannelPermForTest(c *Client, channelID, perm int64) bool { - return h.hasChannelPerm(c, channelID, perm) + return h.hasChannelPerm(context.Background(), c, channelID, perm) } diff --git a/Server/ws/handler_v2_channel_focus_test.go b/Server/ws/handler_v2_channel_focus_test.go index d44f1b2c..136ab048 100644 --- a/Server/ws/handler_v2_channel_focus_test.go +++ b/Server/ws/handler_v2_channel_focus_test.go @@ -23,11 +23,11 @@ func newFocusTestDeps(t *testing.T) (PresenceDeps, int64, int64) { } t.Cleanup(func() { database.Close() }) - userID, err := database.CreateUser("focuser", "hash", 1) // Owner role + userID, err := database.CreateUser(context.Background(), "focuser", "hash", 1) // Owner role if err != nil { t.Fatalf("CreateUser: %v", err) } - chID, err := database.CreateChannel("focus-chan", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "focus-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -98,11 +98,11 @@ func TestChannelFocusV2_NoPermission_ReturnsForbidden(t *testing.T) { t.Cleanup(func() { database.Close() }) // Use Member role (id=4) and create a channel with a deny override. - userID, _ := database.CreateUser("noperm", "hash", 4) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + userID, _ := database.CreateUser(context.Background(), "noperm", "hash", 4) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role on this channel via raw SQL. - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, ?)`, chID, permissions.ReadMessages, ) diff --git a/Server/ws/handler_v2_migration_test.go b/Server/ws/handler_v2_migration_test.go index d41bbc6b..b2fef015 100644 --- a/Server/ws/handler_v2_migration_test.go +++ b/Server/ws/handler_v2_migration_test.go @@ -140,7 +140,7 @@ func TestHandleChatCommandV2_NoRegistry(t *testing.T) { // canPluginBroadcast fails closed when the posting-gate service is absent. func TestCanPluginBroadcast_NilServiceFailsClosed(t *testing.T) { - gate := canPluginBroadcast(nil, 1, 2) + gate := canPluginBroadcast(context.Background(), nil, 1, 2) if gate == nil { t.Fatal("expected a forbidden Result when MessageSvc is nil") } diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 92e621b0..6aad9065 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -37,7 +37,7 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { c.mu.Unlock() if shouldCheck && c.tokenHash != "" { - result, dbErr := h.db.GetSessionWithBanStatus(c.tokenHash) + result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash) if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { slog.Info("ws session expired, closing connection", "user_id", c.userID) h.kickClient(c) @@ -200,19 +200,19 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { // connection — including the SPEAK/VIDEO grants baked into a freshly minted // LiveKit token — instead of persisting until the user reconnects. This mirrors // the V2 handlers, which already resolve the live role (deps.go). -func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool { - role, err := h.db.GetRoleForUser(c.userID) +func (h *Hub) hasChannelPerm(ctx context.Context, c *Client, channelID int64, perm int64) bool { + role, err := h.db.GetRoleForUser(ctx, c.userID) if err != nil || role == nil { return false } - return h.permChecker.HasChannelPerm(role.Permissions, role.ID, channelID, perm) + return h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) } // requireChannelPerm checks whether the client has the given permission on the // channel. If not, it sends a FORBIDDEN error to the client and returns false. // The permLabel should be the human-readable permission name (e.g. "SEND_MESSAGES"). -func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLabel string) bool { - if h.hasChannelPerm(c, channelID, perm) { +func (h *Hub) requireChannelPerm(ctx context.Context, c *Client, channelID int64, perm int64, permLabel string) bool { + if h.hasChannelPerm(ctx, c, channelID, perm) { return true } slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel) diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index 02bed897..70b8481c 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -78,11 +78,11 @@ func handleChatSendV2(ctx context.Context, cmd Command, info ClientInfo, deps an } // handleChatEditV2 processes a chat_edit command via the MessageService. -func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleChatEditV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ChatDeps) editCmd := cmd.(ChatEditCmd) - result, err := d.MessageSvc.EditMessage(info.UserID, editCmd.MessageID(), editCmd.Content()) + result, err := d.MessageSvc.EditMessage(ctx, info.UserID, editCmd.MessageID(), editCmd.Content()) if err != nil { return serviceErrorToResult(err) } @@ -102,11 +102,11 @@ func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) } // handleChatDeleteV2 processes a chat_delete command via the MessageService. -func handleChatDeleteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleChatDeleteV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ChatDeps) deleteCmd := cmd.(ChatDeleteCmd) - result, err := d.MessageSvc.DeleteMessage(info.UserID, deleteCmd.MessageID()) + result, err := d.MessageSvc.DeleteMessage(ctx, info.UserID, deleteCmd.MessageID()) if err != nil { return serviceErrorToResult(err) } diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 341b8766..aff262fa 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -67,7 +67,7 @@ func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps an // gate denies, the error wins and the ephemeral reply is dropped (Result // carries either an error or a reply, not both) — an untested edge; V1 // sent both. Preserve the security signal (denial) over the ack. - if gate := canPluginBroadcast(d.MessageSvc, cc.userID, cc.channelID); gate != nil { + if gate := canPluginBroadcast(ctx, d.MessageSvc, cc.userID, cc.channelID); gate != nil { return *gate } msg := buildCommandBroadcast(cc.channelID, cc.userID, cc.command, result.Broadcast) @@ -83,12 +83,12 @@ func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps an // allowed, or a Result carrying the appropriate ClientError otherwise. A nil // MessageSvc (bare test hub) fails closed rather than allowing an ungated // broadcast. -func canPluginBroadcast(messageSvc *service.MessageService, userID, channelID int64) *Result { +func canPluginBroadcast(ctx context.Context, messageSvc *service.MessageService, userID, channelID int64) *Result { if messageSvc == nil { r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "broadcast gate unavailable"}} return &r } - if err := messageSvc.CanPost(userID, channelID); err != nil { + if err := messageSvc.CanPost(ctx, userID, channelID); err != nil { if errors.Is(err, service.ErrNotFound) { r := Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}} return &r diff --git a/Server/ws/handlers_presence.go b/Server/ws/handlers_presence.go index 58569a8d..0aae0f7f 100644 --- a/Server/ws/handlers_presence.go +++ b/Server/ws/handlers_presence.go @@ -18,13 +18,13 @@ func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) { // handleTypingV2 is the V2 handler for typing_start messages. // It validates the channel, checks permissions, and returns events to broadcast // the typing indicator to channel members (excluding the sender). -func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleTypingV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(PresenceDeps) typingCmd := cmd.(TypingStartCmd) channelID := typingCmd.ChannelID() userID := info.UserID - ch, err := d.ChannelSvc.HandleTyping(userID, channelID, d.Limiter) + ch, err := d.ChannelSvc.HandleTyping(ctx, userID, channelID, d.Limiter) if err != nil || ch == nil { return Result{} // silently drop } @@ -32,7 +32,7 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R payload := buildTypingMsg(channelID, userID, info.Username) if ch.Type == "dm" { - participantIDs, pErr := d.ChannelSvc.GetDMParticipantIDs(channelID) + participantIDs, pErr := d.ChannelSvc.GetDMParticipantIDs(ctx, channelID) if pErr != nil { return Result{} } @@ -62,13 +62,13 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R // handlePresenceV2 is the V2 handler for presence_update messages. // It validates the status, updates the DB, and broadcasts to all clients. -func handlePresenceV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handlePresenceV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(PresenceDeps) presenceCmd := cmd.(PresenceUpdateCmd) userID := info.UserID status := presenceCmd.Status() - if err := d.ChannelSvc.HandlePresenceUpdate(userID, status, d.Limiter); err != nil { + if err := d.ChannelSvc.HandlePresenceUpdate(ctx, userID, status, d.Limiter); err != nil { return serviceErrorToResult(err) } @@ -82,12 +82,12 @@ func handlePresenceV2(_ context.Context, cmd Command, info ClientInfo, deps any) // handleChannelFocusV2 is the V2 handler for channel_focus messages. // It validates permissions, signals the client's focused channel via SetChannelID, // and marks the channel as read. -func handleChannelFocusV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleChannelFocusV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(PresenceDeps) focusCmd := cmd.(ChannelFocusCmd) chID := focusCmd.ChannelID() - _, err := d.ChannelSvc.HandleChannelFocus(info.UserID, chID) + _, err := d.ChannelSvc.HandleChannelFocus(ctx, info.UserID, chID) if err != nil { if errors.Is(err, service.ErrForbidden) { return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "access denied"}} diff --git a/Server/ws/handlers_reaction.go b/Server/ws/handlers_reaction.go index fe5053fa..8f75b159 100644 --- a/Server/ws/handlers_reaction.go +++ b/Server/ws/handlers_reaction.go @@ -15,7 +15,7 @@ func registerReactionHandlers(r *HandlerRegistry, deps ReactionDeps) { // reactionV2Handler returns a V2 handler for reaction_add (add=true) or // reaction_remove (add=false). Both share identical validation and routing. func reactionV2Handler(add bool) HandlerV2 { - return func(_ context.Context, cmd Command, info ClientInfo, deps any) Result { + return func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ReactionDeps) userID := info.UserID @@ -34,9 +34,9 @@ func reactionV2Handler(add bool) HandlerV2 { var result *service.ReactionResult var err error if add { - result, err = d.MessageSvc.AddReaction(userID, msgID, emoji) + result, err = d.MessageSvc.AddReaction(ctx, userID, msgID, emoji) } else { - result, err = d.MessageSvc.RemoveReaction(userID, msgID, emoji) + result, err = d.MessageSvc.RemoveReaction(ctx, userID, msgID, emoji) } if err != nil { return serviceErrorToResult(err) diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 688a81c8..e253eb66 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "fmt" "testing" @@ -85,11 +86,11 @@ func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) { // includes MANAGE_MESSAGES bit 0x10000). func seedModUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 3) // roleID=3 → Moderator + _, err := database.CreateUser(context.Background(), username, "hash", 3) // roleID=3 → Moderator if err != nil { t.Fatalf("seedModUser CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedModUser GetUserByUsername: %v", err) } @@ -100,11 +101,11 @@ func seedModUser(t *testing.T, database *db.DB, username string) *db.User { // does NOT have MANAGE_MESSAGES (0x10000=65536). func seedMemberUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 4) // roleID=4 → Member + _, err := database.CreateUser(context.Background(), username, "hash", 4) // roleID=4 → Member if err != nil { t.Fatalf("seedMemberUser CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedMemberUser GetUserByUsername: %v", err) } @@ -115,12 +116,12 @@ func seedMemberUser(t *testing.T, database *db.DB, username string) *db.User { // given seconds value, then returns the channel ID. func seedChannelWithSlowMode(t *testing.T, database *db.DB, name string, slowModeSecs int) int64 { t.Helper() - chID, err := database.CreateChannel(name, "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), name, "text", "", "", 0) if err != nil { t.Fatalf("seedChannelWithSlowMode CreateChannel: %v", err) } if slowModeSecs > 0 { - if err := database.SetChannelSlowMode(chID, slowModeSecs); err != nil { + if err := database.SetChannelSlowMode(context.Background(), chID, slowModeSecs); err != nil { t.Fatalf("seedChannelWithSlowMode SetChannelSlowMode: %v", err) } } @@ -194,7 +195,7 @@ func TestSessionExpiry_ValidSessionAllowsMessages(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } hash := auth.HashToken(token) - if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -228,11 +229,11 @@ func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } hash := auth.HashToken(token) - if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Delete the session to simulate it being expired/revoked. - if err := database.DeleteSession(hash); err != nil { + if err := database.DeleteSession(context.Background(), hash); err != nil { t.Fatalf("DeleteSession: %v", err) } @@ -501,7 +502,7 @@ func chatSendMsgWithAttachments(channelID int64, content string, attachmentIDs [ // denyAttachOnChannel inserts a channel_override that denies ATTACH_FILES. func denyAttachOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, channelID, roleID, permissions.AttachFiles, ) @@ -536,7 +537,7 @@ func TestChatSend_AttachmentsDeniedNoMessageCreated(t *testing.T) { // Verify no message was persisted in the database. var count int - err := database.QueryRow("SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count) + err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count) if err != nil { t.Fatalf("count query: %v", err) } @@ -739,7 +740,7 @@ func TestChatSend_SuccessWithReplyTo(t *testing.T) { hub, database := newHandlerHub(t) user := seedOwnerUser(t, database, "send-reply1") chID := seedTestChannel(t, database, "send-reply-chan") - parentMsgID, err := database.CreateMessage(chID, user.ID, "parent message", nil) + parentMsgID, err := database.CreateMessage(context.Background(), chID, user.ID, "parent message", nil) if err != nil { t.Fatalf("CreateMessage parent: %v", err) } @@ -851,7 +852,7 @@ func TestPresence_RateLimit_ReturnsError(t *testing.T) { // and returns its ID. func seedMessage(t *testing.T, database *db.DB, channelID, userID int64, content string) int64 { t.Helper() - id, err := database.CreateMessage(channelID, userID, content, nil) + id, err := database.CreateMessage(context.Background(), channelID, userID, content, nil) if err != nil { t.Fatalf("seedMessage CreateMessage: %v", err) } @@ -1277,7 +1278,7 @@ func TestChatEdit_DeletedMessage_ReturnsForbidden(t *testing.T) { msgID := seedMessage(t, database, chID, user.ID, "to be deleted") // Soft-delete the message. - if err := database.DeleteMessage(msgID, user.ID, false); err != nil { + if err := database.DeleteMessage(context.Background(), msgID, user.ID, false); err != nil { t.Fatalf("DeleteMessage: %v", err) } @@ -1331,7 +1332,7 @@ func TestReaction_RemoveReaction_BroadcastsReactionUpdate(t *testing.T) { msgID := seedMessage(t, database, chID, user.ID, "react to me 2") // Pre-seed the reaction so removal has something to remove. - if err := database.AddReaction(msgID, user.ID, "❤️"); err != nil { + if err := database.AddReaction(context.Background(), msgID, user.ID, "❤️"); err != nil { t.Fatalf("seedReaction: %v", err) } @@ -1524,7 +1525,7 @@ func TestReaction_DeletedMessage_ReturnsBadRequest(t *testing.T) { msgID := seedMessage(t, database, chID, user.ID, "to be deleted") // Soft-delete the message. - if err := database.DeleteMessage(msgID, user.ID, false); err != nil { + if err := database.DeleteMessage(context.Background(), msgID, user.ID, false); err != nil { t.Fatalf("DeleteMessage: %v", err) } @@ -1960,12 +1961,12 @@ func TestHandleMessage_BannedUser_GetKickedAfterSessionCheck(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } hash := auth.HashToken(token) - if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Ban the user in the database (permanent ban, no expiry). - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `UPDATE users SET banned=1, ban_reason='test ban', ban_expires=NULL WHERE id=?`, user.ID, ); err != nil { diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 925b252b..c9eb684b 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -157,12 +157,12 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * KeyHolder: h, }) - h.refreshSettingsLocked() + h.refreshSettingsLocked(context.Background()) return h } // getCachedSettings returns server_name and motd, refreshing the cache if stale. -func (h *Hub) getCachedSettings() (string, string) { +func (h *Hub) getCachedSettings(ctx context.Context) (string, string) { h.settingsMu.RLock() if time.Since(h.settingsLastUpdate) < settingsCacheTTL { name, motd := h.settingsName, h.settingsMotd @@ -177,20 +177,24 @@ func (h *Hub) getCachedSettings() (string, string) { if time.Since(h.settingsLastUpdate) < settingsCacheTTL { return h.settingsName, h.settingsMotd } - h.refreshSettingsLocked() + h.refreshSettingsLocked(ctx) return h.settingsName, h.settingsMotd } // refreshSettingsLocked reloads server_name and motd from the DB. // Caller must hold settingsMu (write lock) or call during init. -func (h *Hub) refreshSettingsLocked() { +func (h *Hub) refreshSettingsLocked(ctx context.Context) { if h.db == nil { return } - if name, err := h.db.GetSetting("server_name"); err == nil { + // The refresh serves the hub-wide settings cache, not the connection that + // happened to trigger it — a dying connection's ctx must not fail the + // fetches (the TTL stamp below would then pin stale values for 30s). + ctx = context.WithoutCancel(ctx) + if name, err := h.db.GetSetting(ctx, "server_name"); err == nil { h.settingsName = name } - if motd, err := h.db.GetSetting("motd"); err == nil { + if motd, err := h.db.GetSetting(ctx, "motd"); err == nil { h.settingsMotd = motd } h.settingsLastUpdate = time.Now() @@ -357,8 +361,10 @@ func (h *Hub) GracefulStop() { // CleanupVoiceForChannel removes all voice participants from the given channel. // Called when a channel is deleted. func (h *Hub) CleanupVoiceForChannel(channelID int64) { + // Cleanup must complete even if the triggering request goes away. + ctx := context.Background() // Get all users in the channel's voice state from DB. - states, err := h.db.GetChannelVoiceStates(channelID) + states, err := h.db.GetChannelVoiceStates(ctx, channelID) if err != nil { slog.Error("CleanupVoiceForChannel GetChannelVoiceStates", "err", err, "channel_id", channelID) return @@ -369,7 +375,7 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) { // Clean up DB state and LiveKit for each participant. for _, vs := range states { - if err := h.db.LeaveVoiceChannel(vs.UserID); err != nil { + if err := h.db.LeaveVoiceChannel(ctx, vs.UserID); err != nil { slog.Error("CleanupVoiceForChannel LeaveVoiceChannel", "err", err, "user_id", vs.UserID, "channel_id", channelID) } @@ -382,7 +388,7 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) { // Remove from LiveKit (best-effort). if h.livekit != nil { - _ = h.livekit.RemoveParticipant(channelID, vs.UserID, vs.JoinedAt) + _ = h.livekit.RemoveParticipant(ctx, channelID, vs.UserID, vs.JoinedAt) } } @@ -555,6 +561,10 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { } h.mu.RUnlock() + // Called via the admin HubBroadcaster interface, which carries no context; + // the targeted re-sync must complete regardless of the triggering request. + ctx := context.Background() + // Visibility is a function of the role, so resolve each role once. visibleByRole := make(map[int64]bool) roleVisible := func(roleID int64) bool { @@ -562,12 +572,12 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { return v } visible := false - role, err := h.db.GetRoleByID(roleID) + role, err := h.db.GetRoleByID(ctx, roleID) if err == nil && role != nil { // Single visibility predicate shared with buildReady / REST // ListVisibleChannels; the checker fails closed on a lookup error // and bypasses for admins, matching the other sites exactly. - visible = h.permChecker.HasChannelPerm(role.Permissions, roleID, ch.ID, permissions.ReadMessages) + visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, ch.ID, permissions.ReadMessages) } visibleByRole[roleID] = visible return visible @@ -580,7 +590,7 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { // c.user is a connect-time snapshot; an admin may have changed the // user's role mid-session, so resolve the current role from the DB. // Fail closed: on error send nothing rather than mis-target. - fresh, err := h.db.GetUserByID(c.user.ID) + fresh, err := h.db.GetUserByID(ctx, c.user.ID) if err != nil || fresh == nil { slog.Warn("hub: RefreshChannelVisibility could not resolve user role", "user_id", c.user.ID, "err", err) @@ -922,6 +932,8 @@ func (h *Hub) sweepRevokedSessions() { if h.db == nil { return } + // Hub run-loop sweeper — no request tie. + ctx := context.Background() h.mu.RLock() snapshot := make([]*Client, 0, len(h.clients)) @@ -933,7 +945,7 @@ func (h *Hub) sweepRevokedSessions() { h.mu.RUnlock() for _, c := range snapshot { - result, err := h.db.GetSessionWithBanStatus(c.tokenHash) + result, err := h.db.GetSessionWithBanStatus(ctx, c.tokenHash) if err != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { slog.Info("session sweep: revoked/expired session, disconnecting", "user_id", c.userID) @@ -958,7 +970,9 @@ func (h *Hub) sweepStaleVoiceStates() { if h.db == nil { return } - allStates, err := h.db.GetAllVoiceStates() + // Hub run-loop sweeper — no request tie. + ctx := context.Background() + allStates, err := h.db.GetAllVoiceStates(ctx) if err != nil { slog.Warn("sweepStaleVoiceStates: GetAllVoiceStates failed", "err", err) return @@ -989,7 +1003,7 @@ func (h *Hub) sweepStaleVoiceStates() { // Channel-conditional delete: only removes the row if it still points // at the channel we snapshotted. If the user rejoined or moved between // the snapshot and now, the delete is a no-op and we skip the broadcast. - deleted, err := h.db.LeaveVoiceChannelIfMatch(s.userID, s.channelID, s.joinedAt) + deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt) if err != nil { slog.Error("sweepStaleVoiceStates: LeaveVoiceChannelIfMatch failed", "err", err, "user_id", s.userID, "channel_id", s.channelID) @@ -1002,7 +1016,7 @@ func (h *Hub) sweepStaleVoiceStates() { "user_id", s.userID, "channel_id", s.channelID) h.BroadcastToAll(buildVoiceLeave(s.channelID, s.userID)) if h.livekit != nil { - _ = h.livekit.RemoveParticipant(s.channelID, s.userID, s.joinedAt) + _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) } } } diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index 6bbfebbb..075c5c59 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -44,7 +44,7 @@ func newTestHub(t *testing.T) (*ws.Hub, *db.DB) { // seedTestUser inserts a Member-role user and returns its ID. func seedTestUser(t *testing.T, database *db.DB, username string) int64 { t.Helper() - id, err := database.CreateUser(username, "hash", 4) + id, err := database.CreateUser(context.Background(), username, "hash", 4) if err != nil { t.Fatalf("seedUser: %v", err) } @@ -55,11 +55,11 @@ func seedTestUser(t *testing.T, database *db.DB, username string) int64 { // Owner role (id=1) has all permissions (0x7FFFFFFF), so it passes all checks. func seedOwnerUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner + _, err := database.CreateUser(context.Background(), username, "hash", 1) // roleID=1 → Owner if err != nil { t.Fatalf("seedOwnerUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedOwnerUser GetUserByUsername: %v", err) } @@ -69,7 +69,7 @@ func seedOwnerUser(t *testing.T, database *db.DB, username string) *db.User { // seedTestChannel inserts a channel and returns its ID. func seedTestChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "text", "", "", 0) if err != nil { t.Fatalf("seedChannel: %v", err) } @@ -716,27 +716,27 @@ func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) { defer hub.Stop() // Create two users with sessions. - uid1, err := database.CreateUser("alice-revoke", "hash", 3) + uid1, err := database.CreateUser(context.Background(), "alice-revoke", "hash", 3) if err != nil { t.Fatalf("CreateUser: %v", err) } - uid2, err := database.CreateUser("bob-valid", "hash", 3) + uid2, err := database.CreateUser(context.Background(), "bob-valid", "hash", 3) if err != nil { t.Fatalf("CreateUser: %v", err) } - u1, _ := database.GetUserByID(uid1) - u2, _ := database.GetUserByID(uid2) + u1, _ := database.GetUserByID(context.Background(), uid1) + u2, _ := database.GetUserByID(context.Background(), uid2) token1 := "revoke-token-1" token2 := "valid-token-2" hash1 := auth.HashToken(token1) hash2 := auth.HashToken(token2) - if _, err := database.CreateSession(uid1, hash1, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid1, hash1, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession 1: %v", err) } - if _, err := database.CreateSession(uid2, hash2, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid2, hash2, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession 2: %v", err) } @@ -750,7 +750,7 @@ func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) { time.Sleep(20 * time.Millisecond) // Delete alice's session (simulating logout from another device). - if err := database.DeleteSession(hash1); err != nil { + if err := database.DeleteSession(context.Background(), hash1); err != nil { t.Fatalf("DeleteSession: %v", err) } @@ -909,14 +909,14 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { defer hub.Stop() chID := seedTestChannel(t, database, "secret-room") - ch, err := database.GetChannel(chID) + ch, err := database.GetChannel(context.Background(), chID) if err != nil || ch == nil { t.Fatalf("GetChannel: %v", err) } owner := seedOwnerUser(t, database, "vis-owner") memberID := seedTestUser(t, database, "vis-member") - member, err := database.GetUserByID(memberID) + member, err := database.GetUserByID(context.Background(), memberID) if err != nil || member == nil { t.Fatalf("GetUserByID: %v", err) } @@ -930,7 +930,7 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { time.Sleep(30 * time.Millisecond) // Hide the channel from the Member role (deny ReadMessages). - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, chID, ); err != nil { @@ -944,7 +944,7 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { drainForMsgType(t, ownerSend, "channel_create") // Restore visibility — the member gets the channel back. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID, ); err != nil { t.Fatalf("delete override: %v", err) @@ -958,7 +958,7 @@ func TestRefreshChannelVisibility_ForcesFullResyncForStaleResumes(t *testing.T) hub, database := newTestHub(t) chID := seedTestChannel(t, database, "watermark-room") - ch, err := database.GetChannel(chID) + ch, err := database.GetChannel(context.Background(), chID) if err != nil || ch == nil { t.Fatalf("GetChannel: %v", err) } diff --git a/Server/ws/livekit.go b/Server/ws/livekit.go index aacafbb8..a1392e00 100644 --- a/Server/ws/livekit.go +++ b/Server/ws/livekit.go @@ -153,11 +153,11 @@ func (c *LiveKitClient) URL() string { const lkTimeout = 5 * time.Second // RemoveParticipant forcefully disconnects a participant from a room. -func (c *LiveKitClient) RemoveParticipant(channelID int64, userID int64, voiceJoinToken string) error { +func (c *LiveKitClient) RemoveParticipant(ctx context.Context, channelID int64, userID int64, voiceJoinToken string) error { roomName := RoomName(channelID) identity := participantIdentity(userID, voiceJoinToken) - ctx, cancel := context.WithTimeout(context.Background(), lkTimeout) + ctx, cancel := context.WithTimeout(ctx, lkTimeout) defer cancel() _, err := c.roomSvc.RemoveParticipant(ctx, &livekit.RoomParticipantIdentity{ Room: roomName, diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index ebb1b5b6..7debe3c1 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -391,10 +391,10 @@ func TestWebhook_ParticipantLeft_NoDoubleBroadcast_AfterFreshCleanup(t *testing. // Insert the matching DB row first so the simulated client carries the // same join token production would have persisted and handed to LiveKit. - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - vs, err := database.GetVoiceState(user.ID) + vs, err := database.GetVoiceState(context.Background(), user.ID) if err != nil || vs == nil { t.Fatalf("GetVoiceState: %v (nil=%v)", err, vs == nil) } @@ -407,7 +407,7 @@ func TestWebhook_ParticipantLeft_NoDoubleBroadcast_AfterFreshCleanup(t *testing. // --- Simulate what serve.go fresh-cleanup does (lines 150-172) --- // 1. Delete the DB row. - deleted, err := database.LeaveVoiceChannelIfMatch(user.ID, chanID, vs.JoinedAt) + deleted, err := database.LeaveVoiceChannelIfMatch(context.Background(), user.ID, chanID, vs.JoinedAt) if err != nil || !deleted { t.Fatalf("LeaveVoiceChannelIfMatch: err=%v deleted=%v", err, deleted) } @@ -459,18 +459,18 @@ func TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement(t *testing. // Create an old same-channel voice session, then rejoin the same channel so // the DB carries a replacement join token like production would. - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { t.Fatalf("JoinVoiceChannel(old): %v", err) } - oldState, err := database.GetVoiceState(user.ID) + oldState, err := database.GetVoiceState(context.Background(), user.ID) if err != nil || oldState == nil { t.Fatalf("GetVoiceState(old): %v (nil=%v)", err, oldState == nil) } - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { t.Fatalf("JoinVoiceChannel(new): %v", err) } - newState, err := database.GetVoiceState(user.ID) + newState, err := database.GetVoiceState(context.Background(), user.ID) if err != nil || newState == nil { t.Fatalf("GetVoiceState(new): %v (nil=%v)", err, newState == nil) } @@ -504,7 +504,7 @@ func TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement(t *testing. } // DB row should still exist. - vs, err := database.GetVoiceState(user.ID) + vs, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index 51898cf9..12baad35 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -98,7 +98,7 @@ func parseRoomChannelID(roomName string) (int64, error) { return strconv.ParseInt(roomName[8:], 10, 64) } -func (h *Hub) handleWebhookParticipantJoined(_ context.Context, event *livekit.WebhookEvent) { +func (h *Hub) handleWebhookParticipantJoined(ctx context.Context, event *livekit.WebhookEvent) { p := event.GetParticipant() room := event.GetRoom() if p == nil || room == nil { @@ -128,12 +128,12 @@ func (h *Hub) handleWebhookParticipantJoined(_ context.Context, event *livekit.W // A replayed token from a previous session will not have a matching row, // so we remove the rogue participant from LiveKit. if h.db != nil { - state, stateErr := h.db.GetVoiceState(userID) + state, stateErr := h.db.GetVoiceState(ctx, userID) if stateErr != nil || state == nil || state.ChannelID != channelID { slog.Warn("livekit webhook: rogue participant_joined — no matching voice state, removing", "user_id", userID, "channel_id", channelID) if h.livekit != nil { - if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { //nolint:contextcheck // RemoveParticipant manages its own timeout context + if rmErr := h.livekit.RemoveParticipant(ctx, channelID, userID, joinToken); rmErr != nil { slog.Error("livekit webhook: failed to remove rogue participant", "error", rmErr, "user_id", userID, "channel_id", channelID) } @@ -146,7 +146,7 @@ func (h *Hub) handleWebhookParticipantJoined(_ context.Context, event *livekit.W "user_id", userID, "channel_id", channelID, "expected_token", state.JoinedAt, "got_token", joinToken) if h.livekit != nil { - if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { //nolint:contextcheck // RemoveParticipant manages its own timeout context + if rmErr := h.livekit.RemoveParticipant(ctx, channelID, userID, joinToken); rmErr != nil { slog.Error("livekit webhook: failed to remove stale participant", "error", rmErr, "user_id", userID, "channel_id", channelID) } @@ -210,7 +210,7 @@ func (h *Hub) handleWebhookParticipantLeft(ctx context.Context, event *livekit.W } else if h.db != nil { // Client has voiceChID=0 or moved to a different channel (e.g. // after F5 reload), or this webhook is for an older join instance. - deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken) + deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(ctx, userID, channelID, joinToken) if dbErr != nil { slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (stale DB row)", "error", dbErr, "user_id", userID, "channel_id", channelID) @@ -223,7 +223,7 @@ func (h *Hub) handleWebhookParticipantLeft(ctx context.Context, event *livekit.W } else if h.db != nil { // Client already disconnected from WS — use channel-conditional delete // to avoid wiping a newer row if the user reconnected and rejoined. - deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken) + deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(ctx, userID, channelID, joinToken) if dbErr != nil { slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (client gone)", "error", dbErr, "user_id", userID, "channel_id", channelID) diff --git a/Server/ws/reconnect_db_test.go b/Server/ws/reconnect_db_test.go index 3d41e5ac..c948c09d 100644 --- a/Server/ws/reconnect_db_test.go +++ b/Server/ws/reconnect_db_test.go @@ -61,7 +61,7 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { // computeAllowedChannels returns an empty channel set — but events with // channelID=0 (global) bypass the per-channel filter in the DB event store // and in EventsSinceFiltered, so they are always returned. - userID, err := database.CreateUser("reconnect-db-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "reconnect-db-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -69,7 +69,7 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { if err != nil { t.Fatalf("GenerateToken: %v", err) } - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } diff --git a/Server/ws/serve.go b/Server/ws/serve.go index c245dada..9e2336e4 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -97,13 +97,13 @@ func (h *Hub) upgradeAndAuth( // Look up role name for protocol-compliant payloads and cache on client. roleName := "member" - if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil { + if role, roleErr := database.GetRoleByID(r.Context(), user.RoleID); roleErr == nil && role != nil { roleName = strings.ToLower(role.Name) } c.roleName = roleName slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr) - db.WriteAudit(database, user.ID, "ws_connect", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "ws_connect", "user", user.ID, "WebSocket connected from "+r.RemoteAddr) return c, lastSeq, nil @@ -124,7 +124,7 @@ func (h *Hub) handleReconnect( } // Compute the set of channel IDs the reconnecting user can access so that // channel-scoped replay events are filtered by current permissions (M3). - allowedChannelIDs, err := h.computeAllowedChannels(database, c.user) + allowedChannelIDs, err := h.computeAllowedChannels(ctx, database, c.user) if err != nil { slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready", "user_id", c.userID, "err", err) @@ -179,7 +179,7 @@ func (h *Hub) handleReconnect( // is included in the payload so the client can attribute reconnect // behaviour without separate metric scraping. slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource) - if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, replaySource)); err != nil { + if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil { slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err) h.unregisterNow(c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") @@ -196,7 +196,7 @@ func (h *Hub) handleReconnect( slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource) // Update presence but skip member_join — user was already known. - if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil { + if updateErr := database.UpdateUserStatus(ctx, c.userID, "online"); updateErr != nil { slog.Warn("ws UpdateUserStatus", "err", updateErr) } h.BroadcastToAll(buildPresenceMsg(c.userID, "online")) @@ -210,13 +210,13 @@ func (h *Hub) handleReconnect( // permissions.Checker predicate shared with buildReady and REST // ListVisibleChannels, so replay-buffer filtering can never drift from the // ready payload's visible channels. -func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64]bool, error) { - channels, err := database.ListChannels() +func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user *db.User) (map[int64]bool, error) { + channels, err := database.ListChannels(ctx) if err != nil { return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err) } - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(ctx, user.RoleID) if err != nil { return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err) } @@ -226,7 +226,7 @@ func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64] if role != nil { var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - overrides, err = database.GetAllChannelPermissionsForRole(role.ID) + overrides, err = database.GetAllChannelPermissionsForRole(ctx, role.ID) if err != nil { return nil, fmt.Errorf("computeAllowedChannels GetAllChannelPermissionsForRole: %w", err) } @@ -235,7 +235,7 @@ func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64] } // Include the user's open DM channels. - dmChannels, dmErr := database.GetUserDMChannels(user.ID) + dmChannels, dmErr := database.GetUserDMChannels(ctx, user.ID) if dmErr != nil { slog.Warn("computeAllowedChannels GetUserDMChannels", "err", dmErr) // Non-fatal: DM events will simply be filtered out. @@ -255,10 +255,10 @@ func (h *Hub) handleFreshConnect( // When a user F5-reloads while in voice, the DB row from the previous // session must be removed so the ready payload doesn't include it and // other clients see a voice_leave broadcast. - if vs, err := database.GetVoiceState(c.userID); err == nil && vs != nil { + if vs, err := database.GetVoiceState(ctx, c.userID); err == nil && vs != nil { slog.Info("ws fresh connect: cleaning stale voice state", "user_id", c.userID, "channel_id", vs.ChannelID) - if _, delErr := database.LeaveVoiceChannelIfMatch(c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil { + if _, delErr := database.LeaveVoiceChannelIfMatch(ctx, c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil { slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr) } h.BroadcastToAll(buildVoiceLeave(vs.ChannelID, c.userID)) @@ -266,16 +266,18 @@ func (h *Hub) handleFreshConnect( // BUG-089: Capture stale join token so the goroutine only removes // the exact stale participant. The identity includes joinedAt, so // even if the user rejoins voice quickly, the new session has a - // different identity and won't be removed. Use a hub-stop-aware - // context to avoid goroutine leaks on shutdown. + // different identity and won't be removed. The removal must + // complete even if this connection drops mid-handshake, so detach + // from cancellation (values kept); shutdown is handled via h.stop. staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt - go func() { //nolint:contextcheck // goroutine intentionally detaches from request context; lifecycle managed via h.stop + lkCtx := context.WithoutCancel(ctx) + go func() { select { case <-h.stop: return default: } - if err := h.livekit.RemoveParticipant(staleChID, staleUserID, staleJoinToken); err != nil { + if err := h.livekit.RemoveParticipant(lkCtx, staleChID, staleUserID, staleJoinToken); err != nil { slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)", "err", err, "user_id", staleUserID, "channel_id", staleChID) } @@ -286,7 +288,7 @@ func (h *Hub) handleFreshConnect( // Look up role for permission-filtered ready payload. // Fail closed: if the role lookup fails, disconnect rather than serving // a permissive ready payload with nil role (BUG-094). - userRole, roleErr := database.GetRoleByID(c.user.RoleID) + userRole, roleErr := database.GetRoleByID(ctx, c.user.RoleID) if roleErr != nil || userRole == nil { slog.Error("ws: role lookup failed, disconnecting", "user_id", c.userID, "role_id", c.user.RoleID, "err", roleErr) _ = conn.Close(websocket.StatusInternalError, "role lookup failed") @@ -301,13 +303,13 @@ func (h *Hub) handleFreshConnect( // Fresh connection or replay fallback: full auth_ok + ready flow. slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName) - if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, "none")); err != nil { + if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil { slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err) h.unregisterNow(c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") return err } - if ready, readyErr := h.buildReady(database, c.userID, userRole); readyErr == nil { + if ready, readyErr := h.buildReady(ctx, database, c.userID, userRole); readyErr == nil { slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready)) if err := conn.Write(ctx, websocket.MessageText, ready); err != nil { slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err) @@ -324,7 +326,7 @@ func (h *Hub) handleFreshConnect( return readyErr } - if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil { + if updateErr := database.UpdateUserStatus(ctx, c.userID, "online"); updateErr != nil { slog.Warn("ws UpdateUserStatus", "err", updateErr) } @@ -404,6 +406,10 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { var lastReadErr error defer func() { + // The connection is gone, so ctx is (or is about to be) cancelled. + // Teardown DB writes must still complete — a dead connection must not + // cancel its own cleanup — so detach cancellation but keep values. + cleanupCtx := context.WithoutCancel(ctx) // Snapshot voice state BEFORE unregister to avoid TOCTOU with replacement connections. voiceChID := c.getVoiceChID() replaced := hub.unregisterNow(c) @@ -415,7 +421,7 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { // cleaning here would delete the replacement's DB row whenever // teardown snapshots voiceChID before the transfer zeroes it. if voiceChID != 0 && !replaced { - hub.handleVoiceLeave(ctx, c) + hub.handleVoiceLeave(cleanupCtx, c) } c.mu.Lock() received := c.msgsReceived @@ -445,7 +451,7 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { slog.Info("websocket disconnected", attrs...) if !replaced { - _ = hub.db.UpdateUserStatus(c.userID, "offline") + _ = hub.db.UpdateUserStatus(cleanupCtx, c.userID, "offline") hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) } } @@ -494,7 +500,7 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db } hash := auth.HashToken(p.Token) - sess, err := database.GetSessionByTokenHash(hash) + sess, err := database.GetSessionByTokenHash(ctx, hash) if err != nil || sess == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token")) return nil, "", 0, fmt.Errorf("auth: invalid session") @@ -505,7 +511,7 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db return nil, "", 0, fmt.Errorf("auth: session expired") } - user, err := database.GetUserByID(sess.UserID) + user, err := database.GetUserByID(ctx, sess.UserID) if err != nil || user == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found")) return nil, "", 0, fmt.Errorf("auth: user not found") @@ -526,13 +532,13 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db // - "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(user *db.User, roleName string, replaySource string) []byte { +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() + serverName, motd := h.getCachedSettings(ctx) return buildJSON(map[string]any{ "type": MsgTypeAuthOK, @@ -594,17 +600,17 @@ func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool { // buildReady constructs the ready server→client message. // Per 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(database *db.DB, userID int64, role *db.Role) ([]byte, error) { - channels, err := database.ListChannels() +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() + roles, err := database.ListRoles(ctx) if err != nil { return nil, fmt.Errorf("buildReady ListRoles: %w", err) } - members, err := database.ListMembers() + members, err := database.ListMembers(ctx) if err != nil { slog.Warn("buildReady ListMembers", "err", err) members = []db.MemberSummary{} @@ -618,7 +624,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, overrides := map[int64]db.ChannelOverride{} if role != nil && !permissions.HasAdmin(role.Permissions) { var oErr error - overrides, oErr = database.GetAllChannelPermissionsForRole(role.ID) + overrides, oErr = database.GetAllChannelPermissionsForRole(ctx, role.ID) if oErr != nil { return nil, fmt.Errorf("buildReady GetAllChannelPermissionsForRole: %w", oErr) } @@ -638,7 +644,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, } // Per-user unread counts. - unreadMap, err := database.GetChannelUnreadCounts(userID) + unreadMap, err := database.GetChannelUnreadCounts(ctx, userID) if err != nil { slog.Warn("buildReady GetChannelUnreadCounts", "err", err) unreadMap = map[int64]db.ChannelUnread{} @@ -673,7 +679,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, } // Collect voice states, filtered to only visible channels (BUG-095). - allVoiceStates, err := collectAllVoiceStates(database, channels) + 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) @@ -691,13 +697,13 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, } // Load open DM channels for this user. - dmChannels, err := database.GetUserDMChannels(userID) + dmChannels, err := database.GetUserDMChannels(ctx, userID) if err != nil { slog.Warn("buildReady GetUserDMChannels", "err", err) dmChannels = []db.DMChannelInfo{} } - serverName, motd := h.getCachedSettings() + serverName, motd := h.getCachedSettings(ctx) return buildJSON(map[string]any{ "type": MsgTypeReady, @@ -715,6 +721,6 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, // collectAllVoiceStates gathers voice states across all channels in a single // query, replacing the previous N+1 per-channel pattern. -func collectAllVoiceStates(database *db.DB, _ []db.Channel) ([]db.VoiceState, error) { - return database.GetAllVoiceStates() +func collectAllVoiceStates(ctx context.Context, database *db.DB, _ []db.Channel) ([]db.VoiceState, error) { + return database.GetAllVoiceStates(ctx) } diff --git a/Server/ws/serve_test.go b/Server/ws/serve_test.go index 9d64b52f..a2768f2f 100644 --- a/Server/ws/serve_test.go +++ b/Server/ws/serve_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "testing/fstest" @@ -68,11 +69,11 @@ func newServeHub(t *testing.T) (*ws.Hub, *db.DB) { // seedServeUser inserts an Owner-role user and returns the full *db.User. func seedServeUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) + _, err := database.CreateUser(context.Background(), username, "hash", 1) if err != nil { t.Fatalf("seedServeUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedServeUser GetUserByUsername: %v", err) } @@ -82,7 +83,7 @@ func seedServeUser(t *testing.T, database *db.DB, username string) *db.User { // ownerRole fetches the Owner role (ID=1) for permission-aware buildReady calls. func ownerRole(t *testing.T, database *db.DB) *db.Role { t.Helper() - role, err := database.GetRoleByID(1) + role, err := database.GetRoleByID(context.Background(), 1) if err != nil || role == nil { t.Fatalf("ownerRole: %v", err) } @@ -253,7 +254,7 @@ func TestBuildReady_IncludesSeededChannel(t *testing.T) { role := ownerRole(t, database) // Seed a text channel. - chID, err := database.CreateChannel("general", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -293,7 +294,7 @@ func TestBuildReady_TextChannelHasUnreadCount(t *testing.T) { user := seedServeUser(t, database, "ready-user4") role := ownerRole(t, database) - _, err := database.CreateChannel("unread-chan", "text", "", "", 0) + _, err := database.CreateChannel(context.Background(), "unread-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -365,7 +366,7 @@ func TestCollectAllVoiceStates_SkipsTextChannels(t *testing.T) { user := seedServeUser(t, database, "collect-text-user") // Only text channels — no voice states should be collected. - _, err := database.CreateChannel("text-only", "text", "", "", 0) + _, err := database.CreateChannel(context.Background(), "text-only", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -395,16 +396,16 @@ func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) { user2 := seedServeUser(t, database, "collect-voice-u2") requester := seedServeUser(t, database, "collect-voice-req") - chID, err := database.CreateChannel("voice-room", "voice", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } // Insert voice states for user1 and user2. - if err := database.JoinVoiceChannel(user1.ID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user1.ID, chID); err != nil { t.Fatalf("JoinVoiceChannel user1: %v", err) } - if err := database.JoinVoiceChannel(user2.ID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user2.ID, chID); err != nil { t.Fatalf("JoinVoiceChannel user2: %v", err) } @@ -439,31 +440,31 @@ func TestBuildReady_VoiceStatesFilteredByVisibility(t *testing.T) { hub, database := newServeHub(t) // Create a member user (role 4, permissions=1635, includes ReadMessages). - _, err := database.CreateUser("vs-member", "hash", 4) + _, err := database.CreateUser(context.Background(), "vs-member", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } - member, err := database.GetUserByUsername("vs-member") + member, err := database.GetUserByUsername(context.Background(), "vs-member") if err != nil || member == nil { t.Fatalf("GetUserByUsername: %v", err) } - memberRole, err := database.GetRoleByID(4) + memberRole, err := database.GetRoleByID(context.Background(), 4) if err != nil || memberRole == nil { t.Fatalf("GetRoleByID: %v", err) } // Create two voice channels: one visible, one denied. - visibleCh, err := database.CreateChannel("public-voice", "voice", "", "", 0) + visibleCh, err := database.CreateChannel(context.Background(), "public-voice", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel visible: %v", err) } - hiddenCh, err := database.CreateChannel("hidden-voice", "voice", "", "", 1) + hiddenCh, err := database.CreateChannel(context.Background(), "hidden-voice", "voice", "", "", 1) if err != nil { t.Fatalf("CreateChannel hidden: %v", err) } // Deny READ_MESSAGES on the hidden channel for Member role (role 4). - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, hiddenCh, ) @@ -474,10 +475,10 @@ func TestBuildReady_VoiceStatesFilteredByVisibility(t *testing.T) { // Create users in both voice channels. u1 := seedServeUser(t, database, "vs-visible-user") u2 := seedServeUser(t, database, "vs-hidden-user") - if err := database.JoinVoiceChannel(u1.ID, visibleCh); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1.ID, visibleCh); err != nil { t.Fatalf("JoinVoiceChannel visible: %v", err) } - if err := database.JoinVoiceChannel(u2.ID, hiddenCh); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2.ID, hiddenCh); err != nil { t.Fatalf("JoinVoiceChannel hidden: %v", err) } @@ -539,7 +540,7 @@ func TestGetCachedSettings_ReflectsDBValues(t *testing.T) { // Verify the default settings were loaded correctly from the seeded DB. var name string - if err := database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='server_name'").Scan(&name); err != nil { t.Fatalf("query server_name: %v", err) } if name != "OwnCord Server" { @@ -804,7 +805,7 @@ func TestGetCachedSettings_CacheMiss_RefreshesFromDB(t *testing.T) { hub, database := newServeHub(t) // Update the DB settings value so we can detect a refresh. - _, err := database.Exec("UPDATE settings SET value='Refreshed Server' WHERE key='server_name'") + _, err := database.ExecContext(context.Background(), "UPDATE settings SET value='Refreshed Server' WHERE key='server_name'") if err != nil { t.Fatalf("UPDATE settings: %v", err) } @@ -888,7 +889,7 @@ func TestBuildReady_NoVoiceChannels_EmptyVoiceStates(t *testing.T) { user := seedServeUser(t, database, "ready-novch") // Create only a text channel — voice_states list must still be non-nil. - _, err := database.CreateChannel("text-chan", "text", "", "", 0) + _, err := database.CreateChannel(context.Background(), "text-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go index 9fa0ea36..bc1ada87 100644 --- a/Server/ws/voice_controls.go +++ b/Server/ws/voice_controls.go @@ -9,7 +9,7 @@ import ( ) // handleVoiceMuteV2 processes a voice_mute command. -func handleVoiceMuteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceMuteV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) muteCmd := cmd.(VoiceMuteCmd) userID := info.UserID @@ -23,17 +23,17 @@ func handleVoiceMuteV2(_ context.Context, cmd Command, info ClientInfo, deps any return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } - if err := d.DB.UpdateVoiceMute(userID, muteCmd.Muted()); err != nil { + if err := d.DB.UpdateVoiceMute(ctx, userID, muteCmd.Muted()); err != nil { slog.Error("ws handleVoiceMuteV2 UpdateVoiceMute", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update mute state"}} } slog.Debug("voice mute changed", "user_id", userID, "muted", muteCmd.Muted(), "channel_id", info.VoiceChannelID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // handleVoiceDeafenV2 processes a voice_deafen command. -func handleVoiceDeafenV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceDeafenV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) deafenCmd := cmd.(VoiceDeafenCmd) userID := info.UserID @@ -47,17 +47,17 @@ func handleVoiceDeafenV2(_ context.Context, cmd Command, info ClientInfo, deps a return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } - if err := d.DB.UpdateVoiceDeafen(userID, deafenCmd.Deafened()); err != nil { + if err := d.DB.UpdateVoiceDeafen(ctx, userID, deafenCmd.Deafened()); err != nil { slog.Error("ws handleVoiceDeafenV2 UpdateVoiceDeafen", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update deafen state"}} } slog.Debug("voice deafen changed", "user_id", userID, "deafened", deafenCmd.Deafened(), "channel_id", info.VoiceChannelID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // handleVoiceCameraV2 processes a voice_camera command. -func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceCameraV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) cameraCmd := cmd.(VoiceCameraCmd) userID := info.UserID @@ -73,7 +73,7 @@ func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps a } // Permission check. - if r := requirePerm(d.DB, d.Permissions, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { + if r := requirePerm(ctx, d.DB, d.Permissions, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { return *r } @@ -81,9 +81,9 @@ func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps a // Enforce MaxVideo limit when enabling camera using an atomic check-and-update. if enabled { - ch, chErr := d.DB.GetChannel(voiceChID) + ch, chErr := d.DB.GetChannel(ctx, voiceChID) if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 { - ok, limitErr := d.DB.EnableCameraIfUnderLimit(userID, voiceChID, ch.VoiceMaxVideo) + ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo) if limitErr != nil { slog.Error("handleVoiceCameraV2 EnableCameraIfUnderLimit", "err", limitErr, "channel_id", voiceChID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check video limit"}} @@ -95,24 +95,24 @@ func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps a }} } } else { - if err := d.DB.UpdateVoiceCamera(userID, true); err != nil { + if err := d.DB.UpdateVoiceCamera(ctx, userID, true); err != nil { slog.Error("ws handleVoiceCameraV2 UpdateVoiceCamera", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update camera state"}} } } } else { - if err := d.DB.UpdateVoiceCamera(userID, false); err != nil { + if err := d.DB.UpdateVoiceCamera(ctx, userID, false); err != nil { slog.Error("ws handleVoiceCameraV2 UpdateVoiceCamera", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update camera state"}} } } slog.Debug("voice camera changed", "user_id", userID, "enabled", enabled, "channel_id", voiceChID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // handleVoiceScreenshareV2 processes a voice_screenshare command. -func handleVoiceScreenshareV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceScreenshareV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) ssCmd := cmd.(VoiceScreenshareCmd) userID := info.UserID @@ -128,23 +128,23 @@ func handleVoiceScreenshareV2(_ context.Context, cmd Command, info ClientInfo, d } // Permission check. - if r := requirePerm(d.DB, d.Permissions, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { + if r := requirePerm(ctx, d.DB, d.Permissions, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { return *r } - if err := d.DB.UpdateVoiceScreenshare(userID, ssCmd.Enabled()); err != nil { + if err := d.DB.UpdateVoiceScreenshare(ctx, userID, ssCmd.Enabled()); err != nil { slog.Error("ws handleVoiceScreenshareV2 UpdateVoiceScreenshare", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update screenshare state"}} } slog.Debug("voice screenshare changed", "user_id", userID, "enabled", ssCmd.Enabled(), "channel_id", voiceChID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // voiceStateBroadcast reads the current voice state from DB and returns a // BroadcastAll event. Shared by all voice control V2 handlers. -func voiceStateBroadcast(d VoiceDeps, userID int64) Result { - state, err := d.DB.GetVoiceState(userID) +func voiceStateBroadcast(ctx context.Context, d VoiceDeps, userID int64) Result { + state, err := d.DB.GetVoiceState(ctx, userID) if err != nil { slog.Error("ws voiceStateBroadcast GetVoiceState", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to broadcast voice state update"}} diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index d644d4ed..7ccc30a2 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "testing/fstest" @@ -72,11 +73,11 @@ func newVoiceHub(t *testing.T) (*ws.Hub, *db.DB) { // seedVoiceOwner inserts an Owner-role user for permission-passing tests. func seedVoiceOwner(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner + _, err := database.CreateUser(context.Background(), username, "hash", 1) // roleID=1 → Owner if err != nil { t.Fatalf("seedVoiceOwner CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedVoiceOwner GetUserByUsername: %v", err) } @@ -86,7 +87,7 @@ func seedVoiceOwner(t *testing.T, database *db.DB, username string) *db.User { // seedVoiceChan creates a voice-type channel. func seedVoiceChan(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("seedVoiceChan: %v", err) } @@ -187,7 +188,7 @@ func TestVoice_Join_SetsStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -339,7 +340,7 @@ func TestVoice_Leave_ClearsStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceLeaveMsg()) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState after leave: %v", err) } @@ -403,7 +404,7 @@ func TestVoice_Mute_UpdatesStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceMuteMsg(true)) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -467,7 +468,7 @@ func TestVoice_Deafen_UpdatesStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceDeafenMsg(true)) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -552,7 +553,7 @@ func TestVoice_Camera_UpdatesState(t *testing.T) { time.Sleep(50 * time.Millisecond) // Verify DB state. - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -684,7 +685,7 @@ func TestVoice_Screenshare_UpdatesState(t *testing.T) { time.Sleep(50 * time.Millisecond) // Verify DB state. - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -854,11 +855,11 @@ func TestVoice_HandleMessage_VoiceScreenshare_Dispatched(t *testing.T) { // seedVoiceChanMaxUsers creates a voice channel with a custom voice_max_users limit. func seedVoiceChanMaxUsers(t *testing.T, database *db.DB, name string, maxUsers int) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("seedVoiceChanMaxUsers CreateChannel: %v", err) } - if err := database.SetChannelVoiceMaxUsers(id, maxUsers); err != nil { + if err := database.SetChannelVoiceMaxUsers(context.Background(), id, maxUsers); err != nil { t.Fatalf("seedVoiceChanMaxUsers SetChannelVoiceMaxUsers: %v", err) } return id @@ -881,7 +882,7 @@ func TestVoice_Join_ChannelFull(t *testing.T) { time.Sleep(50 * time.Millisecond) // Verify first user is in DB. - state1, err := database.GetVoiceState(user1.ID) + state1, err := database.GetVoiceState(context.Background(), user1.ID) if err != nil || state1 == nil { t.Fatalf("user1 voice state missing after join: %v", err) } @@ -919,7 +920,7 @@ func TestVoice_Join_ChannelFull(t *testing.T) { } // Second user should NOT be in DB voice state. - state2, err := database.GetVoiceState(user2.ID) + state2, err := database.GetVoiceState(context.Background(), user2.ID) if err != nil { t.Fatalf("GetVoiceState user2: %v", err) } @@ -999,7 +1000,7 @@ func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { drainChan(send) // Verify in channel A via DB. - stateA, _ := database.GetVoiceState(userA.ID) + stateA, _ := database.GetVoiceState(context.Background(), userA.ID) if stateA == nil || stateA.ChannelID != chanA { t.Fatal("user should be in channel A") } @@ -1009,7 +1010,7 @@ func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { time.Sleep(50 * time.Millisecond) // DB state should show channel B. - stateB, _ := database.GetVoiceState(userA.ID) + stateB, _ := database.GetVoiceState(context.Background(), userA.ID) if stateB == nil || stateB.ChannelID != chanB { t.Error("user should be in channel B after switching") } @@ -1070,7 +1071,7 @@ func TestVoice_Leave_OnDisconnect(t *testing.T) { time.Sleep(30 * time.Millisecond) // DB state should be cleared. - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState after disconnect: %v", err) } diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index 473cc1c2..edbac54d 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -58,13 +58,13 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe return } - if !h.requireChannelPerm(c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { + if !h.requireChannelPerm(ctx, c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { return } // Validate the target channel exists before any state changes (leaving // the current voice channel, persisting join, etc.). - ch, err := h.db.GetChannel(channelID) + ch, err := h.db.GetChannel(ctx, channelID) if err != nil || ch == nil { c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) return @@ -111,7 +111,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // background), the old row persists and JoinVoiceChannelIfCapacity's // COUNT(*) may produce an incorrect result. Fail the switch so the // user can retry cleanly. - vs, err := h.db.GetVoiceState(c.userID) + vs, err := h.db.GetVoiceState(ctx, c.userID) if err != nil { slog.Warn("handleVoiceJoin: could not verify voice state cleared", "user_id", c.userID, "err", err) @@ -131,7 +131,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // Check channel capacity and persist to DB atomically. maxUsers := ch.VoiceMaxUsers if maxUsers > 0 { - if err := h.db.JoinVoiceChannelIfCapacity(c.userID, channelID, maxUsers); err != nil { + if err := h.db.JoinVoiceChannelIfCapacity(ctx, c.userID, channelID, maxUsers); err != nil { if errors.Is(err, db.ErrChannelFull) { c.sendMsg(buildErrorMsg(ErrCodeChannelFull, "voice channel is full")) return @@ -142,7 +142,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe } } else { // No capacity limit — use standard join. - if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil { + if err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); err != nil { slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID) c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel")) return @@ -151,10 +151,10 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // Load the persisted row immediately so later cleanup can target this exact // join instance even if the user rejoins the same channel. - state, err := h.db.GetVoiceState(c.userID) + state, err := h.db.GetVoiceState(ctx, c.userID) if err != nil || state == nil { slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID) - h.rollbackVoiceJoin(c, channelID, false) + h.rollbackVoiceJoin(ctx, c, channelID, false) c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel")) return } @@ -167,14 +167,14 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe if h.livekit != nil { // Derive publish permissions from role — prevents SFU-level bypass // when client connects directly via direct_url (BUG-128). - canPublish := h.hasChannelPerm(c, channelID, permissions.SpeakVoice) + canPublish := h.hasChannelPerm(ctx, c, channelID, permissions.SpeakVoice) canSubscribe := true - canVideo := h.hasChannelPerm(c, channelID, permissions.UseVideo) - canScreenShare := h.hasChannelPerm(c, channelID, permissions.ShareScreen) + canVideo := h.hasChannelPerm(ctx, c, channelID, permissions.UseVideo) + canScreenShare := h.hasChannelPerm(ctx, c, channelID, permissions.ShareScreen) token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, state.JoinedAt, canPublish, canSubscribe, canVideo, canScreenShare) if tokenErr != nil { slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID) - h.rollbackVoiceJoin(c, channelID, false) + h.rollbackVoiceJoin(ctx, c, channelID, false) c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice token")) return } @@ -202,7 +202,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe h.BroadcastToAll(buildVoiceState(*state)) // Send existing channel voice states to the joiner. - existing, err := h.db.GetChannelVoiceStates(channelID) + existing, err := h.db.GetChannelVoiceStates(ctx, channelID) if err != nil { slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) return @@ -251,7 +251,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // handleVoiceTokenRefreshV2 is the V2 (pure) handler for voice_token_refresh. // It generates a fresh LiveKit token for a client already in a voice channel. -func handleVoiceTokenRefreshV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) userID := info.UserID channelID := info.VoiceChannelID @@ -269,15 +269,15 @@ func handleVoiceTokenRefreshV2(_ context.Context, cmd Command, info ClientInfo, return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice not configured"}} } - canPublish := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.SpeakVoice) + canPublish := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.SpeakVoice) canSubscribe := true - canVideo := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.UseVideo) - canScreenShare := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.ShareScreen) + canVideo := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.UseVideo) + canScreenShare := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.ShareScreen) joinToken := info.VoiceJoinToken var result Result if joinToken == "" { - state, stateErr := d.DB.GetVoiceState(userID) + state, stateErr := d.DB.GetVoiceState(ctx, userID) if stateErr != nil || state == nil { slog.Error("ws handleVoiceTokenRefreshV2 GetVoiceState", "err", stateErr, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to refresh voice token"}} @@ -305,9 +305,11 @@ func handleVoiceTokenRefreshV2(_ context.Context, cmd Command, info ClientInfo, // rollbackVoiceJoin undoes a partially-completed voice join: clears the // client's voice channel ID, removes the DB voice state row, and broadcasts // voice_leave so other clients don't see a ghost participant. -func (h *Hub) rollbackVoiceJoin(c *Client, channelID int64, broadcast bool) { +func (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, broadcast bool) { c.clearVoiceChID() - if err := h.db.LeaveVoiceChannel(c.userID); err != nil { + // The compensating delete must run even when the join failed BECAUSE the + // connection died — that cancellation is the most common rollback trigger. + if err := h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), c.userID); err != nil { slog.Error("ws rollbackVoiceJoin LeaveVoiceChannel", "err", err, "user_id", c.userID, "channel_id", channelID) } diff --git a/Server/ws/voice_leave.go b/Server/ws/voice_leave.go index fa7a9160..481b0a76 100644 --- a/Server/ws/voice_leave.go +++ b/Server/ws/voice_leave.go @@ -46,7 +46,7 @@ func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) { // Remove from LiveKit (best-effort). if h.livekit != nil { - if err := h.livekit.RemoveParticipant(oldChID, c.userID, oldJoinToken); err != nil { //nolint:contextcheck // TODO: propagate context through this call path + if err := h.livekit.RemoveParticipant(ctx, oldChID, c.userID, oldJoinToken); err != nil { slog.Warn("handleVoiceLeave RemoveParticipant failed (may already be gone)", "err", err, "user_id", c.userID, "channel_id", oldChID) } @@ -73,22 +73,23 @@ func leaveVoiceChannelWithRetry(ctx context.Context, h *Hub, userID int64, chann } // Synchronous first attempt — channel-conditional delete. - if _, err := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); err != nil { + if _, err := h.db.LeaveVoiceChannelIfMatch(ctx, userID, channelID, joinToken); err != nil { slog.Warn("LeaveVoiceChannelIfMatch failed, retrying in background", "err", err, "user_id", userID, "channel_id", channelID, "attempt", 1, "max_retries", 3) - // Background retries — cancellable via ctx or hub stop. + // Background retries — cancellable via hub stop only. The caller's ctx + // is detached: on the webhook path it dies the moment the handler + // returns, and on the voice_leave path it dies with the connection — + // either would kill retry 2 before it ever ran, leaving a ghost + // voice_states row holding a capacity slot until the 60s sweep. go func() { + retryCtx := context.WithoutCancel(ctx) const maxRetries = 3 delay := 200 * time.Millisecond for attempt := 2; attempt <= maxRetries; attempt++ { select { - case <-ctx.Done(): - slog.Info("LeaveVoiceChannelIfMatch retry cancelled (context)", - "user_id", userID, "channel_id", channelID, "attempt", attempt) - return case <-h.stop: slog.Info("LeaveVoiceChannelIfMatch retry cancelled (hub stop)", "user_id", userID, "channel_id", channelID, "attempt", attempt) @@ -97,7 +98,7 @@ func leaveVoiceChannelWithRetry(ctx context.Context, h *Hub, userID int64, chann } delay *= 2 - if _, retryErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); retryErr != nil { + if _, retryErr := h.db.LeaveVoiceChannelIfMatch(retryCtx, userID, channelID, joinToken); retryErr != nil { slog.Warn("LeaveVoiceChannelIfMatch retry failed", "err", retryErr, "user_id", userID, "channel_id", channelID, "attempt", attempt, "max_retries", maxRetries) diff --git a/Server/ws/voice_perm_stale_test.go b/Server/ws/voice_perm_stale_test.go index 3114b517..3edbea93 100644 --- a/Server/ws/voice_perm_stale_test.go +++ b/Server/ws/voice_perm_stale_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "testing" "github.com/owncord/server/permissions" @@ -26,14 +27,14 @@ func TestHasChannelPerm_UsesLiveRoleNotConnectSnapshot(t *testing.T) { // Admin reassigns the user to a role WITHOUT CONNECT_VOICE. The live WS // connection is not refreshed, so c.user.RoleID is now stale. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES (100, 'novoice', NULL, ?, 5, 0)`, permissions.ReadMessages, ); err != nil { t.Fatalf("seed novoice role: %v", err) } - if _, err := database.Exec(`UPDATE users SET role_id = 100 WHERE id = ?`, user.ID); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 100 WHERE id = ?`, user.ID); err != nil { t.Fatalf("reassign user role: %v", err) } diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index 4a768abc..456c6761 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -289,7 +289,7 @@ func TestServeWS_ValidAuth_FullHandshake(t *testing.T) { defer hub.Stop() // Seed user and session. - userID, err := database.CreateUser("ws-handshake-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-handshake-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -298,7 +298,7 @@ func TestServeWS_ValidAuth_FullHandshake(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -372,13 +372,13 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("abruptclose", "hash", 4) + userID, err := database.CreateUser(context.Background(), "abruptclose", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "abrupt-close-token" tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -415,7 +415,7 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) { deadline := time.Now().Add(2 * time.Second) cleanedUp := false for time.Now().Before(deadline) { - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID: %v", getErr) } @@ -427,7 +427,7 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) { } if !cleanedUp { - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID final: %v", getErr) } @@ -445,7 +445,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("ws-reconnect-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-reconnect-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -454,7 +454,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -500,7 +500,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID: %v", getErr) } @@ -510,7 +510,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { time.Sleep(20 * time.Millisecond) } - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID final: %v", getErr) } @@ -527,7 +527,7 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("ws-voice-reconnect", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-voice-reconnect", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -536,12 +536,12 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Create a voice channel. - chID, err := database.CreateChannel("voice-reconnect", "voice", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "voice-reconnect", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -603,10 +603,10 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { // Simulate voice join AFTER conn1 is established — both in-memory and DB. // (Setting it before conn1 would cause serve.go's fresh-connect cleanup // to delete the DB row during conn1's handshake.) - if err := database.JoinVoiceChannel(userID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - vsBeforeReconnect, err := database.GetVoiceState(userID) + vsBeforeReconnect, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(before reconnect): %v", err) } @@ -641,7 +641,7 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { } // Assert: DB row still intact - vs, vsErr := database.GetVoiceState(userID) + vs, vsErr := database.GetVoiceState(context.Background(), userID) if vsErr != nil { t.Fatalf("GetVoiceState: %v", vsErr) } @@ -685,7 +685,7 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { defer hub.Stop() // Create two users: the voice user who F5-reloads, and an observer. - userID, err := database.CreateUser("ws-voice-f5", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-voice-f5", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -694,11 +694,11 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } - observerID, err := database.CreateUser("ws-observer", "hash", 1) + observerID, err := database.CreateUser(context.Background(), "ws-observer", "hash", 1) if err != nil { t.Fatalf("CreateUser (observer): %v", err) } @@ -707,12 +707,12 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { t.Fatalf("GenerateToken (observer): %v", err) } obsTokenHash := auth.HashToken(obsToken) - if _, err := database.CreateSession(observerID, obsTokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), observerID, obsTokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession (observer): %v", err) } // Create a voice channel for the user to be "in". - chID, err := database.CreateChannel("voice-test", "voice", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "voice-test", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -810,10 +810,10 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { } // Simulate voice join — both in-memory and DB - if err := database.JoinVoiceChannel(userID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - vsBeforeReload, err := database.GetVoiceState(userID) + vsBeforeReload, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(before reload): %v", err) } @@ -846,7 +846,7 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { } // Assert 2: DB voice row is gone - vs, vsErr := database.GetVoiceState(userID) + vs, vsErr := database.GetVoiceState(context.Background(), userID) if vsErr != nil { t.Fatalf("GetVoiceState: %v", vsErr) } @@ -908,7 +908,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { defer hub.Stop() // Seed user and session. - userID, err := database.CreateUser("ws-pump-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-pump-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -917,7 +917,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -1004,7 +1004,7 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { defer hub.Stop() // Seed two users with sessions. - userIDA, err := database.CreateUser("roundtrip-a", "hash", 1) + userIDA, err := database.CreateUser(context.Background(), "roundtrip-a", "hash", 1) if err != nil { t.Fatalf("CreateUser A: %v", err) } @@ -1012,11 +1012,11 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { if err != nil { t.Fatalf("GenerateToken A: %v", err) } - if _, err := database.CreateSession(userIDA, auth.HashToken(tokenA), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userIDA, auth.HashToken(tokenA), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession A: %v", err) } - userIDB, err := database.CreateUser("roundtrip-b", "hash", 1) + userIDB, err := database.CreateUser(context.Background(), "roundtrip-b", "hash", 1) if err != nil { t.Fatalf("CreateUser B: %v", err) } @@ -1024,12 +1024,12 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { if err != nil { t.Fatalf("GenerateToken B: %v", err) } - if _, err := database.CreateSession(userIDB, auth.HashToken(tokenB), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userIDB, auth.HashToken(tokenB), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession B: %v", err) } // Create a text channel for the chat. - chID, err := database.CreateChannel("integration-chat", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "integration-chat", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -1149,7 +1149,7 @@ func TestIntegration_SequenceNumbers(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("seq-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "seq-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -1157,7 +1157,7 @@ func TestIntegration_SequenceNumbers(t *testing.T) { if err != nil { t.Fatalf("GenerateToken: %v", err) } - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -1242,7 +1242,7 @@ func TestServeWS_BannedUser_ReceivesError(t *testing.T) { defer hub.Stop() // Seed user, then ban them. - userID, err := database.CreateUser("ws-banned-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-banned-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -1251,11 +1251,11 @@ func TestServeWS_BannedUser_ReceivesError(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Ban the user permanently. - if err := database.BanUser(userID, "test ban", nil); err != nil { + if err := database.BanUser(context.Background(), userID, "test ban", nil); err != nil { t.Fatalf("BanUser: %v", err) }