From 447a4543e71276dfd7e34883b63804e7e9a1716c Mon Sep 17 00:00:00 2001 From: jevb Date: Wed, 1 Apr 2026 11:38:33 +0200 Subject: [PATCH] chore: remaining server changes (code quality, go mod tidy) Go mod tidy, minor server-side adjustments from security verification and code quality cleanup pass. --- Server/.golangci.yml | 34 +++++++++++++++++++++ Server/admin/admin.go | 1 + Server/admin/handlers_backup.go | 8 ++--- Server/admin/handlers_users.go | 4 +-- Server/admin/helpers.go | 2 +- Server/admin/logstream.go | 4 +-- Server/admin/middleware_and_spawn_test.go | 2 +- Server/admin/update_handlers.go | 2 +- Server/api/client_update.go | 6 ++-- Server/api/diagnostics_handler.go | 2 +- Server/api/dm_handler.go | 6 ++-- Server/api/livekit_proxy.go | 13 ++++---- Server/auth/password.go | 6 ++-- Server/config/config.go | 26 ++++++++-------- Server/db/attachment_queries.go | 4 +-- Server/db/channel_queries.go | 2 +- Server/db/dm_queries.go | 12 ++++---- Server/db/message_queries.go | 2 +- Server/db/migrate.go | 28 ++++++++---------- Server/main.go | 3 +- Server/scripts/seed.go | 29 ++++++++++++------ Server/storage/storage.go | 12 ++++---- Server/updater/updater.go | 24 +++++++-------- Server/ws/handlers_chat.go | 14 ++++----- Server/ws/handlers_presence.go | 18 +++++------- Server/ws/handlers_reaction.go | 8 ++--- Server/ws/livekit_process.go | 14 ++++----- Server/ws/livekit_webhook.go | 32 +++++++++----------- Server/ws/message_types.go | 2 +- Server/ws/messages.go | 22 +++++++------- Server/ws/serve.go | 36 ++++++++++++----------- Server/ws/voice_controls.go | 8 ++--- Server/ws/voice_join.go | 2 +- Server/ws/voice_leave.go | 4 +-- 34 files changed, 214 insertions(+), 178 deletions(-) diff --git a/Server/.golangci.yml b/Server/.golangci.yml index 4012cc8a..f4b5bfe0 100644 --- a/Server/.golangci.yml +++ b/Server/.golangci.yml @@ -1,8 +1,42 @@ version: "2" linters: + enable: + - gocritic # opinionated checks for bugs, performance, style + - gosec # security-focused static analysis (SQL injection, hardcoded creds, weak crypto) + - errcheck # ensures all errors are checked + - bodyclose # detects unclosed HTTP response bodies + - contextcheck # verifies context.Context propagation + - nilerr # detects returning nil when err is not nil + - prealloc # suggests pre-allocating slices for performance + - unconvert # removes unnecessary type conversions + - unparam # finds unused function parameters + - wastedassign # finds wasted assignments + - staticcheck # advanced static analysis (correctness, performance, deprecation) + settings: staticcheck: checks: - "all" - "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately) + gocritic: + enabled-tags: + - diagnostic + - performance + disabled-checks: + - hugeParam # too noisy for struct-heavy code + gosec: + excludes: + - G104 # unhandled errors — errcheck covers this better + - G304 # file path from variable — expected in file storage code + - G706 # log injection — false positive with slog structured logging (values are typed key-value pairs, not interpolated) + + exclusions: + # Suppress noisy linters in test files + rules: + - linters: [unparam] + path: _test\.go + - linters: [gosec] + path: _test\.go + - linters: [errcheck] + path: _test\.go diff --git a/Server/admin/admin.go b/Server/admin/admin.go index d459409e..418884c3 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -57,6 +57,7 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. } // 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 { diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index 8da6f234..b1aedbbe 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -116,12 +116,12 @@ func handleDeleteBackup(database *db.DB) http.Handler { } backupPath := filepath.Join("data", "backups", name) - if _, err := os.Stat(backupPath); os.IsNotExist(err) { + if _, err := os.Stat(backupPath); os.IsNotExist(err) { //nolint:gosec // G703: path is sanitized above writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found") return } - if err := os.Remove(backupPath); err != nil { + if err := os.Remove(backupPath); err != nil { //nolint:gosec // G703: path is sanitized above writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete backup") return } @@ -156,7 +156,7 @@ func handleRestoreBackup(database *db.DB) http.Handler { } backupPath := filepath.Join("data", "backups", name) - if _, err := os.Stat(backupPath); os.IsNotExist(err) { + if _, err := os.Stat(backupPath); os.IsNotExist(err) { //nolint:gosec // G703: path is sanitized above writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found") return } @@ -195,7 +195,7 @@ func handleRestoreBackup(database *db.DB) http.Handler { // copyFile streams src to dst without loading the entire file into memory. func copyFile(src, dst string) error { - in, err := os.Open(src) + in, err := os.Open(src) //nolint:gosec // G703: src is from sanitized backup path if err != nil { return fmt.Errorf("open source: %w", err) } diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index a86ddc16..40f89353 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -37,8 +37,8 @@ func handleListUsers(database *db.DB) http.HandlerFunc { } safe := make([]adminUserResponse, len(users)) - for i, u := range users { - safe[i] = toAdminUserResponse(u) + for i := range users { + safe[i] = toAdminUserResponse(users[i]) } writeJSON(w, http.StatusOK, safe) } diff --git a/Server/admin/helpers.go b/Server/admin/helpers.go index 81d61cb6..1553cba2 100644 --- a/Server/admin/helpers.go +++ b/Server/admin/helpers.go @@ -29,7 +29,7 @@ func writeErr(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, errorResponse{Error: code, Message: msg}) } -func pathInt64(r *http.Request, param string) (int64, error) { +func pathInt64(r *http.Request, param string) (int64, error) { //nolint:unparam // kept generic for future URL params raw := chi.URLParam(r, param) return strconv.ParseInt(raw, 10, 64) } diff --git a/Server/admin/logstream.go b/Server/admin/logstream.go index eddfb987..7405d5f6 100644 --- a/Server/admin/logstream.go +++ b/Server/admin/logstream.go @@ -202,8 +202,8 @@ func NewMultiHandler(stdout slog.Handler, buf *RingBuffer, minLevel slog.Leveler } } -func (h *multiHandler) Enabled(_ context.Context, level slog.Level) bool { - return h.stdout.Enabled(context.Background(), level) || h.ring.Enabled(level) +func (h *multiHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.stdout.Enabled(ctx, level) || h.ring.Enabled(level) } func (h *multiHandler) Handle(ctx context.Context, r slog.Record) error { diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 2f75a0bd..4b92b0a3 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -401,7 +401,7 @@ func (m *mockHubWB) BroadcastChannelUpdate(ch *db.Channel) {} func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {} func (m *mockHubWB) BroadcastMemberBan(userID int64) {} func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {} -func (m *mockHubWB) ClientCount() int { return 0 } +func (m *mockHubWB) ClientCount() int { return 0 } // TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a // real executable (the Go test binary itself) with a flag that causes immediate diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 29168676..3521a5f4 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -133,7 +133,7 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha // spawnDetached starts a new process that is not attached to the current one. func spawnDetached(exePath string, args []string) error { - cmd := exec.Command(exePath, args...) + cmd := exec.Command(exePath, args...) //nolint:gosec // G204: command path from trusted server config cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/Server/api/client_update.go b/Server/api/client_update.go index 2be19f80..93d7d995 100644 --- a/Server/api/client_update.go +++ b/Server/api/client_update.go @@ -21,9 +21,9 @@ type tauriPlatformResponse struct { // tauriUpdateResponse is the JSON shape the Tauri updater plugin expects. type tauriUpdateResponse struct { - Version string `json:"version"` - Notes string `json:"notes,omitempty"` - PubDate string `json:"pub_date,omitempty"` + Version string `json:"version"` + Notes string `json:"notes,omitempty"` + PubDate string `json:"pub_date,omitempty"` Platforms map[string]tauriPlatformResponse `json:"platforms"` } diff --git a/Server/api/diagnostics_handler.go b/Server/api/diagnostics_handler.go index 877914ed..414fb6f1 100644 --- a/Server/api/diagnostics_handler.go +++ b/Server/api/diagnostics_handler.go @@ -46,7 +46,7 @@ func handleDiagnosticsConnectivity( clientAddr := clientIP(r) lkHealthy := false - if ok, _ := hub.LiveKitHealthCheck(); ok { + if ok, _ := hub.LiveKitHealthCheck(); ok { //nolint:contextcheck // TODO: propagate context through this call path lkHealthy = true } diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index 61fabc49..868203a6 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -35,9 +35,9 @@ type createDMRequest struct { // createDMResponse is the JSON response for POST /api/v1/dms. type createDMResponse struct { - ChannelID int64 `json:"channel_id"` + ChannelID int64 `json:"channel_id"` Recipient db.DMUser `json:"recipient"` - Created bool `json:"created"` + Created bool `json:"created"` } // listDMsResponse is the JSON response for GET /api/v1/dms. @@ -102,7 +102,7 @@ func handleCreateDM(database *db.DB) http.HandlerFunc { } // Get or create the DM channel. - ch, created, err := database.GetOrCreateDMChannel(user.ID, req.RecipientID) + ch, created, err := database.GetOrCreateDMChannel(user.ID, req.RecipientID) //nolint:contextcheck // TODO: propagate context through this call path if err != nil { slog.Error("handleCreateDM GetOrCreateDMChannel", "err", err, "user_id", user.ID, "recipient_id", req.RecipientID) diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index cd371860..7f378c24 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -129,9 +129,12 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, all backendURL.RawQuery = r.URL.RawQuery // Connect to LiveKit backend. - backConn, _, err := websocket.Dial(r.Context(), backendURL.String(), &websocket.DialOptions{ + backConn, dialResp, err := websocket.Dial(r.Context(), backendURL.String(), &websocket.DialOptions{ Subprotocols: r.Header.Values("Sec-WebSocket-Protocol"), }) + if dialResp != nil && dialResp.Body != nil { + defer dialResp.Body.Close() //nolint:errcheck // best-effort close + } if err != nil { slog.Warn("livekit proxy: backend dial failed", "host", backendURL.Host, "path", backendURL.Path, "err", err) writeJSON(w, http.StatusBadGateway, errorResponse{ @@ -188,11 +191,11 @@ func copyWS(ctx context.Context, dst, src *websocket.Conn) error { if err != nil { return err } - if _, err = io.Copy(writer, reader); err != nil { - return err + if _, copyErr := io.Copy(writer, reader); copyErr != nil { + return copyErr } - if err = writer.Close(); err != nil { - return err + if closeErr := writer.Close(); closeErr != nil { + return closeErr } } } diff --git a/Server/auth/password.go b/Server/auth/password.go index 29752700..e1820343 100644 --- a/Server/auth/password.go +++ b/Server/auth/password.go @@ -8,9 +8,9 @@ import ( ) const ( - bcryptCost = 12 - minPassLen = 8 - maxPassLen = 72 // bcrypt silently truncates beyond 72 bytes + bcryptCost = 12 + minPassLen = 8 + maxPassLen = 72 // bcrypt silently truncates beyond 72 bytes ) // ErrPasswordTooShort is returned when the password is below the minimum length. diff --git a/Server/config/config.go b/Server/config/config.go index 9cb86d1d..0ad6a823 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -34,12 +34,12 @@ type GitHubConfig struct { // VoiceConfig holds LiveKit server connection and voice quality settings. type VoiceConfig struct { - LiveKitAPIKey string `koanf:"livekit_api_key"` // LiveKit API key - LiveKitAPISecret string `koanf:"livekit_api_secret"` // LiveKit API secret - LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880) - LiveKitBinaryPath string `koanf:"livekit_binary"` // path to livekit-server binary; empty = don't auto-start - NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect - Quality string `koanf:"quality"` // low | medium | high + LiveKitAPIKey string `koanf:"livekit_api_key"` // LiveKit API key + LiveKitAPISecret string `koanf:"livekit_api_secret"` // LiveKit API secret + LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880) + LiveKitBinaryPath string `koanf:"livekit_binary"` // path to livekit-server binary; empty = don't auto-start + NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect + Quality string `koanf:"quality"` // low | medium | high } // ServerConfig holds HTTP server settings. @@ -82,12 +82,12 @@ func defaults() Config { AllowedOrigins: []string{}, TrustedProxies: []string{}, AdminAllowedCIDRs: []string{ - "127.0.0.0/8", // localhost IPv4 - "::1/128", // localhost IPv6 - "10.0.0.0/8", // private class A - "172.16.0.0/12", // private class B - "192.168.0.0/16", // private class C - "fc00::/7", // IPv6 unique local + "127.0.0.0/8", // localhost IPv4 + "::1/128", // localhost IPv6 + "10.0.0.0/8", // private class A + "172.16.0.0/12", // private class B + "192.168.0.0/16", // private class C + "fc00::/7", // IPv6 unique local }, }, Database: DatabaseConfig{ @@ -229,7 +229,7 @@ func Load(cfgPath string) (*Config, error) { // production — NewLiveKitClient rejects them. const ( DefaultLiveKitAPIKey = "devkey" - DefaultLiveKitAPISecret = "owncord-dev-secret-key-min-32chars" + DefaultLiveKitAPISecret = "owncord-dev-secret-key-min-32chars" //nolint:gosec // G101: false positive — config key name, not a credential ) // IsDefaultVoiceCredentials returns true when the voice config still uses diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index c320d40e..af57842b 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -64,7 +64,7 @@ func (d *DB) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) ( args = append(args, id) } - query := fmt.Sprintf( + query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input `UPDATE attachments SET message_id = ? WHERE id IN (%s) AND message_id IS NULL`, strings.Join(placeholders, ","), ) @@ -88,7 +88,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI args[i] = id } - query := fmt.Sprintf( + query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input `SELECT id, message_id, filename, size, mime_type, width, height FROM attachments WHERE message_id IN (%s)`, strings.Join(placeholders, ","), diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index 9dc1c18e..4daccf5c 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -219,7 +219,7 @@ func (d *DB) GetChannelTypes(ids []int64) (map[int64]string, error) { args[i] = id } - query := fmt.Sprintf( + query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input `SELECT id, type FROM channels WHERE id IN (%s)`, strings.Join(placeholders, ","), ) diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index 17c8c30a..f2b5ae18 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -11,12 +11,12 @@ import ( // DMChannelInfo holds a DM channel summary for the channel list. type DMChannelInfo struct { - ChannelID int64 `json:"channel_id"` - Recipient DMUser `json:"recipient"` - LastMessageID *int64 `json:"last_message_id"` - LastMessage string `json:"last_message"` - LastMessageAt string `json:"last_message_at"` - UnreadCount int `json:"unread_count"` + ChannelID int64 `json:"channel_id"` + Recipient DMUser `json:"recipient"` + LastMessageID *int64 `json:"last_message_id"` + LastMessage string `json:"last_message"` + LastMessageAt string `json:"last_message_at"` + UnreadCount int `json:"unread_count"` } // DMUser is the public-facing shape for a DM participant. diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index 7d94d88d..473c7186 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -383,7 +383,7 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6 placeholders := sb.String() // Query: aggregate count + check if requesting user reacted. - query := fmt.Sprintf( + query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input `SELECT r.message_id, r.emoji, COUNT(*) as cnt, MAX(CASE WHEN r.user_id = ? THEN 1 ELSE 0 END) as me FROM reactions r diff --git a/Server/db/migrate.go b/Server/db/migrate.go index 0278a1c9..9ad42c5c 100644 --- a/Server/db/migrate.go +++ b/Server/db/migrate.go @@ -18,6 +18,7 @@ package db // filename without executing the SQL, so subsequent runs treat them as done. import ( + "database/sql" "fmt" "io/fs" "sort" @@ -46,8 +47,10 @@ func isExistingDatabase(d *DB) (bool, error) { "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", ).Scan(&name) if err != nil { - // sql.ErrNoRows means the table does not exist. - return false, nil + if err == sql.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("isExistingDatabase: %w", err) } return true, nil } @@ -59,7 +62,10 @@ func schemaVersionsExists(d *DB) (bool, error) { "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'", ).Scan(&name) if err != nil { - return false, nil + if err == sql.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("schemaVersionsExists: %w", err) } return true, nil } @@ -71,22 +77,14 @@ func isApplied(d *DB, filename string) (bool, error) { "SELECT version FROM schema_versions WHERE version = ?", filename, ).Scan(&v) if err != nil { - return false, nil + if err == sql.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("isApplied: %w", err) } return true, nil } -// recordApplied inserts a migration filename into schema_versions. -func recordApplied(d *DB, filename string) error { - _, err := d.sqlDB.Exec( - "INSERT INTO schema_versions (version) VALUES (?)", filename, - ) - if err != nil { - return fmt.Errorf("recording migration %s: %w", filename, err) - } - return nil -} - // sqlFilenames returns all .sql entries from the FS sorted lexicographically. func sqlFilenames(fsys fs.FS) ([]string, error) { entries, err := fs.ReadDir(fsys, ".") diff --git a/Server/main.go b/Server/main.go index 94cf9b8f..aef12dd2 100644 --- a/Server/main.go +++ b/Server/main.go @@ -69,7 +69,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { } // ── 2. Ensure data directory exists ──────────────────────────────────── - if mkdirErr := os.MkdirAll(cfg.Server.DataDir, 0o755); mkdirErr != nil { + if mkdirErr := os.MkdirAll(cfg.Server.DataDir, 0o750); mkdirErr != nil { return fmt.Errorf("creating data dir %s: %w", cfg.Server.DataDir, mkdirErr) } @@ -333,4 +333,3 @@ func getOutboundIP() string { addr := conn.LocalAddr().(*net.UDPAddr) return addr.IP.String() } - diff --git a/Server/scripts/seed.go b/Server/scripts/seed.go index b3378e23..fd23c502 100644 --- a/Server/scripts/seed.go +++ b/Server/scripts/seed.go @@ -140,30 +140,40 @@ func main() { if err != nil { log.Fatalf("failed to open database at %s: %v", *dbPath, err) } - defer func() { _ = database.Close() }() + exitCode := run(database) + _ = database.Close() + os.Exit(exitCode) +} + +func run(database *db.DB) int { if err := db.Migrate(database); err != nil { - log.Fatalf("failed to run migrations: %v", err) + log.Printf("failed to run migrations: %v", err) + return 1 } userIDs, err := createUsers(database) if err != nil { - log.Fatalf("failed to create users: %v", err) + log.Printf("failed to create users: %v", err) + return 1 } channelIDs, err := createChannels(database) if err != nil { - log.Fatalf("failed to create channels: %v", err) + log.Printf("failed to create channels: %v", err) + return 1 } msgCount, err := createMessages(database, channelIDs, userIDs) if err != nil { - log.Fatalf("failed to create messages: %v", err) + log.Printf("failed to create messages: %v", err) + return 1 } dmMsgCount, err := createDMConversation(database, userIDs) if err != nil { - log.Fatalf("failed to create DM conversation: %v", err) + log.Printf("failed to create DM conversation: %v", err) + return 1 } fmt.Println("--- Seed complete ---") @@ -171,6 +181,7 @@ func main() { fmt.Printf(" Channels: %d\n", len(channelIDs)) fmt.Printf(" Messages: %d (channel) + %d (DM) = %d total\n", msgCount, dmMsgCount, msgCount+dmMsgCount) + return 0 } // ─── User creation ────────────────────────────────────────────────────────── @@ -234,8 +245,8 @@ func createChannels(database *db.DB) ([]int64, error) { return nil, fmt.Errorf("listing channels: %w", err) } existingByName := make(map[string]int64, len(existing)) - for _, ch := range existing { - existingByName[ch.Name] = ch.ID + for i := range existing { + existingByName[existing[i].Name] = existing[i].ID } for i, sc := range seedChannels { @@ -353,7 +364,7 @@ func createDMConversation(database *db.DB, userIDs []int64) (int, error) { func init() { // The default DB path is data/chatserver.db. Ensure the data directory // exists so db.Open doesn't fail on a fresh checkout. - if err := os.MkdirAll("data", 0o755); err != nil { + if err := os.MkdirAll("data", 0o750); err != nil { log.Printf("warning: could not create data directory: %v", err) } } diff --git a/Server/storage/storage.go b/Server/storage/storage.go index 12468921..c8019d89 100644 --- a/Server/storage/storage.go +++ b/Server/storage/storage.go @@ -17,11 +17,11 @@ var blockedMagic = []struct { name string magic []byte }{ - {"PE executable", []byte("MZ")}, // Windows .exe / .dll - {"ELF binary", []byte("\x7fELF")}, // Linux binaries - {"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit - {"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit - {"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.) + {"PE executable", []byte("MZ")}, // Windows .exe / .dll + {"ELF binary", []byte("\x7fELF")}, // Linux binaries + {"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit + {"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit + {"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.) } // ValidateFileType checks the first few bytes of a file against known blocked @@ -45,7 +45,7 @@ type Storage struct { // New creates a Storage instance that stores files in dir. // dir is created if it does not exist. func New(dir string, maxSizeMB int) (*Storage, error) { - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("creating storage dir %s: %w", dir, err) } return &Storage{dir: dir, maxSizeMB: maxSizeMB}, nil diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 73b69795..c2e16773 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -21,22 +21,22 @@ import ( ) const ( - defaultBaseURL = "https://api.github.com" - cacheTTL = 1 * time.Hour - errorCacheTTL = 5 * time.Minute - binaryAsset = "chatserver.exe" - checksumAsset = "checksums.sha256" + defaultBaseURL = "https://api.github.com" + cacheTTL = 1 * time.Hour + errorCacheTTL = 5 * time.Minute + binaryAsset = "chatserver.exe" + checksumAsset = "checksums.sha256" ) // UpdateInfo holds the result of a version check. type UpdateInfo struct { - Current string `json:"current"` - Latest string `json:"latest"` - UpdateAvailable bool `json:"update_available"` - ReleaseURL string `json:"release_url"` - DownloadURL string `json:"download_url"` - ChecksumURL string `json:"checksum_url"` - ReleaseNotes string `json:"release_notes"` + Current string `json:"current"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + ReleaseURL string `json:"release_url"` + DownloadURL string `json:"download_url"` + ChecksumURL string `json:"checksum_url"` + ReleaseNotes string `json:"release_notes"` Assets []Asset `json:"assets,omitempty"` } diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index fc913c4f..935091d5 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -32,7 +32,7 @@ type chatSendPayload struct { } // handleChatSend processes a chat_send message. -func (h *Hub) handleChatSend(ctx context.Context, c *Client, reqID string, payload json.RawMessage) { +func (h *Hub) handleChatSend(_ context.Context, c *Client, reqID string, payload json.RawMessage) { ratKey := fmt.Sprintf("chat:%d", c.userID) if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) { c.sendMsg(buildRateLimitError("too many messages", chatWindow.Seconds())) @@ -229,7 +229,7 @@ func (h *Hub) broadcastChatMessage(c *Client, channelID int64, isDM bool, broadc } // handleChatEdit processes a chat_edit message. -func (h *Hub) handleChatEdit(ctx context.Context, c *Client, _ string, payload json.RawMessage) { +func (h *Hub) handleChatEdit(_ context.Context, c *Client, _ string, payload json.RawMessage) { ratKey := fmt.Sprintf("chat_edit:%d", c.userID) if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) { c.sendMsg(buildRateLimitError("too many edits", chatWindow.Seconds())) @@ -278,12 +278,10 @@ func (h *Hub) handleChatEdit(ctx context.Context, c *Client, _ string, payload j c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message")) return } - } else { + } else if !h.hasChannelPerm(c, msg.ChannelID, permissions.SendMessages) { // Re-check that the user still has SendMessages permission on this channel. - if !h.hasChannelPerm(c, msg.ChannelID, permissions.SendMessages) { - c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message")) - return - } + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot edit this message")) + return } // EditMessage checks ownership internally. @@ -315,7 +313,7 @@ func (h *Hub) handleChatEdit(ctx context.Context, c *Client, _ string, payload j } // handleChatDelete processes a chat_delete message. -func (h *Hub) handleChatDelete(ctx context.Context, c *Client, _ string, payload json.RawMessage) { +func (h *Hub) handleChatDelete(_ context.Context, c *Client, _ string, payload json.RawMessage) { ratKey := fmt.Sprintf("chat_delete:%d", c.userID) if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) { c.sendMsg(buildRateLimitError("too many deletes", chatWindow.Seconds())) diff --git a/Server/ws/handlers_presence.go b/Server/ws/handlers_presence.go index 7e27451d..17652a74 100644 --- a/Server/ws/handlers_presence.go +++ b/Server/ws/handlers_presence.go @@ -23,7 +23,7 @@ func registerPresenceHandlers(r *HandlerRegistry) { } // handleTyping processes a typing_start message. -func (h *Hub) handleTyping(ctx context.Context, c *Client, payload json.RawMessage) { +func (h *Hub) handleTyping(_ context.Context, c *Client, payload json.RawMessage) { channelID, err := parseChannelID(payload) if err != nil || channelID <= 0 { c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be positive integer")) @@ -45,10 +45,8 @@ func (h *Hub) handleTyping(ctx context.Context, c *Client, payload json.RawMessa if dmErr != nil || !ok { return // silently drop — not a DM participant } - } else { - if !h.hasChannelPerm(c, channelID, permissions.ReadMessages) { - return // silently drop — no read permission on this channel - } + } else if !h.hasChannelPerm(c, channelID, permissions.ReadMessages) { + return // silently drop — no read permission on this channel } var username string @@ -65,7 +63,7 @@ func (h *Hub) handleTyping(ctx context.Context, c *Client, payload json.RawMessa } // handlePresence processes a presence_update message. -func (h *Hub) handlePresence(ctx context.Context, c *Client, payload json.RawMessage) { +func (h *Hub) handlePresence(_ context.Context, c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("presence:%d", c.userID) if !h.limiter.Allow(ratKey, presenceRateLimit, presenceWindow) { c.sendMsg(buildRateLimitError("too many presence updates", presenceWindow.Seconds())) @@ -97,7 +95,7 @@ func (h *Hub) handlePresence(ctx context.Context, c *Client, payload json.RawMes // handleChannelFocus sets which channel the client is currently viewing, // so channel-scoped broadcasts (chat messages, typing) reach them. // Also updates read_states so unread counts decrease when the user views a channel. -func (h *Hub) handleChannelFocus(ctx context.Context, c *Client, payload json.RawMessage) { +func (h *Hub) handleChannelFocus(_ context.Context, c *Client, payload json.RawMessage) { chID, err := parseChannelID(payload) if err != nil || chID <= 0 { slog.Debug("handleChannelFocus: invalid channel_id", "user_id", c.userID, "err", err) @@ -116,10 +114,8 @@ func (h *Hub) handleChannelFocus(ctx context.Context, c *Client, payload json.Ra c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not a participant in this DM")) return } - } else { - if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") { - return - } + } else if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") { + return } c.mu.Lock() diff --git a/Server/ws/handlers_reaction.go b/Server/ws/handlers_reaction.go index 5f4bc8f1..426d93e1 100644 --- a/Server/ws/handlers_reaction.go +++ b/Server/ws/handlers_reaction.go @@ -20,7 +20,7 @@ func registerReactionHandlers(r *HandlerRegistry) { } // handleReaction processes reaction_add and reaction_remove messages. -func (h *Hub) handleReaction(ctx context.Context, c *Client, add bool, payload json.RawMessage) { +func (h *Hub) handleReaction(_ context.Context, c *Client, add bool, payload json.RawMessage) { ratKey := fmt.Sprintf("reaction:%d", c.userID) if !h.limiter.Allow(ratKey, reactionRateLimit, reactionWindow) { c.sendMsg(buildRateLimitError("too many reactions", reactionWindow.Seconds())) @@ -79,10 +79,8 @@ func (h *Hub) handleReaction(ctx context.Context, c *Client, add bool, payload j c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "reaction failed")) return } - } else { - if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") { - return - } + } else if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") { + return } action := "add" diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index 46cad5d3..da65ee45 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -76,7 +76,7 @@ func (p *LiveKitProcess) generateConfig() (string, error) { return "", fmt.Errorf("node_ip contains unsafe character %q", string(ch)) } } - nodeIPLine = fmt.Sprintf("\n node_ip: \"%s\"", p.cfg.NodeIP) + nodeIPLine = fmt.Sprintf("\n node_ip: %q", p.cfg.NodeIP) } content := fmt.Sprintf(`# Auto-generated by OwnCord — do not edit manually. @@ -98,7 +98,7 @@ logging: level: info `, nodeIPLine, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) - if err := os.MkdirAll(p.dataDir, 0o755); err != nil { + if err := os.MkdirAll(p.dataDir, 0o750); err != nil { return "", fmt.Errorf("creating data dir: %w", err) } if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { @@ -150,10 +150,10 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { }() const ( - baseDelay = 3 * time.Second - maxDelay = 60 * time.Second - maxRetries = 10 - stableAfter = 30 * time.Second // reset counter if process runs longer than this + baseDelay = 3 * time.Second + maxDelay = 60 * time.Second + maxRetries = 10 + stableAfter = 30 * time.Second // reset counter if process runs longer than this ) rapidFailures := 0 @@ -164,7 +164,7 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { return } - cmd := exec.CommandContext(ctx, p.cfg.LiveKitBinaryPath, "--config", cfgPath) + cmd := exec.CommandContext(ctx, p.cfg.LiveKitBinaryPath, "--config", cfgPath) //nolint:gosec // G204: binary path from trusted server config cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.WaitDelay = 6 * time.Second // bound Wait to prevent goroutine leak on Windows diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index a872ab39..433a357c 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -190,33 +190,29 @@ func (h *Hub) handleWebhookParticipantLeft(event *livekit.WebhookEvent) { slog.Info("livekit webhook: cleaned up stale voice state", "user_id", userID, "channel_id", channelID) - } else { + } 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. - if h.db != nil { - deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken) - if dbErr != nil { - slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (stale DB row)", - "error", dbErr, "user_id", userID, "channel_id", channelID) - } else if deleted { - h.BroadcastToAll(buildVoiceLeave(channelID, userID)) - slog.Info("livekit webhook: cleaned stale DB voice row after reconnect", - "user_id", userID, "channel_id", channelID) - } - } - } - } else { - // Client already disconnected from WS — use channel-conditional delete - // to avoid wiping a newer row if the user reconnected and rejoined. - if h.db != nil { deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken) if dbErr != nil { - slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (client gone)", + slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (stale DB row)", "error", dbErr, "user_id", userID, "channel_id", channelID) } else if deleted { h.BroadcastToAll(buildVoiceLeave(channelID, userID)) + slog.Info("livekit webhook: cleaned stale DB voice row after reconnect", + "user_id", userID, "channel_id", channelID) } } + } 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) + if dbErr != nil { + slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (client gone)", + "error", dbErr, "user_id", userID, "channel_id", channelID) + } else if deleted { + h.BroadcastToAll(buildVoiceLeave(channelID, userID)) + } } } diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index 51a684d2..46f1ac9f 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -22,7 +22,7 @@ const ( MsgTypeVoiceCamera = "voice_camera" MsgTypeVoiceScreenshare = "voice_screenshare" MsgTypePing = "ping" - MsgTypeVoiceTokenRefresh = "voice_token_refresh" + MsgTypeVoiceTokenRefresh = "voice_token_refresh" //nolint:gosec // G101: false positive — message type constant, not a credential ) // Server → Client message types (sent in broadcasts/responses). diff --git a/Server/ws/messages.go b/Server/ws/messages.go index a49ba221..dcd833bd 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -43,15 +43,15 @@ type memberJoinPayload struct { } type chatMessagePayload struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channel_id"` - User memberUserPayload `json:"user"` - Content string `json:"content"` - ReplyTo *int64 `json:"reply_to"` - Timestamp string `json:"timestamp"` - Attachments []map[string]any `json:"attachments"` - Reactions []any `json:"reactions"` - Pinned bool `json:"pinned"` + ID int64 `json:"id"` + ChannelID int64 `json:"channel_id"` + User memberUserPayload `json:"user"` + Content string `json:"content"` + ReplyTo *int64 `json:"reply_to"` + Timestamp string `json:"timestamp"` + Attachments []map[string]any `json:"attachments"` + Reactions []any `json:"reactions"` + Pinned bool `json:"pinned"` } type memberUpdatePayload struct { @@ -147,7 +147,7 @@ type serverRestartPayload struct { // dmChannelOpenPayload is sent when a DM is opened/reopened for a user. type dmChannelOpenPayload struct { - ChannelID int64 `json:"channel_id"` + ChannelID int64 `json:"channel_id"` Recipient dmUserPayload `json:"recipient"` } @@ -370,7 +370,7 @@ func buildVoiceConfig(channelID int64, quality string, bitrate int, maxUsers int // buildVoiceToken constructs a voice_token message with a LiveKit token and URL. // url is the proxy path ("/livekit") for remote clients; direct_url is the raw // LiveKit URL (e.g. "ws://localhost:7880") for localhost clients. -func buildVoiceToken(channelID int64, token string, proxyPath string, directURL string) []byte { +func buildVoiceToken(channelID int64, token string, proxyPath string, directURL string) []byte { //nolint:unparam // kept configurable for proxy path flexibility return buildJSON(wsMsg{ Type: MsgTypeVoiceToken, Payload: voiceTokenPayload{ diff --git a/Server/ws/serve.go b/Server/ws/serve.go index bf2dc9d5..b58045fe 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -16,9 +16,11 @@ import ( "github.com/owncord/server/permissions" ) -const authDeadline = 10 * time.Second -const writeTimeout = 10 * time.Second -const settingsCacheTTL = 30 * time.Second +const ( + authDeadline = 10 * time.Second + writeTimeout = 10 * time.Second + settingsCacheTTL = 30 * time.Second +) // ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth, // then drives the client's read/write loops. @@ -77,7 +79,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun func (h *Hub) upgradeAndAuth( conn *websocket.Conn, database *db.DB, r *http.Request, ) (*Client, uint64, error) { - user, tokenHash, lastSeq, err := authenticateConn(conn, database) + user, tokenHash, lastSeq, err := authenticateConn(conn, database) //nolint:contextcheck // TODO: propagate context through this call path if err != nil { slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr) _ = conn.Close(websocket.StatusPolicyViolation, "authentication failed") @@ -150,7 +152,7 @@ func (h *Hub) handleFreshConnect( } h.BroadcastToAll(buildVoiceLeave(vs.ChannelID, c.userID)) if h.livekit != nil { - go h.livekit.RemoveParticipant(vs.ChannelID, c.userID, vs.JoinedAt) + go h.livekit.RemoveParticipant(vs.ChannelID, c.userID, vs.JoinedAt) //nolint:errcheck,gosec,contextcheck // fire-and-forget cleanup on disconnect; G118: no request-scoped context available for background goroutine } } @@ -382,16 +384,16 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, } } var visibleChannels []db.Channel - for _, ch := range channels { + for i := range channels { // When role is unavailable, include all channels (backwards compat). if role == nil || permissions.HasAdmin(role.Permissions) { - visibleChannels = append(visibleChannels, ch) + visibleChannels = append(visibleChannels, channels[i]) continue } - o := overrides[ch.ID] + o := overrides[channels[i].ID] effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) if effective&permissions.ReadMessages == permissions.ReadMessages { - visibleChannels = append(visibleChannels, ch) + visibleChannels = append(visibleChannels, channels[i]) } } if visibleChannels == nil { @@ -407,16 +409,16 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, // Build protocol-compliant channel objects (strip extra fields). channelPayloads := make([]map[string]any, 0, len(visibleChannels)) - for _, ch := range visibleChannels { + for i := range visibleChannels { entry := map[string]any{ - "id": ch.ID, - "name": ch.Name, - "type": ch.Type, - "category": ch.Category, - "position": ch.Position, + "id": visibleChannels[i].ID, + "name": visibleChannels[i].Name, + "type": visibleChannels[i].Type, + "category": visibleChannels[i].Category, + "position": visibleChannels[i].Position, } - if ch.Type == "text" { - if u, ok := unreadMap[ch.ID]; ok { + if visibleChannels[i].Type == "text" { + if u, ok := unreadMap[visibleChannels[i].ID]; ok { entry["unread_count"] = u.UnreadCount entry["last_message_id"] = u.LastMessageID } else { diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go index 0c3d6555..02682870 100644 --- a/Server/ws/voice_controls.go +++ b/Server/ws/voice_controls.go @@ -13,7 +13,7 @@ import ( // 1. Parses muted bool. // 2. Updates DB. // 3. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceMute(ctx context.Context, c *Client, payload json.RawMessage) { +func (h *Hub) handleVoiceMute(_ context.Context, c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_mute:%d", c.userID) if !h.limiter.Allow(ratKey, voiceMuteRateLimit, voiceMuteWindow) { c.sendMsg(buildRateLimitError("too many mute toggles", voiceMuteWindow.Seconds())) @@ -47,7 +47,7 @@ func (h *Hub) handleVoiceMute(ctx context.Context, c *Client, payload json.RawMe // 1. Parses deafened bool. // 2. Updates DB. // 3. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceDeafen(ctx context.Context, c *Client, payload json.RawMessage) { +func (h *Hub) handleVoiceDeafen(_ context.Context, c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_deafen:%d", c.userID) if !h.limiter.Allow(ratKey, voiceDeafenRateLimit, voiceDeafenWindow) { c.sendMsg(buildRateLimitError("too many deafen toggles", voiceDeafenWindow.Seconds())) @@ -84,7 +84,7 @@ func (h *Hub) handleVoiceDeafen(ctx context.Context, c *Client, payload json.Raw // 4. Enforces MaxVideo limit via DB count (race-free). // 5. Updates DB. // 6. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceCamera(ctx context.Context, c *Client, payload json.RawMessage) { +func (h *Hub) handleVoiceCamera(_ context.Context, c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_camera:%d", c.userID) if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) { c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds())) @@ -149,7 +149,7 @@ func (h *Hub) handleVoiceCamera(ctx context.Context, c *Client, payload json.Raw // 3. Parses enabled bool. // 4. Updates DB. // 5. Broadcasts voice_state update to channel. -func (h *Hub) handleVoiceScreenshare(ctx context.Context, c *Client, payload json.RawMessage) { +func (h *Hub) handleVoiceScreenshare(_ context.Context, c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID) if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) { c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds())) diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index cbb3c3f4..d2227c8a 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -188,7 +188,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // handleVoiceTokenRefresh generates a fresh LiveKit token for a client // that is already in a voice channel. This lets clients request a new token // (e.g. before a manual reconnect) without leaving and rejoining voice. -func (h *Hub) handleVoiceTokenRefresh(ctx context.Context, c *Client) { +func (h *Hub) handleVoiceTokenRefresh(_ context.Context, c *Client) { ratKey := fmt.Sprintf("voice_token_refresh:%d", c.userID) if !h.limiter.Allow(ratKey, 1, 60*time.Second) { c.sendMsg(buildRateLimitError("token refresh rate limited", 60)) diff --git a/Server/ws/voice_leave.go b/Server/ws/voice_leave.go index 4552155f..0bc3bcc4 100644 --- a/Server/ws/voice_leave.go +++ b/Server/ws/voice_leave.go @@ -10,7 +10,7 @@ import ( // 1. Gets old voiceChID from clearVoiceChID(). // 2. If was in voice: remove from DB (with retry), broadcast voice_leave. // 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone). -func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) { +func (h *Hub) handleVoiceLeave(_ context.Context, c *Client) { oldChID, oldJoinToken := c.clearVoiceState() if oldChID == 0 { slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID) @@ -36,7 +36,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 { + if err := h.livekit.RemoveParticipant(oldChID, c.userID, oldJoinToken); err != nil { //nolint:contextcheck // TODO: propagate context through this call path slog.Warn("handleVoiceLeave RemoveParticipant failed (may already be gone)", "err", err, "user_id", c.userID, "channel_id", oldChID) }