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
103 lines
4.0 KiB
TypeScript
103 lines
4.0 KiB
TypeScript
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;
|
||
});
|