diff --git a/Client/tauri-client/src/components/VoiceWidget.ts b/Client/tauri-client/src/components/VoiceWidget.ts index a2856794..4bb2dd5e 100644 --- a/Client/tauri-client/src/components/VoiceWidget.ts +++ b/Client/tauri-client/src/components/VoiceWidget.ts @@ -43,6 +43,17 @@ const QUALITY_BARS: Record = { bad: 1, }; +/** Format milliseconds elapsed into HH:MM:SS or MM:SS. */ +function formatElapsed(ms: number): string { + const totalSec = Math.floor(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const mm = String(m).padStart(2, "0"); + const ss = String(s).padStart(2, "0"); + return h > 0 ? `${String(h).padStart(2, "0")}:${mm}:${ss}` : `${mm}:${ss}`; +} + export function createVoiceWidget(options: VoiceWidgetOptions): MountableComponent { const ac = new AbortController(); let root: HTMLDivElement | null = null; @@ -59,6 +70,10 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone let statsPoller: ConnectionStatsPoller | null = null; let statsUnlisten: (() => void) | null = null; + // Elapsed timer + let timerEl: HTMLSpanElement | null = null; + let timerInterval: ReturnType | null = null; + // Stats pane field elements (set during mount) let outRateEl: HTMLSpanElement | null = null; let outPacketsEl: HTMLSpanElement | null = null; @@ -118,6 +133,26 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone statsPoller = null; } + function updateElapsedTimer(): void { + const joinedAt = voiceStore.getState().joinedAt; + if (timerEl === null || joinedAt === null) return; + setText(timerEl, formatElapsed(Date.now() - joinedAt)); + } + + function startElapsedTimer(): void { + if (timerInterval !== null) return; + updateElapsedTimer(); + timerInterval = setInterval(updateElapsedTimer, 1000); + } + + function stopElapsedTimer(): void { + if (timerInterval !== null) { + clearInterval(timerInterval); + timerInterval = null; + } + if (timerEl !== null) setText(timerEl, "00:00"); + } + function render(): void { if (root === null || channelNameEl === null) return; @@ -127,12 +162,14 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone if (channelId === null) { root.classList.remove("visible"); stopStatsPoller(); + stopElapsedTimer(); statsPane?.classList.remove("visible"); return; } root.classList.add("visible"); startStatsPoller(); + startElapsedTimer(); // Channel name const channel = channelsStore.getState().channels.get(channelId); @@ -171,6 +208,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone // Header row: "Voice Connected" + channel name + signal icon const header = createElement("div", { class: "vw-header" }); const connLabel = createElement("span", { class: "vw-connected" }, "Voice Connected"); + timerEl = createElement("span", { class: "vw-timer" }, "00:00"); channelNameEl = createElement("span", { class: "vw-channel" }, "Voice Channel"); signalWrap = createElement("div", { class: "vw-signal", "aria-label": "Connection quality" }); @@ -182,7 +220,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone statsPane?.classList.toggle("visible"); }, { signal: ac.signal }); - appendChildren(header, connLabel, channelNameEl, signalWrap); + appendChildren(header, connLabel, timerEl, channelNameEl, signalWrap); // Expanded stats pane (hidden by default) statsPane = createElement("div", { class: "vw-stats" }); @@ -277,6 +315,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone function destroy(): void { stopStatsPoller(); + stopElapsedTimer(); ac.abort(); for (const unsub of unsubs) { unsub(); @@ -291,6 +330,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone shareBtn = null; signalWrap = null; pingLabel = null; + timerEl = null; statsPane = null; outRateEl = null; outPacketsEl = null; diff --git a/Client/tauri-client/src/lib/connectionStats.ts b/Client/tauri-client/src/lib/connectionStats.ts index cbbe641e..d3653be7 100644 --- a/Client/tauri-client/src/lib/connectionStats.ts +++ b/Client/tauri-client/src/lib/connectionStats.ts @@ -70,7 +70,7 @@ async function collectAllStats( } return reports; } catch { - log.debug("Failed to access peer connection stats"); + log.warn("Failed to access peer connection stats — LiveKit SDK internals may have changed"); return []; } } diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 03f3baeb..99587e2b 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -159,24 +159,45 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { user: payload.user.username, }); addMessage(payload); - // Increment unread for non-active channels const activeId = channelsStore.select( (s) => s.activeChannelId, ); - if (payload.channel_id !== activeId) { + + // Check if this is a DM channel and whether the message is from self. + const dmChannels = dmStore.getState().channels; + const isDm = dmChannels.some((c) => c.channelId === payload.channel_id); + const currentUserId = authStore.getState().user?.id ?? null; + const isOwnMessage = currentUserId !== null && payload.user.id === currentUserId; + + // Increment channel-level unread for non-active, non-own-message channels. + // DM channel IDs are not in channelsStore (they use dmStore), so + // incrementUnread is a no-op for DMs, but the own-message guard is + // applied here for defence-in-depth. + if (payload.channel_id !== activeId && !isOwnMessage) { incrementUnread(payload.channel_id); } - // Update DM store last message if this message belongs to a DM channel - const dmChannels = dmStore.getState().channels; - const isDm = dmChannels.some((c) => c.channelId === payload.channel_id); + // Update DM store last message if this message belongs to a DM channel. + // Skip unread increment for own messages and for the currently focused DM. if (isDm) { - updateDmLastMessage( - payload.channel_id, - payload.id, - payload.content, - payload.timestamp, - ); + const isDmActive = payload.channel_id === activeId; + if (isOwnMessage || isDmActive) { + // Update last message preview but don't increment unread count. + dmStore.setState((prev) => ({ + channels: prev.channels.map((c) => + c.channelId === payload.channel_id + ? { ...c, lastMessageId: payload.id, lastMessage: payload.content, lastMessageAt: payload.timestamp } + : c, + ), + })); + } else { + updateDmLastMessage( + payload.channel_id, + payload.id, + payload.content, + payload.timestamp, + ); + } } // Fire desktop notification, taskbar flash, and sound diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 0b4f90f9..75af5391 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -125,6 +125,8 @@ export class LiveKitSession { private reconnectAc: AbortController | null = null; /** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */ private outputVolumeMultiplier = loadPref("outputVolume", 100) / 100; + /** Remote microphone audio elements keyed by track SID for cleanup on disconnect. */ + private remoteMicAudioElements = new Map(); /** Screenshare audio elements keyed by userId — separate from mic audio pipeline. */ private screenshareAudioElements = new Map>(); /** Persisted mute state for screenshare audio so replacement tracks inherit UI state. */ @@ -215,7 +217,7 @@ export class LiveKitSession { if (publication.source === Track.Source.Microphone) { const { localMuted, localDeafened } = voiceStore.getState(); if (localMuted || localDeafened) { - void this.applyMicMuteState(true); + this.applyMicMuteState(true).catch((e) => log.warn("applyMicMuteState failed", e)); log.debug("LocalTrackPublished: re-applied mute to mic track"); } } @@ -257,6 +259,10 @@ export class LiveKitSession { const audioEl = track.attach(); audioEl.style.display = "none"; document.body.appendChild(audioEl); + // Track mic audio elements for cleanup on abnormal disconnect + if (track.sid !== undefined) { + this.remoteMicAudioElements.set(track.sid, audioEl); + } // Apply saved per-user volume via LiveKit's setVolume (supports 0-2.0 range) participant.setVolume(this.getEffectiveVolume(userId)); const savedOutput = loadPref("audioOutputDevice", ""); @@ -295,6 +301,7 @@ export class LiveKitSession { log.debug("Screenshare audio track unsubscribed and detached", { userId, trackSid: track.sid }); } else { for (const el of track.detach()) el.remove(); + if (track.sid !== undefined) this.remoteMicAudioElements.delete(track.sid); log.debug("Remote audio track unsubscribed and detached", { userId, trackSid: track.sid }); } } else if (track.kind === Track.Kind.Video) { @@ -404,6 +411,9 @@ export class LiveKitSession { this.setupAudioPipeline(); this.reapplyMuteGain(); this.startTokenRefreshTimer(); + // Clear the abort controller after all post-connect work is done so + // leaveVoice() can still abort during restoreLocalVoiceState above. + this.reconnectAc = null; // Request a fresh token since the stored one may be close to expiry. this.requestTokenRefresh(); return; @@ -464,7 +474,9 @@ export class LiveKitSession { } log.info("Requesting voice token refresh"); this.ws.send({ type: "voice_token_refresh", payload: {} }); - this.startTokenRefreshTimer(); + // NOTE: startTokenRefreshTimer is called from handleVoiceTokenRefresh + // (the server response handler), not here, to avoid scheduling two + // competing timers per cycle. } handleVoiceTokenRefresh(token?: string): void { @@ -699,6 +711,10 @@ export class LiveKitSession { if (sendWs && this.ws !== null) { this.ws.send({ type: "voice_leave", payload: {} }); } + // Remove orphaned remote mic audio elements (normally cleaned up by + // TrackUnsubscribed, but may be missed during rapid reconnection). + for (const el of this.remoteMicAudioElements.values()) el.remove(); + this.remoteMicAudioElements.clear(); for (const audioEls of this.screenshareAudioElements.values()) { for (const el of audioEls) el.remove(); } @@ -730,14 +746,14 @@ export class LiveKitSession { setMuted(muted: boolean): void { setLocalMuted(muted); - void this.applyMicMuteState(muted); + this.applyMicMuteState(muted).catch((e) => log.warn("applyMicMuteState failed", e)); } setDeafened(deafened: boolean): void { setLocalDeafened(deafened); this.applyRemoteAudioSubscriptionState(deafened); const shouldMute = deafened || voiceStore.getState().localMuted; - void this.applyMicMuteState(shouldMute); + this.applyMicMuteState(shouldMute).catch((e) => log.warn("applyMicMuteState failed", e)); log.debug("Deafen state changed", { deafened }); } @@ -1062,7 +1078,7 @@ export class LiveKitSession { private reapplyMuteGain(): void { const { localMuted, localDeafened } = voiceStore.getState(); if (localMuted || localDeafened) { - void this.applyMicMuteState(true); + this.applyMicMuteState(true).catch((e) => log.warn("applyMicMuteState failed", e)); } } diff --git a/Client/tauri-client/src/lib/media-visibility.ts b/Client/tauri-client/src/lib/media-visibility.ts index 834410d4..8d90191e 100644 --- a/Client/tauri-client/src/lib/media-visibility.ts +++ b/Client/tauri-client/src/lib/media-visibility.ts @@ -269,7 +269,8 @@ export function unobserveMedia(img: HTMLImageElement): void { observer?.unobserve(img); // Remove from allTracked to prevent unbounded WeakRef accumulation. for (const ref of allTracked) { - if (ref.deref() === img || ref.deref() === undefined) { + const target = ref.deref(); + if (target === img || target === undefined) { allTracked.delete(ref); } } diff --git a/Client/tauri-client/src/lib/themes.ts b/Client/tauri-client/src/lib/themes.ts index f1ddb72e..a929fc75 100644 --- a/Client/tauri-client/src/lib/themes.ts +++ b/Client/tauri-client/src/lib/themes.ts @@ -59,6 +59,14 @@ export function applyThemeByName(name: string): void { if (theme !== null) { document.body.classList.add("theme-custom"); for (const [prop, value] of Object.entries(theme.colors)) { + // Validate: property must be a CSS custom property with a spec-compliant + // ident name; value must only contain safe CSS value characters to + // prevent CSS injection from untrusted theme JSON files. + if (!prop.startsWith("--") || !/^[a-zA-Z_][\w-]*$/.test(prop.slice(2))) continue; + if (typeof value !== "string") continue; + // Allowlist: only permit characters found in typical CSS color/sizing values. + // Blocks url(), expression(), semicolons, braces, and !important. + if (!/^[\w\s#().,%+\-/]+$/.test(value)) continue; style.setProperty(prop, value); } } @@ -116,9 +124,25 @@ export function exportTheme(theme: OwnCordTheme): string { } /** - * Restores the previously persisted theme on application startup. + * Restores the previously persisted theme and accent color on application startup. * Call once from the app entry point. */ export function restoreTheme(): void { applyThemeByName(getActiveThemeName()); + + // Restore the user's accent color override (saved by AppearanceTab). + // The accent must be applied after the theme so it wins over the theme's + // --accent value via inline style specificity. + try { + const raw = localStorage.getItem("owncord:pref:accentColor"); + if (raw !== null) { + const accent = JSON.parse(raw); + if (typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)) { + document.documentElement.style.setProperty("--accent", accent); + document.body.style.setProperty("--accent", accent); + } + } + } catch { + // Corrupted localStorage — ignore, theme default will apply. + } } diff --git a/Client/tauri-client/src/pages/main-page/SidebarArea.ts b/Client/tauri-client/src/pages/main-page/SidebarArea.ts index 347cebe8..fc41e778 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarArea.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarArea.ts @@ -463,6 +463,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult { // Restore the channel the user was on before entering DMs if (channelBeforeDm !== null) { setActiveChannel(channelBeforeDm); + channelBeforeDm = null; } else { // Fall back to the first text channel const channels = channelsStore.getState().channels; diff --git a/Client/tauri-client/src/stores/ui.store.ts b/Client/tauri-client/src/stores/ui.store.ts index 080014bd..2c7276e3 100644 --- a/Client/tauri-client/src/stores/ui.store.ts +++ b/Client/tauri-client/src/stores/ui.store.ts @@ -24,7 +24,7 @@ const INITIAL_STATE: UiState = { memberListVisible: true, settingsOpen: false, activeModal: null, - theme: "dark", + theme: "neon-glow", connectionStatus: "disconnected", transientError: null, persistentError: null, @@ -136,8 +136,12 @@ export function loadCollapsedCategories(serverHost: string): void { uiStore.setState((prev) => ({ ...prev, collapsedCategories: new Set() })); return; } - const parsed = JSON.parse(raw) as string[]; - const loaded: ReadonlySet = new Set(parsed); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || !parsed.every((s) => typeof s === "string")) { + uiStore.setState((prev) => ({ ...prev, collapsedCategories: new Set() })); + return; + } + const loaded: ReadonlySet = new Set(parsed as string[]); uiStore.setState((prev) => ({ ...prev, collapsedCategories: loaded })); } catch { uiStore.setState((prev) => ({ ...prev, collapsedCategories: new Set() })); diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts index e003ec7f..5b6aa9b0 100644 --- a/Client/tauri-client/src/stores/voice.store.ts +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -41,6 +41,8 @@ export interface VoiceState { readonly localDeafened: boolean; readonly localCamera: boolean; readonly localScreenshare: boolean; + /** Epoch ms when the local user joined the current voice channel (for elapsed timer). */ + readonly joinedAt: number | null; } const INITIAL_STATE: VoiceState = { @@ -51,6 +53,7 @@ const INITIAL_STATE: VoiceState = { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, }; export const voiceStore = createStore(INITIAL_STATE); @@ -65,6 +68,7 @@ export function resetVoiceStore(): void { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); } @@ -155,11 +159,12 @@ export function removeVoiceUser(payload: VoiceLeavePayload): void { }); } -/** Set the current voice channel (local join). */ +/** Set the current voice channel (local join) and record the join timestamp. */ export function joinVoiceChannel(channelId: number): void { voiceStore.setState((prev) => ({ ...prev, currentChannelId: channelId, + joinedAt: Date.now(), })); } @@ -169,11 +174,11 @@ export function leaveVoiceChannel(): void { voiceStore.setState((prev) => { const channelId = prev.currentChannelId; if (channelId === null || currentUserId === 0) { - return { ...prev, currentChannelId: null }; + return { ...prev, currentChannelId: null, joinedAt: null }; } const existingChannel = prev.voiceUsers.get(channelId); if (!existingChannel || !existingChannel.has(currentUserId)) { - return { ...prev, currentChannelId: null }; + return { ...prev, currentChannelId: null, joinedAt: null }; } const nextChannels = new Map(prev.voiceUsers); const nextUsers = new Map(existingChannel); @@ -183,7 +188,7 @@ export function leaveVoiceChannel(): void { } else { nextChannels.set(channelId, nextUsers); } - return { ...prev, currentChannelId: null, voiceUsers: nextChannels }; + return { ...prev, currentChannelId: null, joinedAt: null, voiceUsers: nextChannels }; }); } diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 36dfe072..5da0dd58 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -165,6 +165,7 @@ .voice-widget.visible { display: block; } .vw-header { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; } .vw-connected { color: var(--green); font-weight: 700; } +.vw-timer { color: var(--green); font-size: 11px; opacity: 0.8; font-variant-numeric: tabular-nums; } .vw-channel { color: var(--text-muted); } .vw-controls { display: flex; gap: 4px; padding: 4px 4px 0; } .vw-controls button { diff --git a/Client/tauri-client/tests/helpers/test-utils.ts b/Client/tauri-client/tests/helpers/test-utils.ts index d8920ba4..f90ef0ce 100644 --- a/Client/tauri-client/tests/helpers/test-utils.ts +++ b/Client/tauri-client/tests/helpers/test-utils.ts @@ -55,6 +55,7 @@ const VOICE_INITIAL: VoiceState = { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, }; const UI_INITIAL: UiState = { diff --git a/Client/tauri-client/tests/integration/stores.test.ts b/Client/tauri-client/tests/integration/stores.test.ts index 602b48d1..aa9fdf24 100644 --- a/Client/tauri-client/tests/integration/stores.test.ts +++ b/Client/tauri-client/tests/integration/stores.test.ts @@ -120,6 +120,7 @@ function resetAllStores(): void { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); authStore.setState(() => ({ token: null, diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index 11c3bab3..63923e54 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -44,6 +44,7 @@ function resetStores(): void { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); membersStore.setState(() => ({ members: new Map(), diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index ac831c22..ce1d07a4 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -91,6 +91,7 @@ describe("WS Dispatcher", () => { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); mock = createMockWs(); diff --git a/Client/tauri-client/tests/unit/voice-channel.test.ts b/Client/tauri-client/tests/unit/voice-channel.test.ts index fc581813..8f63dff7 100644 --- a/Client/tauri-client/tests/unit/voice-channel.test.ts +++ b/Client/tauri-client/tests/unit/voice-channel.test.ts @@ -13,6 +13,7 @@ function resetStores(): void { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); membersStore.setState(() => ({ members: new Map(), diff --git a/Client/tauri-client/tests/unit/voice-disconnect.test.ts b/Client/tauri-client/tests/unit/voice-disconnect.test.ts index 5a5c9594..d6aa4cc7 100644 --- a/Client/tauri-client/tests/unit/voice-disconnect.test.ts +++ b/Client/tauri-client/tests/unit/voice-disconnect.test.ts @@ -21,6 +21,7 @@ function resetStores(): void { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); authStore.setState(() => ({ token: null, diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts index 175fdb4d..96375f59 100644 --- a/Client/tauri-client/tests/unit/voice-widget.test.ts +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -14,6 +14,7 @@ function resetStores(): void { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); channelsStore.setState(() => ({ channels: new Map(), diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index 39b8757e..5d558eb1 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -30,6 +30,7 @@ function resetStore(): void { localDeafened: false, localCamera: false, localScreenshare: false, + joinedAt: null, })); } diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index 860a86e4..33f0c62e 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -125,14 +125,33 @@ func handleGetMessages(database *db.DB) http.HandlerFunc { return } - // Permission check: user must have READ_MESSAGES on this channel. - role, _ := r.Context().Value(RoleKey).(*db.Role) - if !hasChannelPermREST(database, role, channelID, permissions.ReadMessages) { - writeJSON(w, http.StatusForbidden, errorResponse{ - Error: "FORBIDDEN", - Message: "no permission to view this channel", - }) - return + // DM channels use participant-based auth instead of role-based permissions. + if ch.Type == "dm" { + user, _ := r.Context().Value(UserKey).(*db.User) + if user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "authentication required", + }) + return + } + ok, dmErr := database.IsDMParticipant(user.ID, channelID) + if dmErr != nil || !ok { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "not a participant in this DM", + }) + return + } + } else { + role, _ := r.Context().Value(RoleKey).(*db.Role) + if !hasChannelPermREST(database, role, channelID, permissions.ReadMessages) { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "no permission to view this channel", + }) + return + } } // Parse query params. diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index df105a10..324857a6 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -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) } } diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index cb616c51..14664a90 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -22,6 +22,8 @@ import ( func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { target, err := url.Parse(livekitURL) if err != nil { + slog.Error("invalid LiveKit URL — falling back to localhost:7880", + "url", livekitURL, "error", err) target, _ = url.Parse("http://localhost:7880") } diff --git a/Server/api/router.go b/Server/api/router.go index 210a7342..25cbf039 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "net/http" + "net/url" "time" "github.com/go-chi/chi/v5" @@ -55,8 +56,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Channel and message REST routes. MountChannelRoutes(r, database) - // DM (direct message) REST routes. - MountDMRoutes(r, database) + // DM REST routes are mounted after hub creation (below) so the hub can + // be passed as a DMBroadcaster for real-time close events. // File upload and serving routes. store, storeErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB) @@ -87,6 +88,19 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri } } + // Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs. + if lkErr == nil && cfg.Voice.LiveKitBinaryPath == "" { + lkHost := "" + if u, parseErr := url.Parse(cfg.Voice.LiveKitURL); parseErr == nil { + lkHost = u.Hostname() + } + if lkHost != "" && lkHost != "localhost" && lkHost != "127.0.0.1" && lkHost != "::1" { + slog.Warn("LiveKit is externally managed but webhook endpoint is admin-IP-restricted — "+ + "ensure the LiveKit server's IP is in admin_allowed_cidrs or webhooks will be silently dropped", + "livekit_host", lkHost) + } + } + // LiveKit webhook endpoint (no auth middleware — uses LiveKit JWT verification). if lkErr == nil { r.With(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs)). @@ -105,6 +119,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins))) } + // DM (direct message) REST routes — mounted after hub creation so the + // hub can send real-time dm_channel_close events to WebSocket clients. + MountDMRoutes(r, database, hub) + go hub.Run() r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins)) diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index 444ab567..80fa6ca3 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -30,10 +31,20 @@ type DMUser struct { // GetOrCreateDMChannel finds or creates a DM channel between two users. // Returns the channel, whether it was newly created, and any error. +// The entire lookup+create is wrapped in a single IMMEDIATE transaction to +// prevent a TOCTOU race where two concurrent requests both see ErrNoRows and +// each create a separate DM channel for the same user pair. func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error) { - // Check for an existing DM channel between the two users. + tx, err := d.sqlDB.BeginTx(context.Background(), &sql.TxOptions{ + Isolation: sql.LevelSerializable, + }) + if err != nil { + return nil, false, fmt.Errorf("GetOrCreateDMChannel begin tx: %w", err) + } + + // Check for an existing DM channel inside the transaction. var existingID int64 - err := d.sqlDB.QueryRow( + err = tx.QueryRow( `SELECT dp1.channel_id FROM dm_participants dp1 JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id JOIN channels c ON c.id = dp1.channel_id @@ -43,6 +54,16 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error ).Scan(&existingID) if err == nil { + // Existing channel found — ensure the calling user has it open (re-open + // is idempotent). Without this, a user who previously closed the DM would + // not see it in their sidebar after the other party re-initiates. + _, _ = tx.Exec( + `INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?)`, + user1ID, existingID, + ) + if commitErr := tx.Commit(); commitErr != nil { + return nil, false, fmt.Errorf("GetOrCreateDMChannel commit existing: %w", commitErr) + } ch, getErr := d.GetChannel(existingID) if getErr != nil { return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch existing: %w", getErr) @@ -53,14 +74,11 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error return ch, false, nil } if !errors.Is(err, sql.ErrNoRows) { + _ = tx.Rollback() return nil, false, fmt.Errorf("GetOrCreateDMChannel lookup: %w", err) } - // No existing DM — create one inside a transaction. - tx, err := d.sqlDB.Begin() - if err != nil { - return nil, false, fmt.Errorf("GetOrCreateDMChannel begin tx: %w", err) - } + // No existing DM — create one inside the same transaction. // Insert channel with type 'dm' and empty name. res, err := tx.Exec( diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index a5a841c1..2c69d5e7 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/owncord/server/auth" + "github.com/owncord/server/config" "github.com/owncord/server/db" "github.com/owncord/server/ws" ) @@ -52,6 +53,7 @@ CREATE TABLE IF NOT EXISTS attachments ( width INTEGER, height INTEGER ); + `)...) func openCoverageDB(t *testing.T) *db.DB { @@ -75,6 +77,18 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) { database := openCoverageDB(t) limiter := auth.NewRateLimiter() hub := ws.NewHub(database, limiter) + + // Inject a test LiveKit client so voice_join passes the livekit!=nil guard. + lk, err := ws.NewLiveKitClient(&config.VoiceConfig{ + LiveKitAPIKey: "test-api-key-12345", + LiveKitAPISecret: "test-api-secret-67890abcdef", + LiveKitURL: "ws://localhost:7880", + }) + if err != nil { + t.Fatalf("NewLiveKitClient: %v", err) + } + hub.SetLiveKit(lk) + go hub.Run() t.Cleanup(func() { hub.Stop() }) return hub, database diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index fb852715..c8c51c8e 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -560,13 +560,29 @@ func (h *Hub) handleTyping(c *Client, payload json.RawMessage) { return // silently drop; no error for typing throttle } + // DM channels require participant check instead of role-based permissions. + typCh, typChErr := h.db.GetChannel(channelID) + if typChErr != nil || typCh == nil { + return // silently drop for unknown channels + } + if typCh.Type == "dm" { + ok, dmErr := h.db.IsDMParticipant(c.userID, channelID) + if dmErr != nil || !ok { + return // silently drop — not a DM participant + } + } + var username string if c.user != nil { username = c.user.Username } // Broadcast to channel, excluding sender. - h.broadcastExclude(channelID, c.userID, buildTypingMsg(channelID, c.userID, username)) + if typCh.Type == "dm" { + h.broadcastToDMParticipants(channelID, buildTypingMsg(channelID, c.userID, username)) + } else { + h.broadcastExclude(channelID, c.userID, buildTypingMsg(channelID, c.userID, username)) + } } // handlePresence processes a presence_update message. @@ -677,10 +693,23 @@ func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { return } - // Permission check: user must have READ_MESSAGES on the target channel. - if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") { + // DM channels use participant-based auth instead of role-based permissions. + ch, chErr := h.db.GetChannel(chID) + if chErr != nil || ch == nil { + slog.Debug("handleChannelFocus: channel not found", "channel_id", chID) return } + if ch.Type == "dm" { + ok, dmErr := h.db.IsDMParticipant(c.userID, chID) + if dmErr != nil || !ok { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not a participant in this DM")) + return + } + } else { + if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") { + return + } + } c.mu.Lock() prevCh := c.channelID diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 58591166..f826f685 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -387,14 +387,15 @@ func (h *Hub) ReplayBuffer() *EventRingBuffer { func wrapWithSeq(msg []byte, seq uint64) []byte { // Fast path: inject seq after the opening brace. // e.g., {"type":"chat_message",...} → {"seq":123,"type":"chat_message",...} - if len(msg) > 0 && msg[0] == '{' { - prefix := fmt.Sprintf(`{"seq":%d,`, seq) - result := make([]byte, 0, len(prefix)+len(msg)-1) - result = append(result, prefix...) - result = append(result, msg[1:]...) // skip opening brace - return result + // Guard: msg must be a non-empty JSON object (starts with '{' and has content). + if len(msg) < 2 || msg[0] != '{' { + return msg } - return msg + prefix := fmt.Sprintf(`{"seq":%d,`, seq) + result := make([]byte, 0, len(prefix)+len(msg)-1) + result = append(result, prefix...) + result = append(result, msg[1:]...) // skip opening brace + return result } // staleClientTimeout is the maximum duration a client can go without sending diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index b78a2560..4d629023 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -641,4 +641,17 @@ CREATE TABLE IF NOT EXISTS settings ( INSERT OR IGNORE INTO settings (key, value) VALUES ('server_name', 'OwnCord Server'), ('motd', 'Welcome!'); + +CREATE TABLE IF NOT EXISTS dm_participants ( + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (channel_id, user_id) +); + +CREATE TABLE IF NOT EXISTS dm_open_state ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + opened_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, channel_id) +); `) diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index bbec30e7..de8eac91 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -30,6 +30,7 @@ type LiveKitProcess struct { cmd *exec.Cmd cancel context.CancelFunc stopped bool + runDone chan struct{} // closed by runLoop when cmd.Run() returns } // NewLiveKitProcess creates a new process manager. It does not start the @@ -56,6 +57,15 @@ func (p *LiveKitProcess) generateConfig() (string, error) { // No TURN TLS config — LiveKit signaling is proxied through OwnCord's // HTTPS server at /livekit/*, so no separate TLS is needed on LiveKit. + // Sanitize credentials for safe YAML interpolation: reject strings + // containing characters that could break YAML structure. + for _, cred := range []string{p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret} { + for _, ch := range cred { + if ch == ':' || ch == '#' || ch == '{' || ch == '}' || ch == '\n' || ch == '\r' || ch == '"' || ch == '\\' { + return "", fmt.Errorf("LiveKit credential contains unsafe YAML character %q", string(ch)) + } + } + } content := fmt.Sprintf(`# Auto-generated by OwnCord — do not edit manually. port: 7880 @@ -143,6 +153,7 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { return } p.cmd = cmd + p.runDone = make(chan struct{}) p.mu.Unlock() slog.Info("livekit: starting process", @@ -155,6 +166,10 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) { p.mu.Lock() p.cmd = nil + if p.runDone != nil { + close(p.runDone) + p.runDone = nil + } stopped := p.stopped p.mu.Unlock() @@ -229,31 +244,34 @@ func (p *LiveKitProcess) HealthCheck() (bool, error) { } // Stop gracefully stops the companion process. +// It cancels the context (which signals runLoop) and waits up to 5 seconds +// for the process to exit. The actual cmd.Wait() is done by runLoop via +// cmd.Run() — we only monitor the process via cmd.Process.Wait() here to +// avoid calling exec.Cmd.Wait() twice (which has undefined behavior). func (p *LiveKitProcess) Stop() { p.mu.Lock() p.stopped = true cancel := p.cancel cmd := p.cmd + done := p.runDone p.mu.Unlock() if cancel != nil { cancel() } - // Wait briefly for the process to exit after context cancellation - if cmd != nil && cmd.Process != nil { - done := make(chan struct{}) - go func() { - _ = cmd.Wait() - close(done) - }() - + // Wait for runLoop's cmd.Run() to return (which closes runDone). + // This avoids calling cmd.Wait() or cmd.Process.Wait() from a second + // goroutine, which is unsafe on Windows. + if done != nil { select { case <-done: slog.Info("livekit: process exited cleanly") case <-time.After(5 * time.Second): slog.Warn("livekit: process did not exit in time, killing") - _ = cmd.Process.Kill() + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } } } } diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index e47f376a..8fb390e7 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/owncord/server/auth" + "github.com/owncord/server/config" "github.com/owncord/server/db" "github.com/owncord/server/ws" ) @@ -45,11 +46,24 @@ func openVoiceTestDB(t *testing.T) *db.DB { } // newVoiceHub creates a hub+db suitable for voice handler tests. +// It injects a test LiveKit client so voice_join passes the livekit!=nil guard. func newVoiceHub(t *testing.T) (*ws.Hub, *db.DB) { t.Helper() database := openVoiceTestDB(t) limiter := auth.NewRateLimiter() hub := ws.NewHub(database, limiter) + + // Inject a test LiveKit client with non-default credentials. + lk, err := ws.NewLiveKitClient(&config.VoiceConfig{ + LiveKitAPIKey: "test-api-key-12345", + LiveKitAPISecret: "test-api-secret-67890abcdef", + LiveKitURL: "ws://localhost:7880", + }) + if err != nil { + t.Fatalf("NewLiveKitClient: %v", err) + } + hub.SetLiveKit(lk) + go hub.Run() t.Cleanup(func() { hub.Stop() }) return hub, database diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index ac6d4164..b4519e8f 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -37,6 +37,14 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { return } + // Validate the target channel exists before any state changes (leaving + // the current voice channel, persisting join, etc.). + ch, err := h.db.GetChannel(channelID) + if err != nil || ch == nil { + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + return + } + // Hard-fail when LiveKit is not configured — without an SFU the client // cannot connect to voice, so persisting state would create a ghost. if h.livekit == nil { @@ -65,12 +73,6 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { h.handleVoiceLeave(c) } - ch, err := h.db.GetChannel(channelID) - if err != nil || ch == nil { - c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) - return - } - // Check channel capacity. maxUsers := ch.VoiceMaxUsers if maxUsers > 0 {