fix: security hardening, DM auth, LiveKit stability, and voice call timer

Security fixes (from multi-reviewer code review):
- Add DM participant auth checks to channel_focus, typing, and REST
  message endpoints — prevents unauthorized access to DM channels
- Fix TOCTOU race in GetOrCreateDMChannel using IMMEDIATE transaction
- Validate YAML credentials before LiveKit config interpolation
- Add CSS variable injection prevention in custom theme loader
- Validate localStorage JSON before unsafe type casts

LiveKit stability:
- Track remote mic audio elements for cleanup on abnormal disconnect
- Remove duplicate token refresh timer scheduling
- Add .catch() to all floating applyMicMuteState promises
- Clear reconnectAc after async post-connect work completes
- Fix double cmd.Wait() race in LiveKit process Stop()
- Reorder voice_join guards: validate channel before livekit==nil check
- Add startup warning for external LiveKit webhook CIDR mismatch

DM system fixes:
- Emit dm_channel_close WebSocket event from REST close handler
- Re-open DM for caller when channel already exists
- Fix unread count incrementing for own messages and active DMs
- Reset channelBeforeDm after Back navigation (stale state bug)

New feature:
- Voice call duration timer in VoiceWidget (MM:SS / HH:MM:SS elapsed)
- Accent color restored on app startup (was only applied in settings)

Test infrastructure:
- Add DM tables to all test schemas (hubTestSchema)
- Inject test LiveKit client in voice handler tests (fixes 28 failures)
This commit is contained in:
jevb
2026-03-27 16:14:54 +01:00
parent b1d37f7d07
commit 76cb9b9630
30 changed files with 359 additions and 72 deletions
+21 -3
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -9,14 +10,21 @@ import (
"github.com/owncord/server/db"
)
// DMBroadcaster is the interface needed to send WebSocket events from REST
// handlers. Satisfied by *ws.Hub.
type DMBroadcaster interface {
SendToUser(userID int64, msg []byte) bool
}
// MountDMRoutes registers DM-related routes onto r.
// All routes require authentication.
func MountDMRoutes(r chi.Router, database *db.DB) {
// hub is used to send real-time WebSocket events on DM close.
func MountDMRoutes(r chi.Router, database *db.DB, broadcaster DMBroadcaster) {
r.Route("/api/v1/dms", func(r chi.Router) {
r.Use(AuthMiddleware(database))
r.Post("/", handleCreateDM(database))
r.Get("/", handleListDMs(database))
r.Delete("/{channelId}", handleCloseDM(database))
r.Delete("/{channelId}", handleCloseDM(database, broadcaster))
})
}
@@ -157,7 +165,7 @@ func handleListDMs(database *db.DB) http.HandlerFunc {
}
// handleCloseDM removes a DM channel from the authenticated user's open list.
func handleCloseDM(database *db.DB) http.HandlerFunc {
func handleCloseDM(database *db.DB, broadcaster DMBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
@@ -202,6 +210,16 @@ func handleCloseDM(database *db.DB) http.HandlerFunc {
return
}
// Notify the closing user's WebSocket connections so the sidebar updates
// immediately without waiting for a reconnect.
if broadcaster != nil {
closeMsg := []byte(fmt.Sprintf(`{"type":"dm_channel_close","payload":{"channel_id":%d}}`, channelID))
if ok := broadcaster.SendToUser(user.ID, closeMsg); !ok {
slog.Debug("handleCloseDM: user not connected, WS notify skipped",
"user_id", user.ID, "channel_id", channelID)
}
}
w.WriteHeader(http.StatusNoContent)
}
}