Fix authenticated media loading for Tauri/WebKit environments

- Add useAuthenticatedMediaUrl hook that fetches media with auth headers
- Add AuthenticatedMedia, AuthenticatedAvatarImage components
- Update downloadMedia and downloadEncryptedMedia to accept access token
- Update all media components to pass access token for auth media
- Fix 401 errors on Matrix servers requiring authenticated media (v1.11+)

This fixes media loading in Tauri desktop apps where service workers
may not work reliably for cross-origin authenticated requests.
This commit is contained in:
2026-01-23 22:31:37 +11:00
parent 94f8466d1c
commit 54f5fa95a6
19 changed files with 330 additions and 38 deletions

View File

@@ -0,0 +1,22 @@
import React, { ComponentProps, forwardRef } from 'react';
import { AvatarImage } from 'folds';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
export type AuthenticatedAvatarImageProps = ComponentProps<typeof AvatarImage>;
/**
* AvatarImage component that handles Matrix authenticated media.
* Automatically fetches authenticated media URLs with the proper Authorization header
* and renders them as blob URLs.
*/
export const AuthenticatedAvatarImage = forwardRef<HTMLImageElement, AuthenticatedAvatarImageProps>(
({ src, ...props }, ref) => {
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
return <AvatarImage ref={ref} {...props} src={authenticatedSrc} />;
}
);
AuthenticatedAvatarImage.displayName = 'AuthenticatedAvatarImage';

View File

@@ -0,0 +1,45 @@
import React, { ImgHTMLAttributes, forwardRef, useEffect, useState } from 'react';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
export interface AuthenticatedMediaProps
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
/** The source URL of the media (may be an authenticated media URL) */
src?: string;
/** Override for authentication - if not provided, uses useMediaAuthentication hook */
useAuthentication?: boolean;
}
/**
* Image component that handles Matrix authenticated media.
* Automatically fetches authenticated media URLs with the proper Authorization header
* and renders them as blob URLs.
*/
export const AuthenticatedMedia = forwardRef<HTMLImageElement, AuthenticatedMediaProps>(
({ src, useAuthentication: useAuthOverride, ...props }, ref) => {
const useAuthFromServer = useMediaAuthentication();
const useAuthentication = useAuthOverride ?? useAuthFromServer;
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
return <img ref={ref} {...props} src={authenticatedSrc} />;
}
);
AuthenticatedMedia.displayName = 'AuthenticatedMedia';
/**
* A simpler authenticated img component for use in places like custom HTML parsers.
* This component handles its own authentication state.
*/
export interface AuthenticatedImgProps extends ImgHTMLAttributes<HTMLImageElement> {}
export const AuthenticatedImg = forwardRef<HTMLImageElement, AuthenticatedImgProps>(
({ src, ...props }, ref) => {
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
return <img ref={ref} {...props} src={authenticatedSrc} />;
}
);
AuthenticatedImg.displayName = 'AuthenticatedImg';

View File

@@ -0,0 +1,4 @@
export { AuthenticatedMedia, AuthenticatedImg } from './AuthenticatedMedia';
export type { AuthenticatedMediaProps, AuthenticatedImgProps } from './AuthenticatedMedia';
export { AuthenticatedAvatarImage } from './AuthenticatedAvatarImage';
export type { AuthenticatedAvatarImageProps } from './AuthenticatedAvatarImage';

View File

@@ -3,7 +3,6 @@ import {
Box,
Text,
Avatar,
AvatarImage,
AvatarFallback,
Button,
Icon,
@@ -25,6 +24,7 @@ import { createUploadAtom, UploadSuccess } from '../../state/upload';
import { CompactUploadCardRenderer } from '../upload-card';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { PackMetaReader } from '../../plugins/custom-emoji';
import { AuthenticatedAvatarImage } from '../authenticated-media';
type ImagePackAvatarProps = {
url?: string;
@@ -34,7 +34,7 @@ function ImagePackAvatar({ url, name }: ImagePackAvatarProps) {
return (
<Avatar size="500" className={ContainerColor({ variant: 'Secondary' })}>
{url ? (
<AvatarImage src={url} alt={name ?? 'Unknown'} />
<AuthenticatedAvatarImage src={url} alt={name ?? 'Unknown'} />
) : (
<AvatarFallback>
<Text size="H2">{nameInitials(name ?? 'Unknown')}</Text>

View File

@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
export type ImageViewerProps = {
alt: string;
@@ -16,11 +17,12 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
const fileContent = await downloadMedia(src);
const fileContent = await downloadMedia(src, mx.getAccessToken());
FileSaver.saveAs(fileContent, alt);
};

View File

@@ -28,9 +28,10 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);

View File

@@ -55,9 +55,10 @@ export function AudioContent({
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);

View File

@@ -87,9 +87,10 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
const [textState, loadText] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
const text = fileContent.text();
setTextViewer(true);
@@ -177,9 +178,10 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
setPdfViewer(true);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
@@ -254,9 +256,10 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body);

View File

@@ -32,6 +32,32 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
/**
* Fetches media with authentication headers and returns a blob URL.
* This is needed because service workers don't work reliably in Tauri/WebKit.
*/
const fetchAuthenticatedMedia = async (
url: string,
accessToken: string | null
): Promise<string> => {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
};
/**
* Checks if a URL is an authenticated media endpoint.
*/
const isAuthenticatedMediaUrl = (url: string): boolean =>
url.includes('/_matrix/client/v1/media/download') ||
url.includes('/_matrix/client/v1/media/thumbnail');
type RenderViewerProps = {
src: string;
alt: string;
@@ -88,12 +114,19 @@ export const ImageContent = as<'div', ImageContentProps>(
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
const fileContent = await downloadEncryptedMedia(
mediaUrl,
(encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo),
accessToken
);
return URL.createObjectURL(fileContent);
}
// For authenticated media, fetch with auth header and return blob URL
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
return fetchAuthenticatedMedia(mediaUrl, accessToken);
}
return mediaUrl;
}, [mx, url, useAuthentication, mimeType, encInfo])
);

View File

@@ -2,10 +2,28 @@ import { ReactNode, useCallback, useEffect } from 'react';
import { IThumbnailContent } from '../../../../types/matrix/common';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { decryptFile, downloadEncryptedMedia, isAuthenticatedMediaUrl, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
/**
* Fetches media with authentication headers and returns a blob URL.
*/
const fetchAuthenticatedMedia = async (
url: string,
accessToken: string | null
): Promise<string> => {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
};
export type ThumbnailContentProps = {
info: IThumbnailContent;
renderImage: (src: string) => ReactNode;
@@ -24,13 +42,21 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
}
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
const accessToken = mx.getAccessToken();
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
const fileContent = await downloadEncryptedMedia(
mediaUrl,
(encBuf) => decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo),
accessToken
);
return URL.createObjectURL(fileContent);
}
// For authenticated media, fetch with auth header and return blob URL
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
return fetchAuthenticatedMedia(mediaUrl, accessToken);
}
return mediaUrl;
}, [mx, info, useAuthentication])
);

View File

@@ -82,11 +82,12 @@ export const VideoContent = as<'div', VideoContentProps>(
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo)
decryptFile(encBuf, mimeType, encInfo), accessToken
)
: await downloadMedia(mediaUrl);
: await downloadMedia(mediaUrl, accessToken);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);

View File

@@ -4,6 +4,8 @@ import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useSta
import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
type RoomAvatarProps = {
roomId: string;
@@ -13,12 +15,14 @@ type RoomAvatarProps = {
};
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
const [error, setError] = useState(false);
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
};
if (!src || error) {
if (!authenticatedSrc || error) {
return (
<AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
@@ -32,7 +36,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
return (
<AvatarImage
className={css.RoomAvatar}
src={src}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}

View File

@@ -3,6 +3,8 @@ import React, { ReactEventHandler, ReactNode, useState } from 'react';
import classNames from 'classnames';
import * as css from './UserAvatar.css';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
type UserAvatarProps = {
className?: string;
@@ -13,12 +15,14 @@ type UserAvatarProps = {
};
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
const [error, setError] = useState(false);
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
};
if (!src || error) {
if (!authenticatedSrc || error) {
return (
<AvatarFallback
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
@@ -32,7 +36,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
return (
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={src}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}