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/Caddyfile b/Caddyfile index 63fec50e6..d32ef3bb9 100644 --- a/Caddyfile +++ b/Caddyfile @@ -16,6 +16,13 @@ try_files {path} /index.html + # Content-hashed assets can be cached indefinitely — the filename changes on every build. + header /assets/* Cache-Control "public, max-age=31536000, immutable" + # Workbox SW must be revalidated on every load so the browser picks up updates. + header /sw.js Cache-Control "no-cache" + # index.html must not be cached so the browser always gets the latest app shell. + header /index.html Cache-Control "no-cache" + # Required for Sentry browser profiling (JS Self-Profiling API) header Document-Policy "js-profiling" } 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 && ( { ); 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/client/initMatrix.ts b/src/client/initMatrix.ts index 093093ba2..c21043966 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 }); } @@ -538,13 +565,40 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): const v = slidingConfig?.probeTimeoutMs; return typeof v === 'number' && !Number.isNaN(v) && v > 0 ? Math.round(v) : 5000; })(); - const supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); + + // Cache successful probe results for 10 minutes to avoid a full network round-trip + // (300–5000ms) on every cold start. Only positive results are cached; a failed probe + // is always re-attempted next session to recover from transient outages. + const SS_PROBE_CACHE_KEY = `sable_ss_probe_v1_${resolvedProxyBaseUrl}`; + const SS_PROBE_CACHE_TTL_MS = 10 * 60 * 1000; + + let supported: boolean; + let probeFromCache = false; + try { + const raw = localStorage.getItem(SS_PROBE_CACHE_KEY); + const cached = raw ? (JSON.parse(raw) as { result: boolean; ts: number }) : null; + if (cached?.result === true && Date.now() - cached.ts < SS_PROBE_CACHE_TTL_MS) { + supported = true; + probeFromCache = true; + } else { + supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); + if (supported) { + localStorage.setItem(SS_PROBE_CACHE_KEY, JSON.stringify({ result: true, ts: Date.now() })); + } else { + localStorage.removeItem(SS_PROBE_CACHE_KEY); + } + } + } catch { + supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); + } + log.log('startClient sliding probe result', { userId: mx.getUserId(), requestedEnabled: slidingRequested, hasSlidingProxy, proxyBaseUrl: resolvedProxyBaseUrl, supported, + probeFromCache, }); if (!supported) { log.warn('Sliding Sync unavailable, falling back to classic sync for', mx.getUserId()); diff --git a/src/index.tsx b/src/index.tsx index 4f2e57245..686ed0bf7 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) @@ -157,10 +163,20 @@ window.addEventListener('error', (event) => { // Increment retry count and reload sessionStorage.setItem(CHUNK_RETRY_KEY, String(retryCount + 1)); log.warn(`Chunk load failed, reloading (attempt ${retryCount + 1}/${MAX_CHUNK_RETRIES})`); - window.location.reload(); - // Prevent default error handling since we're reloading + // Prevent default error handling synchronously before any async work event.preventDefault(); + + // If the SW is not yet controlling the page (dead after force-kill), wait for + // it to activate so the retry is served from the precache, not the network. + // This eliminates the white-screen flash caused by a blank reload mid-SW-init. + if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) { + navigator.serviceWorker.ready + .then(() => window.location.reload()) + .catch(() => window.location.reload()); + } else { + window.location.reload(); + } } else { // Max retries exceeded, clear counter and let error bubble up sessionStorage.removeItem(CHUNK_RETRY_KEY); diff --git a/src/sw.ts b/src/sw.ts index bd09cd8d3..d904dbf58 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); }) @@ -705,17 +826,30 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } + // Synchronous fast-path: check in-memory sessions by baseUrl and the + // preloaded session before paying the 3-second requestSessionWithTimeout + // cost. This restores the old byBaseUrl behaviour while keeping retry logic. + const syncByBaseUrl = getMatchingSessions(url); + if (syncByBaseUrl.length === 1) { + event.respondWith(fetchMediaWithRetry(url, syncByBaseUrl[0].accessToken, redirect, clientId)); + return; + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + event.respondWith(fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId)); + return; + } + event.respondWith( 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); } console.warn( '[SW fetch] No valid session for media request', @@ -902,7 +1036,5 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { ); }); -if (self.__WB_MANIFEST) { - precacheAndRoute(self.__WB_MANIFEST); -} +precacheAndRoute(self.__WB_MANIFEST); cleanupOutdatedCaches(); diff --git a/vite.config.ts b/vite.config.ts index a130df507..9b32ceac9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -176,7 +176,11 @@ export default defineConfig(({ command }) => ({ injectRegister: false, manifest: false, injectManifest: { - injectionPoint: undefined, + // element-call is a self-contained embedded app; exclude its large assets + // from the SW precache manifest (they are not part of the Sable shell). + globIgnores: ['public/element-call/**'], + // The app's own crypto WASM and main bundle exceed the 2 MiB default. + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10 MiB }, devOptions: { enabled: true,