diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 69a06d1b..bda73835 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -11,8 +11,12 @@ crate-type = ["lib", "cdylib", "staticlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +[features] +default = ["devtools"] +devtools = ["tauri/devtools"] + [dependencies] -tauri = { version = "2", features = ["tray-icon", "devtools"] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-store = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2" diff --git a/Client/tauri-client/src-tauri/src/commands.rs b/Client/tauri-client/src-tauri/src/commands.rs index 52394ade..67634e51 100644 --- a/Client/tauri-client/src-tauri/src/commands.rs +++ b/Client/tauri-client/src-tauri/src/commands.rs @@ -1,10 +1,33 @@ use serde_json::Value; -use tauri::Manager; use tauri_plugin_store::StoreExt; const SETTINGS_STORE: &str = "settings.json"; const CERTS_STORE: &str = "certs.json"; +/// Maximum length for a settings key to prevent denial-of-service. +const MAX_SETTINGS_KEY_LEN: usize = 128; + +/// Allowed key prefixes and exact keys for the settings store. +/// Keys must either match an exact entry or start with an allowed prefix. +const ALLOWED_SETTINGS_PREFIXES: &[&str] = &[ + "owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji + "userVolume_", // per-user volume: userVolume_{userId} +]; + +const ALLOWED_SETTINGS_EXACT: &[&str] = &[ + "windowState", +]; + +fn is_settings_key_allowed(key: &str) -> bool { + if key.len() > MAX_SETTINGS_KEY_LEN || key.is_empty() { + return false; + } + if ALLOWED_SETTINGS_EXACT.contains(&key) { + return true; + } + ALLOWED_SETTINGS_PREFIXES.iter().any(|prefix| key.starts_with(prefix)) +} + // --------------------------------------------------------------------------- // Settings commands // --------------------------------------------------------------------------- @@ -27,6 +50,10 @@ pub fn get_settings(app: tauri::AppHandle) -> Result { #[tauri::command] pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> { + if !is_settings_key_allowed(&key) { + return Err(format!("unknown settings key: {key}")); + } + let store = app .store(SETTINGS_STORE) .map_err(|e| format!("failed to open settings store: {e}"))?; @@ -55,6 +82,20 @@ pub fn store_cert_fingerprint( return Err("fingerprint must not be empty".into()); } + // Validate SHA-256 colon-hex format: "AA:BB:CC:..." (95 chars, 32 hex pairs) + if fingerprint.len() != 95 { + return Err("fingerprint must be a SHA-256 colon-hex string (95 chars)".into()); + } + for (i, ch) in fingerprint.chars().enumerate() { + if i % 3 == 2 { + if ch != ':' { + return Err("fingerprint must use colon-separated hex pairs".into()); + } + } else if !ch.is_ascii_hexdigit() { + return Err("fingerprint contains invalid hex character".into()); + } + } + let store = app .store(CERTS_STORE) .map_err(|e| format!("failed to open certs store: {e}"))?; @@ -95,7 +136,10 @@ pub fn get_cert_fingerprint( // --------------------------------------------------------------------------- #[tauri::command] -pub fn open_devtools(window: tauri::WebviewWindow) { +pub fn open_devtools(_window: tauri::WebviewWindow) { #[cfg(feature = "devtools")] - window.open_devtools(); + { + use tauri::Manager; + _window.open_devtools(); + } } diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index 26e0ef31..1a78fe21 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -8,7 +8,7 @@ use windows::Win32::Security::Credentials::{ }; /// Data returned from `load_credential`. -#[derive(Serialize, Clone, Debug)] +#[derive(Serialize, Clone)] pub struct CredentialData { pub username: String, pub token: String, @@ -16,6 +16,16 @@ pub struct CredentialData { pub password: Option, } +impl std::fmt::Debug for CredentialData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CredentialData") + .field("username", &self.username) + .field("token", &"[REDACTED]") + .field("password", &self.password.as_ref().map(|_| "[REDACTED]")) + .finish() + } +} + /// Build the target name used in Windows Credential Manager. fn target_name(host: &str) -> Vec { let name = format!("OwnCord/{host}"); @@ -129,12 +139,12 @@ pub fn load_credential(host: String) -> Result, String> { let username = parsed .get("username") .and_then(|v| v.as_str()) - .unwrap_or_default() + .ok_or("credential blob missing 'username' field")? .to_string(); let token = parsed .get("token") .and_then(|v| v.as_str()) - .unwrap_or_default() + .ok_or("credential blob missing 'token' field")? .to_string(); let password = parsed .get("password") diff --git a/Client/tauri-client/src-tauri/src/update_commands.rs b/Client/tauri-client/src-tauri/src/update_commands.rs index e404ab6d..6e9057ab 100644 --- a/Client/tauri-client/src-tauri/src/update_commands.rs +++ b/Client/tauri-client/src-tauri/src/update_commands.rs @@ -33,10 +33,14 @@ pub async fn check_client_update( .parse() .map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?; + // OwnCord is self-hosted and commonly uses self-signed TLS certs. + // The updater connects to the user's own server, so accept invalid certs + // (the update artifact itself is verified via Ed25519 signature). let updater = app .updater_builder() .endpoints(vec![url]) .map_err(|e| format!("failed to set endpoints: {e}"))? + .configure_client(|client| client.danger_accept_invalid_certs(true)) .build() .map_err(|e| format!("failed to build updater: {e}"))?; @@ -87,6 +91,7 @@ pub async fn download_and_install_update( .updater_builder() .endpoints(vec![url]) .map_err(|e| format!("failed to set endpoints: {e}"))? + .configure_client(|client| client.danger_accept_invalid_certs(true)) .build() .map_err(|e| format!("failed to build updater: {e}"))?; diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index 23d127d1..410031ec 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -48,8 +48,6 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK", "endpoints": [], - "dangerousAcceptInvalidCerts": true, - "dangerousAcceptInvalidHostnames": true, "windows": { "installMode": "passive" } diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index c8a5fefc..88abbae3 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -101,7 +101,6 @@ function showUserVolumeMenu( // Close on click outside const dismissAc = new AbortController(); - const combinedSignal = signal; setTimeout(() => { document.addEventListener("mousedown", (e: MouseEvent) => { if (!menu.contains(e.target as Node)) { @@ -112,7 +111,7 @@ function showUserVolumeMenu( }, 0); // Also clean up if the parent component is destroyed - combinedSignal.addEventListener("abort", () => { + signal.addEventListener("abort", () => { menu.remove(); dismissAc.abort(); }); diff --git a/Client/tauri-client/src/components/VideoGrid.ts b/Client/tauri-client/src/components/VideoGrid.ts index 7b575e35..2cbee92e 100644 --- a/Client/tauri-client/src/components/VideoGrid.ts +++ b/Client/tauri-client/src/components/VideoGrid.ts @@ -31,9 +31,22 @@ export function createVideoGrid(): VideoGridComponent { function addStream(userId: number, username: string, stream: MediaStream): void { if (root === null) return; - // Remove existing cell for this user first - if (cells.has(userId)) { - removeStream(userId); + // If a cell already exists for this user, update it in place + const existing = cells.get(userId); + if (existing) { + const video = existing.querySelector("video"); + if (video !== null) { + // Only replace srcObject if the underlying tracks changed + const oldTracks = (video.srcObject as MediaStream | null)?.getTracks() ?? []; + const newTracks = stream.getTracks(); + const tracksMatch = + oldTracks.length === newTracks.length && + oldTracks.every((t, i) => t.id === newTracks[i]?.id); + if (!tracksMatch) { + video.srcObject = stream; + } + } + return; } const video = createElement("video", { diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts index b6c68eec..88b6e72b 100644 --- a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -269,6 +269,7 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, const dataArray = new Uint8Array(analyser.frequencyBinCount); + let latestFrame = 0; function updateMeter(): void { if (signal.aborted) return; analyser.getByteFrequencyData(dataArray); @@ -291,11 +292,11 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, meterLevel.style.background = "#faa61a"; // yellow — below threshold } - const frame = requestAnimationFrame(updateMeter); - registerMic(stream, audioCtx, frame); + latestFrame = requestAnimationFrame(updateMeter); + registerMic(stream, audioCtx, latestFrame); } - const firstFrame = requestAnimationFrame(updateMeter); - registerMic(stream, audioCtx, firstFrame); + latestFrame = requestAnimationFrame(updateMeter); + registerMic(stream, audioCtx, latestFrame); } catch { // Mic access denied or unavailable — meter stays empty } diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index bec77ede..12723369 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -625,6 +625,9 @@ export async function enableCamera(): Promise { return; } + // Optimistic UI update — highlight button immediately + setLocalCamera(true); + try { await room.localParticipant.setCameraEnabled(true); @@ -634,10 +637,11 @@ export async function enableCamera(): Promise { await room.switchActiveDevice("videoinput", savedVideoDevice); } - setLocalCamera(true); ws.send({ type: "voice_camera", payload: { enabled: true } }); log.info("Camera enabled"); } catch (err) { + // Revert optimistic update on failure + setLocalCamera(false); log.error("Failed to enable camera", err); if (err instanceof DOMException && err.name === "NotAllowedError") { onErrorCallback?.("Camera permission denied"); @@ -759,7 +763,8 @@ export function getUserVolume(userId: number): number { * High sensitivity (100) = low threshold (picks up quiet sounds). * Low sensitivity (0) = high threshold (only loud sounds). */ export function setVoiceSensitivity(sensitivity: number): void { - speakingThreshold = ((100 - sensitivity) / 100) * 0.15; + const clamped = Math.max(0, Math.min(100, sensitivity)); + speakingThreshold = ((100 - clamped) / 100) * 0.15; } /** Get the local camera stream for self-view display. */ diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 64724202..af6cca6c 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -332,6 +332,8 @@ authStore.subscribe((state) => { dispatcherCleanup?.(); dispatcherCleanup = null; ws.disconnect(); + lastConnectToken = ""; + lastConnectHost = ""; // Clear stored credential on logout const host = api.getConfig().host; if (host) { diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 5aae27a0..b869da48 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -496,8 +496,27 @@ export function createMainPage(options: MainPageOptions): MountableComponent { }); unsubscribers.push(() => clearOnRemoteVideo()); - // Subscribe to voice store for camera state changes - unsubscribers.push(voiceStore.subscribe(() => videoModeCtrl?.checkVideoMode())); + // Subscribe to voice store for camera state changes only (not speaking ticks) + let prevLocalCamera = voiceStore.getState().localCamera; + let prevCameraSignature = ""; + unsubscribers.push(voiceStore.subscribe((state) => { + // Build a lightweight signature of camera-relevant state + let sig = state.localCamera ? "1" : "0"; + const channelId = state.currentChannelId; + if (channelId !== null) { + const users = state.voiceUsers.get(channelId); + if (users) { + for (const [uid, u] of users) { + if (u.camera) sig += `:${uid}`; + } + } + } + if (sig !== prevCameraSignature || state.localCamera !== prevLocalCamera) { + prevCameraSignature = sig; + prevLocalCamera = state.localCamera; + videoModeCtrl?.checkVideoMode(); + } + })); // Auto-update notifier — checks server for newer client version if (apiConfig.host) { diff --git a/Client/tauri-client/src/pages/main-page/VideoModeController.ts b/Client/tauri-client/src/pages/main-page/VideoModeController.ts index a91fbab4..b974487c 100644 --- a/Client/tauri-client/src/pages/main-page/VideoModeController.ts +++ b/Client/tauri-client/src/pages/main-page/VideoModeController.ts @@ -46,6 +46,8 @@ export function createVideoModeController( ): VideoModeController { const { slots, videoGrid, getCurrentUserId } = opts; let videoMode = false; + /** Track whether we've already added the local self-view tile. */ + let localTileAdded = false; function showVideoGrid(): void { if (videoMode) return; @@ -94,20 +96,26 @@ export function createVideoModeController( showChat(); } - // Manage local self-view tile + // Manage local self-view tile — only add once, skip if already showing const currentUserId = getCurrentUserId(); if (voice.localCamera) { - const localStream = getLocalCameraStream(); - if (localStream !== null) { - const me = channelUsers.get(currentUserId); - videoGrid.addStream( - currentUserId, - me?.username ? `${me.username} (You)` : "You", - localStream, - ); + if (!localTileAdded) { + const localStream = getLocalCameraStream(); + if (localStream !== null) { + const me = channelUsers.get(currentUserId); + videoGrid.addStream( + currentUserId, + me?.username ? `${me.username} (You)` : "You", + localStream, + ); + localTileAdded = true; + } } } else { - videoGrid.removeStream(currentUserId); + if (localTileAdded) { + videoGrid.removeStream(currentUserId); + localTileAdded = false; + } } // Remove remote video tiles for users who turned off their camera @@ -126,6 +134,7 @@ export function createVideoModeController( function destroy(): void { if (videoMode) showChat(); + localTileAdded = false; } return { diff --git a/Server/config/config.go b/Server/config/config.go index a77fbade..b4d68047 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" + "log/slog" "os" "strings" @@ -207,6 +208,14 @@ func Load(cfgPath string) (*Config, error) { // the YAML section is present but fields are commented out / omitted). applyVoiceDefaults(&cfg.Voice) + // Warn if using default dev credentials — these are public and insecure. + if cfg.Voice.LiveKitAPISecret == "owncord-dev-secret-key-min-32chars" { + slog.Warn("using default LiveKit API secret — change voice.livekit_api_secret in config.yaml for production") + } + if cfg.Voice.LiveKitAPIKey == "devkey" { + slog.Warn("using default LiveKit API key — change voice.livekit_api_key in config.yaml for production") + } + return &cfg, nil } diff --git a/Server/ws/client.go b/Server/ws/client.go index bfdd02b2..e6687ca5 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -108,6 +108,13 @@ func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, chann } } +// getChannelID returns the currently focused channel ID under mu. +func (c *Client) getChannelID() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.channelID +} + // getVoiceChID returns the voice channel ID under voiceMu. func (c *Client) getVoiceChID() int64 { c.voiceMu.Lock() @@ -146,6 +153,22 @@ func (c *Client) sendMsg(msg []byte) { } } +// trySendMsg queues a message and returns true if it was accepted, false if +// the buffer is full or the channel is closed. +func (c *Client) trySendMsg(msg []byte) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.sendClosed { + return false + } + select { + case c.send <- msg: + return true + default: + return false + } +} + // closeSend marks the send channel closed and closes it exactly once. // Safe to call from any goroutine. func (c *Client) closeSend() { diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index e5be58a9..a2430f58 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -483,13 +483,10 @@ func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) { if uid == excludeUserID { continue } - if channelID != 0 && c.channelID != channelID { + if channelID != 0 && c.getChannelID() != channelID { continue } - select { - case c.send <- msg: - default: - } + c.sendMsg(msg) } } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 97d0db98..6618d361 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -258,13 +258,7 @@ func (h *Hub) SendToUser(userID int64, msg []byte) bool { if !ok { return false } - select { - case c.send <- msg: - return true - default: - // send buffer full — drop rather than block. - return false - } + return c.trySendMsg(msg) } // ClientCount returns the number of currently registered clients (test helper). @@ -295,17 +289,12 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) { skipped := 0 for _, c := range h.clients { // channelID == 0 → broadcast to everyone. - if bm.channelID != 0 && c.channelID != bm.channelID && c.getVoiceChID() != bm.channelID { + if bm.channelID != 0 && c.getChannelID() != bm.channelID && c.getVoiceChID() != bm.channelID { skipped++ continue } - select { - case c.send <- bm.msg: - delivered++ - default: - slog.Warn("broadcast dropped: client send buffer full", - "user_id", c.userID, "channel_id", bm.channelID) - } + c.sendMsg(bm.msg) + delivered++ } if bm.channelID != 0 { slog.Debug("hub: channel broadcast", diff --git a/Server/ws/livekit.go b/Server/ws/livekit.go index 9134d8e2..36bb528d 100644 --- a/Server/ws/livekit.go +++ b/Server/ws/livekit.go @@ -19,7 +19,7 @@ import ( ) // tokenTTL is the validity duration for generated LiveKit access tokens. -const tokenTTL = 24 * time.Hour +const tokenTTL = 4 * time.Hour // LiveKitClient provides token generation and room management via // the LiveKit server SDK. diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index 74a49f67..e6e1429c 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -64,7 +64,7 @@ logging: if err := os.MkdirAll(p.dataDir, 0o755); err != nil { return "", fmt.Errorf("creating data dir: %w", err) } - if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { return "", fmt.Errorf("writing livekit config: %w", err) } diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go index 98410d8f..7067fd01 100644 --- a/Server/ws/voice_handlers.go +++ b/Server/ws/voice_handlers.go @@ -96,6 +96,11 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { // Generate LiveKit token if LiveKit client is available. if h.livekit != nil { + if c.user == nil { + slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "not authenticated")) + return + } canPublish := true canSubscribe := true token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, canPublish, canSubscribe)