diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index d0da73220..3edd1e86f 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -107,7 +107,7 @@ export const VideoContent = as<'div', VideoContentProps>( // support; a stale SW build would otherwise serve the bare URL to // the homeserver and the element would fail with a 4xx. if (!preferBlobRef.current && (await probeSWMediaAuthSupport())) return mediaUrl; - return createObjectURL(downloadMedia(mediaUrl)); + return createObjectURL(downloadMedia(mediaUrl, { forceDirectAuth: true })); } if (isTauri()) { await setMediaEncryption(mediaUrl, encInfo, mimeType); diff --git a/src/app/utils/mediaTransport.test.ts b/src/app/utils/mediaTransport.test.ts index c83a3babc..9d599313c 100644 --- a/src/app/utils/mediaTransport.test.ts +++ b/src/app/utils/mediaTransport.test.ts @@ -251,6 +251,48 @@ describe('fetchMediaBlob', () => { expect(mediaCache.putInMediaCache).toHaveBeenCalledTimes(1); }); + it('does not share an inflight SW request with forced direct auth', async () => { + swMediaAuth.getCachedSWMediaAuthSupport.mockReturnValue(true); + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id'; + const headersSeen: Array = []; + let resolveSWRequest!: (response: Response) => void; + const pendingSWRequest = new Promise((resolve) => { + resolveSWRequest = resolve; + }); + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'token-1', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@alice:example.org'); + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const authorization = new Headers(init?.headers).get('authorization'); + headersSeen.push(authorization); + if (authorization === null) return pendingSWRequest; + return new Response('direct', { status: 200 }); + }); + + const ordinaryRequest = fetchMediaBlob(url); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + + const directRequest = fetchMediaBlob(url, { forceDirectAuth: true }); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + await expect(directRequest).resolves.toHaveProperty('size', 6); + expect(headersSeen).toEqual([null, 'Bearer token-1']); + + resolveSWRequest(new Response('ordinary', { status: 200 })); + await expect(ordinaryRequest).resolves.toHaveProperty('size', 8); + }); + it('re-resolves auth once after a 401 in direct-fetch mode', async () => { const { fetchMediaBlob } = await import('./mediaTransport'); const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id'; @@ -397,6 +439,37 @@ describe('fetchMediaBlob', () => { expect(headersSeen).toEqual(['Bearer widget-token']); }); + it('bypasses the service worker path when direct auth is forced', async () => { + swMediaAuth.getCachedSWMediaAuthSupport.mockReturnValue(true); + const { fetchMediaBlob } = await import('./mediaTransport'); + const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id'; + const headersSeen: Array = []; + + localStorage.setItem( + 'matrixSessions', + JSON.stringify([ + { + baseUrl: 'https://matrix.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE', + accessToken: 'token-1', + }, + ]) + ); + localStorage.setItem('matrixActiveSession', '@alice:example.org'); + + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + headersSeen.push(headers.get('authorization')); + return new Response('ok', { status: 200 }); + }); + + const blob = await fetchMediaBlob(url, { forceDirectAuth: true }); + + expect(await blob.text()).toBe('ok'); + expect(headersSeen).toEqual(['Bearer token-1']); + }); + it('uses direct auth fetches when service workers are supported but not controlling', async () => { swMediaAuth.getCachedSWMediaAuthSupport.mockReturnValue(false); const { fetchMediaBlob } = await import('./mediaTransport'); diff --git a/src/app/utils/mediaTransport.ts b/src/app/utils/mediaTransport.ts index f47d7a3a5..e4a93b710 100644 --- a/src/app/utils/mediaTransport.ts +++ b/src/app/utils/mediaTransport.ts @@ -12,6 +12,7 @@ export type MediaFetchCacheMode = 'default' | 'reload' | 'bypass'; export type MediaTransportOptions = { cache?: MediaFetchCacheMode; + forceDirectAuth?: boolean; accessToken?: string | null; getAccessToken?: () => string | null | undefined; sessionScope?: string; @@ -179,8 +180,13 @@ function getFetchCacheMode(cacheMode: MediaFetchCacheMode): RequestCache { return 'default'; } -function getRequestKey(url: string, cacheMode: MediaFetchCacheMode): string { - return `${cacheMode}:${getStableMediaCacheKeyFragment(url)}`; +function getRequestKey( + url: string, + cacheMode: MediaFetchCacheMode, + forceDirectAuth: boolean +): string { + const transportMode = forceDirectAuth ? 'direct-auth' : 'default'; + return `${transportMode}:${cacheMode}:${getStableMediaCacheKeyFragment(url)}`; } type MatrixMediaInfo = { @@ -290,7 +296,9 @@ async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptio // Only let the service worker attach the token once it has proven media-auth // support; a stale SW build would forward the request bare and get a 4xx. const useServiceWorker = - getCachedSWMediaAuthSupport() === true && !hasExplicitMediaAuthOverride(options); + getCachedSWMediaAuthSupport() === true && + !hasExplicitMediaAuthOverride(options) && + !options?.forceDirectAuth; const fetchAndCache = async (response: Response): Promise => { if (!response.ok) { throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); @@ -351,7 +359,8 @@ export async function fetchMediaBlob(url: string, options?: MediaTransportOption const cacheMode = options?.cache ?? 'default'; const requestKey = getRequestKey( getScopedMediaCacheKey(url, resolveSessionScope(options)), - cacheMode + cacheMode, + options?.forceDirectAuth === true ); const inflight = inflightRequests.get(requestKey); diff --git a/src/app/utils/swMediaAuth.test.ts b/src/app/utils/swMediaAuth.test.ts index 922399a44..d4f2b2929 100644 --- a/src/app/utils/swMediaAuth.test.ts +++ b/src/app/utils/swMediaAuth.test.ts @@ -6,15 +6,17 @@ const platform = vi.hoisted(() => ({ vi.mock('$utils/platform', () => platform); -function stubServiceWorker(controller: unknown): void { +function stubServiceWorker(controller: unknown) { + const serviceWorker = { + controller, + addEventListener: vi.fn<(...args: unknown[]) => void>(), + removeEventListener: vi.fn<(...args: unknown[]) => void>(), + }; Object.defineProperty(navigator, 'serviceWorker', { configurable: true, - value: { - controller, - addEventListener: vi.fn<(...args: unknown[]) => void>(), - removeEventListener: vi.fn<(...args: unknown[]) => void>(), - }, + value: serviceWorker, }); + return serviceWorker; } describe('swMediaAuth', () => { @@ -24,6 +26,7 @@ describe('swMediaAuth', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -68,9 +71,75 @@ describe('swMediaAuth', () => { const mod = await import('./swMediaAuth'); await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(false); - expect(mod.getCachedSWMediaAuthSupport()).toBe(false); + expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined(); + }, 10_000); + + it('retries a timed-out probe for the same controller', async () => { + platform.hasServiceWorker.mockReturnValue(true); + let probeCount = 0; + const postMessage = vi.fn<(...args: unknown[]) => void>((...args: unknown[]) => { + probeCount += 1; + if (probeCount === 2) { + const [port] = args[1] as MessagePort[]; + port?.postMessage({ type: 'swMediaAuth', supported: true, version: 1 }); + } + }); + stubServiceWorker({ postMessage }); + const mod = await import('./swMediaAuth'); + + const firstProbe = mod.probeSWMediaAuthSupport(); + expect(postMessage).toHaveBeenCalledOnce(); + await expect(firstProbe).resolves.toBe(false); + expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined(); + + await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(true); + expect(postMessage).toHaveBeenCalledTimes(2); }, 10_000); + it('notifies unsupported while a replacement controller probe is unresolved', async () => { + platform.hasServiceWorker.mockReturnValue(true); + const firstController = { + postMessage: vi.fn<(...args: unknown[]) => void>((...args: unknown[]) => { + const [port] = args[1] as MessagePort[]; + port?.postMessage({ type: 'swMediaAuth', supported: true, version: 1 }); + }), + }; + const serviceWorker = stubServiceWorker(firstController); + const mod = await import('./swMediaAuth'); + const listener = vi.fn<(supported: boolean) => void>(); + mod.subscribeSWMediaAuthSupport(listener); + + await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(true); + listener.mockClear(); + vi.useFakeTimers(); + + serviceWorker.controller = { postMessage: vi.fn<() => void>() }; + const controllerChange = serviceWorker.addEventListener.mock.calls.find( + ([type]) => type === 'controllerchange' + )?.[1] as (() => void) | undefined; + controllerChange?.(); + + expect(controllerChange).toBeTypeOf('function'); + expect(listener).toHaveBeenCalledWith(false); + expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(1500); + expect(mod.getCachedSWMediaAuthSupport()).toBeUndefined(); + }); + + it('resolves false when posting the probe throws', async () => { + platform.hasServiceWorker.mockReturnValue(true); + stubServiceWorker({ + postMessage: vi.fn<() => void>(() => { + throw new Error('postMessage failed'); + }), + }); + const mod = await import('./swMediaAuth'); + + await expect(mod.probeSWMediaAuthSupport()).resolves.toBe(false); + expect(mod.getCachedSWMediaAuthSupport()).toBe(false); + }); + it('resolves false when posting to a stale controller throws', async () => { platform.hasServiceWorker.mockReturnValue(true); const postMessage = vi.fn<() => void>(() => { diff --git a/src/app/utils/swMediaAuth.ts b/src/app/utils/swMediaAuth.ts index 5d0279752..8526e5275 100644 --- a/src/app/utils/swMediaAuth.ts +++ b/src/app/utils/swMediaAuth.ts @@ -51,7 +51,8 @@ export function probeSWMediaAuthSupport(): Promise { probedController = controller; inflightProbe = new Promise((resolve) => { - const channel = new MessageChannel(); + let channel: MessageChannel | undefined; + let timeoutId: ReturnType | undefined; let settled = false; const onMessage = (ev: MessageEvent) => { const data = ev.data as { type?: unknown; supported?: unknown; version?: unknown }; @@ -62,55 +63,58 @@ export function probeSWMediaAuthSupport(): Promise { data.version >= SW_MEDIA_AUTH_PROTOCOL_VERSION ); }; - const timeoutId = setTimeout(() => finish(false), PROBE_TIMEOUT_MS); - - const finish = (supported: boolean) => { + const finish = (supported: boolean, shouldCache = true) => { if (settled) return; settled = true; - clearTimeout(timeoutId); - channel.port1.removeEventListener('message', onMessage); - channel.port1.close(); + if (timeoutId !== undefined) clearTimeout(timeoutId); + channel?.port1.removeEventListener('message', onMessage); + channel?.port1.close(); // Controller may have changed mid-probe; activation resets the cache and // starts a fresh probe, so don't clobber that state with a stale answer. if (navigator.serviceWorker.controller === controller) { - cachedSupport = supported; - notify(supported); + if (shouldCache) { + cachedSupport = supported; + notify(supported); + } } resolve(supported); }; - channel.port1.addEventListener('message', onMessage); - channel.port1.start(); - try { + channel = new MessageChannel(); + timeoutId = setTimeout(() => finish(false, false), PROBE_TIMEOUT_MS); + channel.port1.addEventListener('message', onMessage); + channel.port1.start(); + // oxlint-disable-next-line unicorn/require-post-message-target-origin controller.postMessage({ type: 'swMediaAuthProbe' }, [channel.port2]); } catch { // The controller can become redundant between reading it and posting the // probe. Treat that race like any other unsupported controller so callers // can use their authenticated blob fallback. - channel.port2.close(); + channel?.port2.close(); finish(false); } - }).finally(() => { - if (probedController === controller) { - inflightProbe = undefined; - } - }); + }) + .catch(() => false) + .finally(() => { + if (probedController === controller) { + inflightProbe = undefined; + } + }); return inflightProbe; } if (typeof window !== 'undefined' && typeof navigator !== 'undefined' && hasServiceWorker()) { navigator.serviceWorker.addEventListener('controllerchange', () => { - cachedSupport = undefined; + const { controller } = navigator.serviceWorker; + cachedSupport = controller ? undefined : false; inflightProbe = undefined; probedController = undefined; - if (navigator.serviceWorker.controller) { + notify(false); + if (controller) { void probeSWMediaAuthSupport(); - } else { - cachedSupport = false; - notify(false); } }); } diff --git a/src/sw-media-auth-recovery.test.ts b/src/sw-media-auth-recovery.test.ts index ee96d8f68..02b7067e1 100644 --- a/src/sw-media-auth-recovery.test.ts +++ b/src/sw-media-auth-recovery.test.ts @@ -12,13 +12,15 @@ type SwTestHooks = typeof swTestHooksHelper; describe('service worker media auth recovery', () => { let swTestHooks: SwTestHooks; let clients: Map; + let addEventListener: ReturnType; beforeEach(async () => { vi.resetModules(); clients = new Map(); + addEventListener = vi.fn(); vi.stubGlobal('self', { __WB_MANIFEST: [], - addEventListener: vi.fn(), + addEventListener, caches: { open: vi.fn(async () => ({ delete: vi.fn(async () => true), @@ -37,6 +39,22 @@ describe('service worker media auth recovery', () => { swTestHooks = (await import('./sw')).swTestHooks; }); + it('does not intercept media requests that already carry authorization', () => { + const fetchHandler = addEventListener.mock.calls.find(([type]) => type === 'fetch')?.[1] as + | ((event: FetchEvent) => void) + | undefined; + const respondWith = vi.fn(); + const request = new Request( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id', + { headers: { Authorization: 'Bearer direct-token' } } + ); + + fetchHandler?.({ request, respondWith, clientId: 'client-a' } as unknown as FetchEvent); + + expect(fetchHandler).toBeTypeOf('function'); + expect(respondWith).not.toHaveBeenCalled(); + }); + it('shares recovery and preserves each Range header on retry', async () => { const client = { id: 'client-a', diff --git a/src/sw.ts b/src/sw.ts index 3513150eb..5565b05f3 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -889,6 +889,10 @@ self.addEventListener('fetch', (event: FetchEvent) => { if (!mediaPath(url)) return; + // Direct-auth fallback requests already carry the page's token. Let the + // browser send them unchanged instead of routing them back through SW auth. + if (event.request.headers.has('Authorization')) return; + const { clientId } = event; // For browser sub-resource loads (images, video, audio, etc.), 'follow' is