mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: camera button delay and video feed flickering + security hardening
Camera fixes: - Optimistic setLocalCamera(true) before async setCameraEnabled for instant button highlight, with revert on failure - Only call checkVideoMode when camera-relevant state changes, not on every speaking poll tick (100ms) - VideoGrid.addStream updates existing cells in place instead of destroy+recreate to prevent black frame flashes - VideoModeController tracks localTileAdded to avoid redundant addStream calls Security & hardening (from prior session review): - Settings store key allowlist prevents arbitrary key writes - Certificate fingerprint validates SHA-256 colon-hex format - CredentialData Debug impl redacts token and password - LiveKit config file written with 0600 permissions - Token TTL reduced from 24h to 4h - Null-check on client.user before token generation - Thread-safe getChannelID/trySendMsg helpers on Client - Warn on default dev LiveKit credentials - Devtools feature-gated behind cfg(feature = "devtools") - Updater uses configure_client for self-signed cert acceptance - Clamp voice sensitivity input to 0-100 range - Clear lastConnectToken/Host on logout - Fix animation frame leak in VoiceAudioTab mic meter
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<Value, String> {
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<u16> {
|
||||
let name = format!("OwnCord/{host}");
|
||||
@@ -129,12 +139,12 @@ pub fn load_credential(host: String) -> Result<Option<CredentialData>, 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")
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
@@ -48,8 +48,6 @@
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
|
||||
"endpoints": [],
|
||||
"dangerousAcceptInvalidCerts": true,
|
||||
"dangerousAcceptInvalidHostnames": true,
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -625,6 +625,9 @@ export async function enableCamera(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic UI update — highlight button immediately
|
||||
setLocalCamera(true);
|
||||
|
||||
try {
|
||||
await room.localParticipant.setCameraEnabled(true);
|
||||
|
||||
@@ -634,10 +637,11 @@ export async function enableCamera(): Promise<void> {
|
||||
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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-15
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user