Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-media-error-handling.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
100 changes: 87 additions & 13 deletions src/app/components/message/content/ImageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down
56 changes: 42 additions & 14 deletions src/app/components/url-preview/UrlPreviewCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,9 +20,9 @@
// 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<any, Map<string, Promise<IPreviewUrlResponse>>>();
const previewRequestCache = new WeakMap<any, Map<string, Promise<IPreviewUrlResponse | null>>>();

const getClientCache = (mx: any): Map<string, Promise<IPreviewUrlResponse>> => {
const getClientCache = (mx: any): Map<string, Promise<IPreviewUrlResponse | null>> => {
let clientCache = previewRequestCache.get(mx);
if (!clientCache) {
clientCache = new Map();
Expand All @@ -31,9 +31,27 @@
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');

Check warning on line 54 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
const blob = await downloadMedia(url);
Expand All @@ -45,25 +63,35 @@
({ 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;

Expand All @@ -82,14 +110,14 @@
);
const handleAuxClick = (ev: React.MouseEvent) => {
if (!prev['og:image']) {
console.warn('No image');

Check warning on line 113 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
if (ev.button === 1) {
ev.preventDefault();
const mxcUrl = mxcUrlToHttp(mx, prev['og:image'], /* useAuthentication */ true);
if (!mxcUrl) {
console.error('Error converting mxc:// url.');

Check warning on line 120 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
openMediaInNewTab(mxcUrl);
Expand Down Expand Up @@ -117,14 +145,14 @@
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)}
</Text>
{title && (
<Text truncate priority="400">
Expand Down Expand Up @@ -216,13 +244,13 @@
style={linkStyles}
truncate
as="a"
href={url}
href={previewUrl}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{safeDecodeUrl(url)}
{safeDecodeUrl(previewUrl)}
</Text>
</UrlPreviewContent>
);
Expand Down
81 changes: 80 additions & 1 deletion src/app/features/settings/developer-tools/DevelopTools.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -23,6 +25,33 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();

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.
Expand Down Expand Up @@ -115,6 +144,56 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
)}
</Box>
{developerTools && <SyncDiagnostics />}
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Encryption</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Rotate Encryption Sessions"
description="Discard current Megolm sessions and begin sharing new keys with all room members. Key delivery happens in the background — send a message in each affected room to confirm the bridge has received the new keys."
after={
<Button
onClick={rotateAllSessions}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={rotateState.status === AsyncStatus.Loading}
before={
rotateState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Secondary" />
)
}
>
<Text size="B300">
{rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'}
</Text>
</Button>
}
>
{rotateState.status === AsyncStatus.Success && (
<Text size="T200" style={{ color: color.Success.Main }}>
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.
</Text>
)}
{rotateState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{rotateState.error.message}
</Text>
)}
</SettingTile>
</SequenceCard>
</Box>
)}
{developerTools && (
<AccountData
expand={expand}
Expand Down
2 changes: 1 addition & 1 deletion src/app/hooks/useAsyncCallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading
Loading