diff --git a/.changeset/fix-media-error-handling.md b/.changeset/fix-media-error-handling.md new file mode 100644 index 000000000..eec3c4fd9 --- /dev/null +++ b/.changeset/fix-media-error-handling.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix intermittent 401 errors when loading media after service worker restart. Service worker now expires cached authentication tokens after 60 seconds, forcing fresh token retrieval from the active page instead of using potentially stale persisted tokens. diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index ad51d2a4f..af673adc8 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -33,8 +33,22 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '$utils/matrix import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { ModalWide } from '$styles/Modal.css'; import { validBlurHash } from '$utils/blurHash'; +import { createDebugLogger } from '$utils/debugLogger'; import * as css from './style.css'; +const debugLog = createDebugLogger('ImageContent'); + +const addCacheBuster = (inputUrl: string): string => { + try { + const parsed = new URL(inputUrl); + parsed.searchParams.set('_sable_retry', String(Date.now())); + return parsed.toString(); + } catch { + const join = inputUrl.includes('?') ? '&' : '?'; + return `${inputUrl}${join}_sable_retry=${Date.now()}`; + } +}; + type RenderViewerProps = { src: string; alt: string; @@ -89,36 +103,96 @@ export const ImageContent = as<'div', ImageContentProps>( const [viewer, setViewer] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [isHovered, setIsHovered] = useState(false); + const [didForceRemoteRetry, setDidForceRemoteRetry] = useState(false); const [srcState, loadSrc] = useAsyncCallback( - useCallback(async () => { - if (url.startsWith('http')) return url; + useCallback( + async (forceRemote = false) => { + if (url.startsWith('http')) return forceRemote ? addCacheBuster(url) : url; - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); - if (!mediaUrl) throw new Error('Invalid media URL'); - if (encInfo) { - const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => - decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) - ); - return URL.createObjectURL(fileContent); - } - return mediaUrl; - }, [mx, url, useAuthentication, mimeType, encInfo]) + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); + const resolvedUrl = forceRemote ? addCacheBuster(mediaUrl) : mediaUrl; + if (encInfo) { + const fileContent = await downloadEncryptedMedia(resolvedUrl, (encBuf) => + decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) + ); + return URL.createObjectURL(fileContent); + } + return resolvedUrl; + }, + [mx, url, useAuthentication, mimeType, encInfo] + ) ); const handleLoad = () => { setLoad(true); + if (didForceRemoteRetry) { + debugLog.info('network', 'Image loaded after retry', { + forcedRemoteRetry: true, + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + } }; const handleError = () => { setLoad(false); setError(true); + if (didForceRemoteRetry) { + debugLog.warn('network', 'Image still failed after retry', { + forcedRemoteRetry: true, + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + } }; const handleRetry = () => { setError(false); - loadSrc(); + const forceRemote = !didForceRemoteRetry; + debugLog.info('network', 'Image retry requested', { + forceRemote, + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + if (forceRemote) { + setDidForceRemoteRetry(true); + loadSrc(true) + .then(() => { + debugLog.info('network', 'Forced remote retry source resolved', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + }) + .catch((err) => { + debugLog.warn('network', 'Forced remote retry source failed', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + loadSrc() + .then(() => { + debugLog.info('network', 'Standard retry source resolved', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + }) + .catch((err) => { + debugLog.warn('network', 'Standard retry source failed', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + error: err instanceof Error ? err.message : String(err), + }); + }); }; + useEffect(() => { + setDidForceRemoteRetry(false); + }, [url]); + useEffect(() => { if (autoPlay) loadSrc(); }, [autoPlay, loadSrc]); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 3ac780f74..a29ed629c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { IPreviewUrlResponse } from '$types/matrix-sdk'; +import { IPreviewUrlResponse, MatrixError } from '$types/matrix-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -20,9 +20,9 @@ const linkStyles = { color: color.Success.Main }; // Inner cache keyed by URL only (not ts) — the same URL shows the same preview // regardless of which message referenced it. Promises are evicted after settling // so a later render can retry after network recovery. -const previewRequestCache = new WeakMap>>(); +const previewRequestCache = new WeakMap>>(); -const getClientCache = (mx: any): Map> => { +const getClientCache = (mx: any): Map> => { let clientCache = previewRequestCache.get(mx); if (!clientCache) { clientCache = new Map(); @@ -31,6 +31,24 @@ const getClientCache = (mx: any): Map> => { return clientCache; }; +const normalizePreviewUrl = (input: string): string => { + const trimmed = input.trim().replace(/^<+/, '').replace(/>+$/, ''); + + try { + const parsed = new URL(trimmed); + parsed.pathname = parsed.pathname.replace(/(?:%60|`)+$/gi, ''); + return parsed.toString(); + } catch { + // Keep the original-ish value; URL preview fetch will fail gracefully. + return trimmed.replace(/(?:%60|`)+$/gi, ''); + } +}; + +const isIgnorablePreviewError = (error: unknown): boolean => { + if (!(error instanceof MatrixError)) return false; + return error.httpStatus === 404 || error.httpStatus === 502; +}; + const openMediaInNewTab = async (url: string | undefined) => { if (!url) { console.warn('Attempted to open an empty url'); @@ -45,25 +63,35 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s ({ url, ts, mediaType, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const previewUrl = normalizePreviewUrl(url); const isDirect = !!mediaType; const [previewStatus, loadPreview] = useAsyncCallback( - useCallback(() => { + useCallback(async () => { if (isDirect) return Promise.resolve(null); const clientCache = getClientCache(mx); - const cached = clientCache.get(url); + const cached = clientCache.get(previewUrl); if (cached !== undefined) return cached; - const urlPreview = mx.getUrlPreview(url, ts); - clientCache.set(url, urlPreview); - urlPreview.finally(() => clientCache.delete(url)); + + const urlPreview = (async () => { + try { + return await mx.getUrlPreview(previewUrl, ts); + } catch (error) { + if (isIgnorablePreviewError(error)) return null; + throw error; + } + })(); + + clientCache.set(previewUrl, urlPreview); + urlPreview.finally(() => clientCache.delete(previewUrl)); return urlPreview; - }, [url, ts, mx, isDirect]) + }, [previewUrl, ts, mx, isDirect]) ); useEffect(() => { loadPreview(); - }, [url, loadPreview]); + }, [previewUrl, loadPreview]); if (previewStatus.status === AsyncStatus.Error) return null; @@ -117,14 +145,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s style={linkStyles} truncate as="a" - href={url} + href={previewUrl} target="_blank" rel="noreferrer" size="T200" priority="300" > {typeof siteName === 'string' && `${siteName} | `} - {safeDecodeUrl(url)} + {safeDecodeUrl(previewUrl)} {title && ( @@ -216,13 +244,13 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s style={linkStyles} truncate as="a" - href={url} + href={previewUrl} target="_blank" rel="noreferrer" size="T200" priority="300" > - {safeDecodeUrl(url)} + {safeDecodeUrl(previewUrl)} ); diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index b717f2261..d786159b3 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; -import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; +import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button, Spinner, color } from 'folds'; +import { KnownMembership } from 'matrix-js-sdk/lib/types'; import { Page, PageContent, PageHeader } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -9,6 +10,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; @@ -23,6 +25,33 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); + const [rotateState, rotateAllSessions] = useAsyncCallback< + { rotated: number; total: number }, + Error, + [] + >( + useCallback(async () => { + const crypto = mx.getCrypto(); + if (!crypto) throw new Error('Crypto module not available'); + + const encryptedRooms = mx + .getRooms() + .filter( + (room) => + room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId) + ); + + await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId))); + const rotated = encryptedRooms.length; + + // Proactively start session creation + key sharing with all devices + // (including bridge bots). fire-and-forget per room. + encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); + + return { rotated, total: encryptedRooms.length }; + }, [mx]) + ); + const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { // TODO: remove cast once account data typing is unified. @@ -115,6 +144,56 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { )} {developerTools && } + {developerTools && ( + + Encryption + + + ) + } + > + + {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'} + + + } + > + {rotateState.status === AsyncStatus.Success && ( + + Sessions discarded for {rotateState.data.rotated} of{' '} + {rotateState.data.total} encrypted rooms. Key sharing is starting in the + background — send a message in an affected room to confirm delivery to + bridges. + + )} + {rotateState.status === AsyncStatus.Error && ( + + {rotateState.error.message} + + )} + + + + )} {developerTools && ( ('m.thread')?.count; + const replyCount = bundledCount ?? thread.length ?? 0; if (replyCount === 0) return null; const uniqueSenders: string[] = []; diff --git a/src/app/hooks/useAsyncCallback.test.tsx b/src/app/hooks/useAsyncCallback.test.tsx index c27d06478..c6e3f45ce 100644 --- a/src/app/hooks/useAsyncCallback.test.tsx +++ b/src/app/hooks/useAsyncCallback.test.tsx @@ -30,7 +30,7 @@ describe('useAsyncCallback', () => { ); await act(async () => { - await result.current[1]().catch(() => {}); + await expect(result.current[1]()).rejects.toBe(boom); }); expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom }); diff --git a/src/app/hooks/useAsyncCallback.ts b/src/app/hooks/useAsyncCallback.ts index 70831bea1..26e6ee16b 100644 --- a/src/app/hooks/useAsyncCallback.ts +++ b/src/app/hooks/useAsyncCallback.ts @@ -73,6 +73,9 @@ export const useAsync = ( }); }); } + // Re-throw so .then()/.catch() callers see the rejection and success + // handlers are skipped. Fire-and-forget unhandled-rejection warnings are + // suppressed at the useAsyncCallback level via a no-op .catch wrapper. throw e; } @@ -102,7 +105,19 @@ export const useAsyncCallback = ( status: AsyncStatus.Idle, }); - const callback = useAsync(asyncCallback, setState); + const innerCallback = useAsync(asyncCallback, setState); + + // Re-throw preserves rejection for callers that await/chain; the no-op .catch + // suppresses "Uncaught (in promise)" for fire-and-forget call sites (e.g. + // loadSrc() in a useEffect) without swallowing the error from intentional callers. + const callback = useCallback( + (...args: TArgs): Promise => { + const p = innerCallback(...args); + p.catch(() => {}); + return p; + }, + [innerCallback] + ) as AsyncCallback; return [state, callback, setState]; }; diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 81371a614..624e39a25 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -156,6 +156,8 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { userId: loginRes.user_id, deviceId: loginRes.device_id, accessToken: loginRes.access_token, + ...(loginRes.refresh_token != null && { refreshToken: loginRes.refresh_token }), + ...(loginRes.expires_in_ms != null && { expiresInMs: loginRes.expires_in_ms }), }; setSessions({ type: 'PUT', session: newSession }); setActiveSessionId(loginRes.user_id); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1a653e950..d8b3a3dae 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -201,11 +201,21 @@ export function ClientRoot({ children }: ClientRootProps) { } await clearMismatchedStores(); log.log('initClient for', activeSession.userId); - const newMx = await initClient(activeSession); + const newMx = await initClient(activeSession, (newAccessToken, newRefreshToken) => { + setSessions({ + type: 'PUT', + session: { + ...activeSession, + accessToken: newAccessToken, + ...(newRefreshToken !== undefined && { refreshToken: newRefreshToken }), + }, + }); + pushSessionToSW(activeSession.baseUrl, newAccessToken, activeSession.userId); + }); loadedUserIdRef.current = activeSession.userId; - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); return newMx; - }, [activeSession, activeSessionId, setActiveSessionId]) + }, [activeSession, activeSessionId, setActiveSessionId, setSessions]) ); const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined; @@ -232,7 +242,7 @@ export function ClientRoot({ children }: ClientRootProps) { activeSession.userId, '— reloading client' ); - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); if (mx?.clientRunning) { stopClient(mx); } diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index 0934e5b02..621dc6409 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -1,4 +1,10 @@ -import { Direction, EventTimeline, MatrixEvent, Room } from '$types/matrix-sdk'; +import { + Direction, + EventTimeline, + IThreadBundledRelationship, + MatrixEvent, + Room, +} from '$types/matrix-sdk'; import { roomHaveNotification, roomHaveUnread, reactionOrEditEvent } from '$utils/room'; export const PAGINATION_LIMIT = 60; @@ -151,7 +157,11 @@ export const getRoomUnreadInfo = (room: Room, scrollTo = false) => { export const getThreadReplyCount = (room: Room, mEventId: string): number => { const thread = room.getThread(mEventId); - if (thread) return thread.length; + if (thread) { + const bundledCount = + thread.rootEvent?.getServerAggregatedRelation('m.thread')?.count; + return bundledCount ?? thread.length; + } const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); return linkedTimelines.reduce((acc, tl) => { diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 093093ba2..ffefb3ad5 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -267,7 +267,10 @@ export const clearMismatchedStores = async (): Promise => { ); }; -const buildClient = async (session: Session): Promise => { +const buildClient = async ( + session: Session, + onTokenRefresh?: (newAccessToken: string, newRefreshToken?: string) => void +): Promise => { const storeName = getSessionStoreName(session); const indexedDBStore = new IndexedDBStore({ @@ -278,6 +281,11 @@ const buildClient = async (session: Session): Promise => { const legacyCryptoStore = new IndexedDBCryptoStore(global.indexedDB, storeName.crypto); + // Pre-allocate a slot for the MatrixClient reference used by tokenRefreshFunction. + // The refresh function is only ever invoked after startClient, so mxRef is always + // assigned before it is called. + let mxRef!: MatrixClient; + const mx = createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, @@ -288,13 +296,32 @@ const buildClient = async (session: Session): Promise => { timelineSupport: true, cryptoCallbacks: cryptoCallbacks as any, verificationMethods: ['m.sas.v1'], + ...(session.refreshToken && { + refreshToken: session.refreshToken, + tokenRefreshFunction: async (oldRefreshToken: string) => { + const res = await mxRef.refreshToken(oldRefreshToken); + onTokenRefresh?.(res.access_token, res.refresh_token); + return { + accessToken: res.access_token, + refreshToken: res.refresh_token ?? oldRefreshToken, + expiry: + typeof res.expires_in_ms === 'number' + ? new Date(Date.now() + res.expires_in_ms) + : undefined, + }; + }, + }), }); + mxRef = mx; await indexedDBStore.startup(); return mx; }; -export const initClient = async (session: Session): Promise => { +export const initClient = async ( + session: Session, + onTokenRefresh?: (newAccessToken: string, newRefreshToken?: string) => void +): Promise => { const storeName = getSessionStoreName(session); debugLog.info('sync', 'Initializing Matrix client', { userId: session.userId, @@ -338,7 +365,7 @@ export const initClient = async (session: Session): Promise => { let mx: MatrixClient; try { - mx = await buildClient(session); + mx = await buildClient(session, onTokenRefresh); } catch (err) { if (!isMismatch(err)) { debugLog.error('sync', 'Failed to build client', { error: err }); @@ -347,7 +374,7 @@ export const initClient = async (session: Session): Promise => { log.warn('initClient: mismatch on buildClient — wiping and retrying:', err); debugLog.warn('sync', 'Client build mismatch - wiping stores and retrying', { error: err }); await wipeAllStores(); - mx = await buildClient(session); + mx = await buildClient(session, onTokenRefresh); } try { @@ -361,7 +388,7 @@ export const initClient = async (session: Session): Promise => { debugLog.warn('sync', 'Crypto init mismatch - wiping stores and retrying', { error: err }); mx.stopClient(); await wipeAllStores(); - mx = await buildClient(session); + mx = await buildClient(session, onTokenRefresh); await mx.initRustCrypto({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); } diff --git a/src/index.tsx b/src/index.tsx index 4f2e57245..5a8496c4d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -83,6 +83,12 @@ if ('serviceWorker' in navigator) { pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); }; + // Push the session synchronously before React renders so the SW has a fresh + // token before any fetch events arrive. If navigator.serviceWorker.controller + // is already set (normal page reload), this eliminates the race where + // preloadedSession (potentially stale) would be used for early thumbnail fetches. + sendSessionToSW(); + navigator.serviceWorker .register(swUrl) .then(sendSessionToSW) diff --git a/src/sw.ts b/src/sw.ts index bd09cd8d3..1b4233add 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -69,9 +69,12 @@ async function loadPersistedSettings() { async function persistSession(session: SessionInfo): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); + const sessionWithTimestamp = { ...session, persistedAt: Date.now() }; await cache.put( SW_SESSION_URL, - new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify(sessionWithTimestamp), { + headers: { 'Content-Type': 'application/json' }, + }) ); } catch { // Ignore — caches may be unavailable in some environments. @@ -91,13 +94,28 @@ async function loadPersistedSession(): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); const response = await cache.match(SW_SESSION_URL); - if (!response) return undefined; - const s = await response.json(); - if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + if (response) { + const s = await response.json(); + + // Reject persisted sessions older than 60 seconds to avoid using stale tokens. + // On iOS, the SW can be killed before persistSession completes, leaving a stale + // token in cache. By rejecting old sessions, we force the SW to wait for a fresh + // token from the live page via requestSession. + const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; + const MAX_SESSION_AGE_MS = 60000; // 60 seconds + if (age > MAX_SESSION_AGE_MS) { + console.debug('[SW] loadPersistedSession: session expired', { + age, + accessToken: s.accessToken.slice(0, 8), + }); + return undefined; + } + return { accessToken: s.accessToken, baseUrl: s.baseUrl, userId: typeof s.userId === 'string' ? s.userId : undefined, + persistedAt: s.persistedAt, }; } return undefined; @@ -111,6 +129,8 @@ type SessionInfo = { baseUrl: string; /** Matrix user ID of the account, used to identify which account a push belongs to. */ userId?: string; + /** Timestamp when this session was persisted to cache, used to expire stale tokens. */ + persistedAt?: number; }; /** @@ -555,6 +575,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setSession') { setSession(client.id, accessToken, baseUrl, userId); + // Keep the SW alive until the cache write completes. persistSession is + // called fire-and-forget inside setSession; without waitUntil the browser + // can kill the SW before caches.put resolves, leaving the persisted session + // stale on the next restart and causing intermittent 401s on media fetches. + const persisted = sessions.get(client.id); + event.waitUntil( + (persisted ? persistSession(persisted) : clearPersistedSession()).catch(() => undefined) + ); event.waitUntil(cleanupDeadClients()); } if (type === 'pushDecryptResult') { @@ -604,12 +632,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const MEDIA_PATHS = [ '/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail', + '/_matrix/client/v1/media/preview_url', + '/_matrix/client/v3/media/download', + '/_matrix/client/v3/media/thumbnail', + '/_matrix/client/v3/media/preview_url', + '/_matrix/client/r0/media/download', + '/_matrix/client/r0/media/thumbnail', + '/_matrix/client/r0/media/preview_url', + '/_matrix/client/unstable/org.matrix.msc3916/media/download', + '/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail', + '/_matrix/client/unstable/org.matrix.msc3916/media/preview_url', // Legacy unauthenticated endpoints — servers that require auth return 404/403 // for these when no token is present, so intercept and add auth here too. '/_matrix/media/v3/download', '/_matrix/media/v3/thumbnail', + '/_matrix/media/v3/preview_url', '/_matrix/media/r0/download', '/_matrix/media/r0/thumbnail', + '/_matrix/media/r0/preview_url', ]; function mediaPath(url: string): boolean { @@ -628,6 +668,39 @@ function validMediaRequest(url: string, baseUrl: string): boolean { }); } +function getMatchingSessions(url: string): SessionInfo[] { + return [...sessions.values()].filter((s) => validMediaRequest(url, s.baseUrl)); +} + +function isAuthFailureStatus(status: number): boolean { + return status === 401 || status === 403; +} + +async function getLiveWindowSessions(url: string, clientId: string): Promise { + const collected: SessionInfo[] = []; + const seen = new Set(); + const add = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seen.has(key)) return; + seen.add(key); + collected.push(session); + }; + + if (clientId) { + add(await requestSessionWithTimeout(clientId, 1500)); + return collected; + } + + const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const liveSessions = await Promise.all( + windowClients.map((client) => requestSessionWithTimeout(client.id, 750)) + ); + liveSessions.forEach((session) => add(session)); + + return collected; +} + function fetchConfig(token: string): RequestInit { return { headers: { @@ -637,6 +710,67 @@ function fetchConfig(token: string): RequestInit { }; } +/** + * Fetch a media URL, retrying once with the most-current in-memory session on 401. + * + * There is a timing window between when the SDK refreshes its access token + * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW() + * postMessage is processed by the SW. Media requests that land in this window + * are sent with the stale token and receive 401. By the time the retry runs, + * the setSession message will normally have been processed and sessions will + * hold the new token. + * + * A second timing window exists at startup: preloadedSession may hold a stale + * token but the live setSession from the page hasn't arrived yet. In that case + * the in-memory check yields no fresher token, so we ask the live client tab + * directly (requestSessionWithTimeout) before giving up. + */ +async function fetchMediaWithRetry( + url: string, + token: string, + redirect: RequestRedirect, + clientId: string +): Promise { + let response = await fetch(url, { ...fetchConfig(token), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + + const attemptedTokens = new Set([token]); + const retrySessions: SessionInfo[] = []; + const seenSessions = new Set(); + + const addRetrySession = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seenSessions.has(key)) return; + seenSessions.add(key); + retrySessions.push(session); + }; + + if (clientId) addRetrySession(sessions.get(clientId)); + getMatchingSessions(url).forEach((session) => addRetrySession(session)); + addRetrySession(preloadedSession); + addRetrySession(await loadPersistedSession()); + (await getLiveWindowSessions(url, clientId)).forEach((session) => addRetrySession(session)); + + // Try each plausible token once. This handles token-refresh races and ambiguous + // multi-account sessions on the same homeserver, including no-clientId requests. + // Sequential await is intentional: we want to try one token at a time until one succeeds. + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < retrySessions.length; i += 1) { + const candidate = retrySessions[i]; + if (!candidate || attemptedTokens.has(candidate.accessToken)) { + // skip this candidate + } else { + attemptedTokens.add(candidate.accessToken); + response = await fetch(url, { ...fetchConfig(candidate.accessToken), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + } + } + /* eslint-enable no-await-in-loop */ + + return response; +} + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -667,37 +801,24 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); - return; - } - - // Since widgets like element call have their own client ids, - // we need this logic. We just go through the sessions list and get a session - // with the right base url. Media requests to a homeserver simply are fine with any account - // on the homeserver authenticating it, so this is fine. But it can be technically wrong. - // If you have two tabs for different users on the same homeserver, it might authenticate - // as the wrong one. - // Thus any logic in the future which cares about which user is authenticating the request - // might break this. Also, again, it is technically wrong. - // Also checks preloadedSession — populated from cache at SW activate — for the window - // between SW restart and the first live setSession arriving from the page. - const byBaseUrl = - [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ?? - (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl) - ? preloadedSession - : undefined); - if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithRetry(url, session.accessToken, redirect, clientId)); return; } // No clientId: the fetch came from a context not associated with a specific - // window (e.g. a prerender). Fall back to the persisted session directly. + // window (e.g. a prerender). Fall back to persisted/unique-by-baseUrl sessions. if (!clientId) { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, ''); + } + const matching = getMatchingSessions(url); + if (matching.length === 1) { + return fetchMediaWithRetry(url, matching[0].accessToken, redirect, ''); + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, ''); } return fetch(event.request); }) @@ -709,13 +830,23 @@ self.addEventListener('fetch', (event: FetchEvent) => { requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithRetry(url, s.accessToken, redirect, clientId); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, clientId); + } + // Last resort: only use by-baseUrl auth if there is exactly one matching + // session. Multiple matches are ambiguous and can intermittently 401 if + // we pick the wrong account token. + const matching = getMatchingSessions(url); + if (matching.length === 1) { + return fetchMediaWithRetry(url, matching[0].accessToken, redirect, clientId); + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId); } console.warn( '[SW fetch] No valid session for media request',