diff --git a/server/voice-proxy.js b/server/voice-proxy.js index 304535a2..149459fb 100644 --- a/server/voice-proxy.js +++ b/server/voice-proxy.js @@ -56,10 +56,14 @@ const VOICE_TIMEOUT_MS = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0 * @returns {Promise} */ async function fetchWithTimeout(url, options = {}) { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol) || !isAllowedBackendUrl(parsed.origin)) { + throw new Error('Blocked outbound voice backend URL'); + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS); try { - return await fetch(url, { redirect: 'manual', ...options, signal: controller.signal }); + return await fetch(parsed.toString(), { redirect: 'manual', ...options, signal: controller.signal }); } finally { clearTimeout(timer); } diff --git a/src/components/chat/hooks/useVoiceAvailable.ts b/src/components/chat/hooks/useVoiceAvailable.ts index df5fe533..0adccd0d 100644 --- a/src/components/chat/hooks/useVoiceAvailable.ts +++ b/src/components/chat/hooks/useVoiceAvailable.ts @@ -7,13 +7,11 @@ import { VOICE_CONFIG_SYNC_EVENT, voiceConfigHeaders } from '../../../hooks/useV // the Settings modal) and a configured voice backend. const STORAGE_KEY = 'uiPreferences'; const SYNC_EVENT = 'ui-preferences:sync'; -const healthCache = new Map(); const healthRequests = new Map>(); function checkVoiceHealth(): Promise { const baseUrl = voiceConfigHeaders()['x-voice-base-url']; const signature = baseUrl || ''; - if (healthCache.has(signature)) return Promise.resolve(healthCache.get(signature) ?? false); const pending = healthRequests.get(signature); if (pending) return pending; const request = authenticatedFetch('/api/voice/health', { @@ -24,10 +22,6 @@ function checkVoiceHealth(): Promise { const data = await response.json(); return data?.configured === true; }) - .then((available) => { - healthCache.set(signature, available); - return available; - }) .finally(() => { healthRequests.delete(signature); }); diff --git a/src/components/chat/hooks/useVoiceInput.ts b/src/components/chat/hooks/useVoiceInput.ts index 706fd6c4..400612a0 100644 --- a/src/components/chat/hooks/useVoiceInput.ts +++ b/src/components/chat/hooks/useVoiceInput.ts @@ -63,7 +63,6 @@ export function useVoiceInput( const start = useCallback(async () => { if (startingRef.current || (recorderRef.current && recorderRef.current.state !== 'inactive')) return; startingRef.current = true; - let recordingCancelled = false; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, @@ -84,7 +83,7 @@ export function useVoiceInput( rec.onstop = async () => { stopTracks(); - if (recordingCancelled || cancelledRef.current) return; + if (cancelledRef.current) return; // Capture and clear the send intent for this stop before any async work. const shouldSend = sendRef.current; sendRef.current = false; @@ -107,23 +106,22 @@ export function useVoiceInput( }); if (!res.ok) throw new Error(`transcribe ${res.status}`); const data = await res.json(); - if (recordingCancelled || cancelledRef.current) return; + if (cancelledRef.current) return; const text = String(data?.text || '').trim(); if (text) onTranscript(text, shouldSend); else onError?.('No speech detected'); } catch (e) { - if (!recordingCancelled && !cancelledRef.current) { + if (!cancelledRef.current) { onError?.(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`); } } finally { - if (!recordingCancelled && !cancelledRef.current) setState('idle'); + if (!cancelledRef.current) setState('idle'); } }; rec.start(); setState('recording'); } catch (e) { - recordingCancelled = true; recorderRef.current = null; stopTracks(); if (cancelledRef.current) return; diff --git a/src/hooks/useVoiceConfig.ts b/src/hooks/useVoiceConfig.ts index 77e22546..c9141f45 100644 --- a/src/hooks/useVoiceConfig.ts +++ b/src/hooks/useVoiceConfig.ts @@ -18,7 +18,12 @@ function read(): VoiceConfig { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULTS }; const parsed = JSON.parse(raw); - return { ...DEFAULTS, ...(parsed && typeof parsed === 'object' ? parsed : {}) }; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ...DEFAULTS }; + const config = { ...DEFAULTS }; + for (const key of Object.keys(DEFAULTS) as (keyof VoiceConfig)[]) { + if (typeof parsed[key] === 'string') config[key] = parsed[key]; + } + return config; } catch { return { ...DEFAULTS }; }