diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index 9deb23f7..4f598cb0 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -2,6 +2,7 @@ package admin import ( "fmt" + "io" "log/slog" "net/http" "os" @@ -141,14 +142,10 @@ func handleRestoreBackup(database *db.DB) http.Handler { slog.Warn("pre-restore backup failed", "err", err) } - // Copy the backup file over the live database. - src, err := os.ReadFile(backupPath) - if err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to read backup file") - return - } - if err := os.WriteFile(dbPath, src, 0o644); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to write database file") + // Stream the backup file over the live database to avoid loading + // the entire DB into memory (could be hundreds of MiB). + if err := copyFile(backupPath, dbPath); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to restore database file") return } @@ -162,3 +159,23 @@ 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) + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer in.Close() //nolint:errcheck + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create destination: %w", err) + } + defer out.Close() //nolint:errcheck + + if _, err := io.Copy(out, in); err != nil { + return fmt.Errorf("copy: %w", err) + } + return out.Close() +} diff --git a/Server/admin/handlers_settings.go b/Server/admin/handlers_settings.go index 4d0d4691..ea8ea0ab 100644 --- a/Server/admin/handlers_settings.go +++ b/Server/admin/handlers_settings.go @@ -41,11 +41,30 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc { } actor := actorFromContext(r) + + // Apply all settings atomically so a mid-loop failure doesn't leave + // partial updates. + tx, err := database.Begin() + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to start transaction") + return + } for key, value := range updates { - if err := database.SetSetting(key, value); err != nil { + if _, txErr := tx.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + key, value, + ); txErr != nil { + _ = tx.Rollback() writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key) return } + } + if err := tx.Commit(); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to commit settings") + return + } + for key := range updates { slog.Info("setting changed", "actor_id", actor, "key", key) _ = database.LogAudit(actor, "setting_change", "setting", 0, fmt.Sprintf("%s updated", key)) diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 6aee7e8c..4214660f 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -109,7 +109,10 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha return } - // Exit current process. + // Exit current process. os.Exit skips deferred cleanup intentionally — + // the process must die to release the file lock on its own binary + // before the new process can replace it on Windows. SQLite WAL mode + // protects DB integrity on unclean shutdown. slog.Info("update: new process spawned, exiting current process") os.Exit(0) }() diff --git a/Server/api/voice_handler.go b/Server/api/voice_handler.go index 263d4c33..5edad18c 100644 --- a/Server/api/voice_handler.go +++ b/Server/api/voice_handler.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "log/slog" + "net" "net/http" "time" @@ -118,21 +119,18 @@ func generateTURNCredentials(userID int64, secret string) turnCredentials { } } -// serverHost extracts the host for ICE server URLs from the request or falls -// back to "localhost". +// serverHost extracts the host (without port) for ICE server URLs from the +// request, or falls back to "localhost". Uses net.SplitHostPort for correct +// handling of IPv6 addresses with ports (e.g. "[::1]:8443"). func serverHost(r *http.Request) string { - if host := r.Host; host != "" { - // Strip port if present. - for i := len(host) - 1; i >= 0; i-- { - if host[i] == ':' { - return host[:i] - } - if host[i] == ']' { - // IPv6 with no port. - return host - } - } + host := r.Host + if host == "" { + return "localhost" + } + h, _, err := net.SplitHostPort(host) + if err != nil { + // No port present — return as-is. return host } - return "localhost" + return h } diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index 2c59d6a1..4a4e7bd6 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -373,6 +373,9 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6 ri.Me = me != 0 result[msgID] = append(result[msgID], ri) } + if rows.Err() != nil { + return nil, fmt.Errorf("getReactionsBatch rows: %w", rows.Err()) + } return result, nil } diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go index 39836a75..31b5a00f 100644 --- a/Server/db/voice_queries.go +++ b/Server/db/voice_queries.go @@ -89,6 +89,39 @@ func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { return states, nil } +// 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.sqlDB.Query( + `SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare + FROM voice_states vs + JOIN users u ON u.id = vs.user_id + ORDER BY vs.channel_id, vs.joined_at ASC`, + ) + if err != nil { + return nil, fmt.Errorf("GetAllVoiceStates: %w", err) + } + defer rows.Close() //nolint:errcheck + + var states []VoiceState + for rows.Next() { + vs, scanErr := scanVoiceStateRow(rows) + if scanErr != nil { + return nil, fmt.Errorf("GetAllVoiceStates scan: %w", scanErr) + } + states = append(states, vs) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAllVoiceStates rows: %w", rows.Err()) + } + if states == nil { + states = []VoiceState{} + } + return states, nil +} + // 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 { diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 3eefb8e6..a724408a 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -290,21 +290,8 @@ func (h *Hub) buildReady(database *db.DB, userID int64) ([]byte, error) { }), nil } -// collectAllVoiceStates gathers voice states for all voice-type channels. -func collectAllVoiceStates(database *db.DB, channels []db.Channel) ([]db.VoiceState, error) { - var all []db.VoiceState - for _, ch := range channels { - if ch.Type != "voice" { - continue - } - states, err := database.GetChannelVoiceStates(ch.ID) - if err != nil { - return nil, err - } - all = append(all, states...) - } - if all == nil { - all = []db.VoiceState{} - } - return all, nil +// 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() } diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go index 83a4cad0..e32d5bcb 100644 --- a/Server/ws/voice_handlers.go +++ b/Server/ws/voice_handlers.go @@ -645,6 +645,8 @@ func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) { return } + // channelID=0: soundboard is a server-wide permission with no per-channel + // override. The client does not send a channel_id in the payload. if !h.requireChannelPerm(c, 0, permissions.UseSoundboard, "USE_SOUNDBOARD") { return }