chore: remaining server changes (code quality, go mod tidy)

Go mod tidy, minor server-side adjustments from security verification
and code quality cleanup pass.
This commit is contained in:
jevb
2026-04-01 11:38:33 +02:00
parent e626291dec
commit 447a4543e7
34 changed files with 214 additions and 178 deletions
+34
View File
@@ -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
+1
View File
@@ -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 {
+4 -4
View File
@@ -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)
}
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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"`
}
+1 -1
View File
@@ -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
}
+3 -3
View File
@@ -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)
+8 -5
View File
@@ -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
}
}
}
+3 -3
View File
@@ -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.
+13 -13
View File
@@ -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
+2 -2
View File
@@ -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, ","),
+1 -1
View File
@@ -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, ","),
)
+6 -6
View File
@@ -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.
+1 -1
View File
@@ -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
+13 -15
View File
@@ -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, ".")
+1 -2
View File
@@ -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()
}
+20 -9
View File
@@ -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)
}
}
+6 -6
View File
@@ -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
+12 -12
View File
@@ -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"`
}
+6 -8
View File
@@ -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()))
+7 -11
View File
@@ -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()
+3 -5
View File
@@ -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"
+7 -7
View File
@@ -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
+14 -18
View File
@@ -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))
}
}
}
+1 -1
View File
@@ -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).
+11 -11
View File
@@ -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{
+19 -17
View File
@@ -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 {
+4 -4
View File
@@ -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()))
+1 -1
View File
@@ -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))
+2 -2
View File
@@ -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)
}