Files
cinny/src/app/utils/soundboardClips.ts
T
jaredandClaude Opus 5 ac0ec9f42d fix(security): revoke soundboard blob URLs on logout; cap the cache
Decrypted clip blob: URLs lived in an unbounded module Map for the page
lifetime and survived logout. Add clearSoundboardClipCache() (called from
both logout paths next to clearPlaintextCaches) and a 64-entry LRU that
revokes on evict. Unit-tested.

Fixes #57

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00

103 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MatrixClient } from 'matrix-js-sdk';
import { downloadMedia, mxcUrlToHttp } from './matrix';
// [P5-15 v2] Shared media helpers for the soundboard. Clip storage/metadata now
// lives in the soundboard pack plugin (plugins/soundboard); this module only
// handles resolving an mxc clip for playback + local preview.
export const SOUNDBOARD_NAME_MAX = 24;
/** Keep clips short: they publish to every peer and hold a track open. */
export const SOUNDBOARD_MAX_CLIP_BYTES = 1024 * 1024; // 1 MB
export const SOUNDBOARD_MAX_CLIPS = 40;
export const SOUNDBOARD_ACCEPT = 'audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/aac';
// Cache resolved object URLs per mxc so re-triggering a clip doesn't re-download
// it. [Gitea #57] Clips are decrypted media held live via `blob:` URLs, so the
// cache is capped LRU-style (oldest entry revoked on evict) and fully revoked
// on logout — see clearSoundboardClipCache().
const objectUrlCache = new Map<string, string>();
/** Cap is global (across every pack), not per-pack like SOUNDBOARD_MAX_CLIPS. */
const OBJECT_URL_CACHE_MAX = 64;
/**
* Resolve an mxc clip to a `blob:` object URL the Element Call widget can fetch
* without credentials. Authenticated media (MSC3916) can't be fetched from the
* widget's realm, so the host downloads it (auth handled by the service worker)
* and hands the widget a same-session blob URL instead.
*/
export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise<string> => {
const cached = objectUrlCache.get(mxcUrl);
if (cached) {
// Refresh recency: re-insert so this entry is last to be evicted.
objectUrlCache.delete(mxcUrl);
objectUrlCache.set(mxcUrl, cached);
return cached;
}
const httpUrl = mxcUrlToHttp(mx, mxcUrl, true);
if (!httpUrl) throw new Error('invalid mxc url');
const blob = await downloadMedia(httpUrl);
const objectUrl = URL.createObjectURL(blob);
if (objectUrlCache.size >= OBJECT_URL_CACHE_MAX) {
// Map preserves insertion order, so the first key is the least recently used.
const oldestKey = objectUrlCache.keys().next().value;
if (oldestKey !== undefined) {
const oldestUrl = objectUrlCache.get(oldestKey);
if (oldestUrl) URL.revokeObjectURL(oldestUrl);
objectUrlCache.delete(oldestKey);
}
}
objectUrlCache.set(mxcUrl, objectUrl);
return objectUrl;
};
/**
* [Gitea #57] Revoke every cached soundboard clip blob URL and empty the
* cache. Decrypted clip bytes must not stay reachable past logout — call this
* from every logout/clear-cache path alongside clearPlaintextCaches().
*/
export const clearSoundboardClipCache = (): void => {
objectUrlCache.forEach((objectUrl) => URL.revokeObjectURL(objectUrl));
objectUrlCache.clear();
};
/**
* Play a resolved clip locally so the person who pressed it gets immediate
* feedback — LiveKit doesn't loop a participant's own published track back to
* them, so without this the presser would hear nothing. `volume` is 0–1.
* Returns the audio element so callers can track when it ends (or undefined if
* playback couldn't start).
*/
export const playClipLocally = (
objectUrl: string,
volume: number,
): HTMLAudioElement | undefined => {
try {
const audio = new Audio(objectUrl);
audio.volume = Math.max(0, Math.min(1, volume));
audio.play().catch(() => undefined);
return audio;
} catch {
return undefined;
}
};
/** Read an audio file's duration in milliseconds from its metadata (no playback). */
export const getAudioDurationMs = (file: Blob): Promise<number | undefined> =>
new Promise((resolve) => {
const url = URL.createObjectURL(file);
const audio = new Audio();
audio.preload = 'metadata';
const done = (ms: number | undefined) => {
URL.revokeObjectURL(url);
resolve(ms);
};
audio.addEventListener('loadedmetadata', () =>
done(Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined),
);
audio.addEventListener('error', () => done(undefined));
audio.src = url;
});