From 10e6ba46e2a754c47aa4cf4647a364caa6e26571 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 29 Jun 2026 23:26:45 -0400 Subject: [PATCH] lotus(#3): harden audio-inject per review - resume() the AudioContext (host postMessage isn't a gesture) so the clip isn't silent; warn if it stays suspended (HIGH). - Close the AudioContext on decode failure (no context leak) (MED). - Abort in-flight clips on teardown (unmount/vm-change/leave) so audio doesn't keep blasting to peers (MED). - Stop the cloned MediaStreamTrack when a room publish fails (MED). - Validate url is https/blob and fetch with credentials:omit, mode:cors (MED security). - Guard against NaN clip duration; fix stale enum doc comment. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusActions.ts | 2 +- src/lotus/lotusAudioInject.ts | 75 ++++++++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index 83e58d33..b7468619 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -21,7 +21,7 @@ export enum LotusWidgetActions { CallState = "io.lotus.call_state", /** toWidget: pin/spotlight (or clear, with userId=null) a participant. */ FocusParticipant = "io.lotus.focus_participant", - /** toWidget: mix an audio clip into the local published mic track. */ + /** toWidget: play an audio clip into the call as a separate published track. */ InjectAudio = "io.lotus.inject_audio", /** toWidget: set audio/screenshare encoding quality (bitrate/framerate). */ SetQuality = "io.lotus.set_quality", diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index 79c2d972..259c4d24 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -27,10 +27,11 @@ const MAX_CLIP_MS = 30_000; * that was impossible against the prebuilt EC bundle (LiveKit's * LocalParticipant lived in EC's module scope). * - * Action data: `{ url: string, volume?: number }`. `url` must already be a - * fetchable http(s)/blob URL (the host resolves mxc → media URL). + * Action data: `{ url: string, volume?: number }`. `url` must be an https/blob + * URL (the host resolves mxc → media URL). * - * No effect unless the host sends the action. Returns a teardown function. + * No effect unless the host sends the action. Returns a teardown function that + * also aborts any clip still playing. */ export function startLotusAudioInject(vm: CallViewModel): () => void { const w = widget; @@ -42,23 +43,25 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { rooms = items.map((i) => i.livekitRoom); }); + // In-flight clips, so we can abort them on teardown (unmount / vm change / + // call leave) instead of leaving audio blasting to peers. + const activeClips = new Set<() => void>(); + const handler = (ev: CustomEvent): void => { void w.api.transport.reply(ev.detail, {}); const data = ev.detail.data as | { url?: unknown; volume?: unknown } | undefined; - const url = typeof data?.url === "string" ? data.url : null; + const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null; if (!url) { - logger.warn("[lotus] inject_audio: missing url"); + logger.warn("[lotus] inject_audio: missing/invalid url"); return; } const volume = - typeof data?.volume === "number" && - data.volume >= 0 && - data.volume <= 1 + typeof data?.volume === "number" && data.volume >= 0 && data.volume <= 1 ? data.volume : 1; - void playInjectedClip(url, volume, rooms).catch((e) => + void playInjectedClip(url, volume, rooms, activeClips).catch((e) => logger.warn("[lotus] inject_audio failed", e), ); }; @@ -67,25 +70,56 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { return () => { sub.unsubscribe(); w.lazyActions.off(LotusWidgetActions.InjectAudio, handler); + // Abort anything still playing. + for (const abort of [...activeClips]) abort(); }; } +/** Only allow fetchable media URLs; never same-origin credentialed GETs etc. */ +function safeMediaUrl(raw: string): string | null { + try { + const u = new URL(raw, window.location.href); + return u.protocol === "https:" || u.protocol === "blob:" ? u.href : null; + } catch { + return null; + } +} + async function playInjectedClip( url: string, volume: number, rooms: LivekitRoom[], + activeClips: Set<() => void>, ): Promise { if (rooms.length === 0) { logger.warn("[lotus] inject_audio: no connected rooms"); return; } - const resp = await fetch(url); + const resp = await fetch(url, { credentials: "omit", mode: "cors" }); if (!resp.ok) throw new Error(`fetch ${url} -> ${resp.status}`); const arrayBuffer = await resp.arrayBuffer(); const ctx = new AudioContext(); - const buffer = await ctx.decodeAudioData(arrayBuffer); + // The action arrives via host postMessage, not a gesture in this iframe, so + // the context may start suspended — resume it or the clip is silent and + // `onended` never fires. + try { + await ctx.resume(); + } catch { + /* best effort */ + } + if (ctx.state !== "running") + logger.warn(`[lotus] inject_audio: AudioContext is ${ctx.state}`); + + let buffer: AudioBuffer; + try { + buffer = await ctx.decodeAudioData(arrayBuffer); + } catch (e) { + void ctx.close(); + throw e; + } + const dest = ctx.createMediaStreamDestination(); const gain = ctx.createGain(); gain.gain.value = volume; @@ -102,8 +136,9 @@ async function playInjectedClip( // Publish (a clone of) the clip track to every connected room. const publications = await Promise.all( rooms.map(async (room) => { + const clone = mst.clone(); try { - const pub = await room.localParticipant.publishTrack(mst.clone(), { + const pub = await room.localParticipant.publishTrack(clone, { source: Track.Source.Unknown, name: "lotus-soundboard", dtx: false, @@ -111,6 +146,7 @@ async function playInjectedClip( }); return { room, pub }; } catch (e) { + clone.stop(); // don't leak the clone if publish failed logger.warn("[lotus] inject_audio: publish failed", e); return null; } @@ -121,6 +157,12 @@ async function playInjectedClip( const cleanup = (): void => { if (cleanedUp) return; cleanedUp = true; + activeClips.delete(cleanup); + try { + source.stop(); + } catch { + /* already stopped */ + } for (const entry of publications) { if (entry?.pub.track) void entry.room.localParticipant @@ -129,10 +171,15 @@ async function playInjectedClip( } void ctx.close().catch(() => undefined); }; + activeClips.add(cleanup); source.onended = cleanup; - // Safety net: clip metadata can lie, so force teardown after the cap. - const guard = setTimeout(cleanup, Math.min(MAX_CLIP_MS, buffer.duration * 1000 + 500)); + // Safety net: clip metadata can lie (NaN/huge duration), so force teardown + // after a sane, capped delay. + const durationMs = Number.isFinite(buffer.duration) + ? buffer.duration * 1000 + 500 + : MAX_CLIP_MS; + const guard = setTimeout(cleanup, Math.min(MAX_CLIP_MS, Math.max(0, durationMs))); source.addEventListener("ended", () => clearTimeout(guard)); source.start();