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}

View File

@@ -6,7 +6,6 @@ import {
Icon,
Icons,
Avatar,
AvatarImage,
AvatarFallback,
toRem,
config,
@@ -33,6 +32,7 @@ import { SequenceCardStyle } from '../styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { AuthenticatedAvatarImage } from '../../../components/authenticated-media';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { suffixRename } from '../../../utils/common';
@@ -236,7 +236,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
))}
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />

View File

@@ -7,7 +7,6 @@ import {
Icons,
IconButton,
Avatar,
AvatarImage,
AvatarFallback,
config,
Spinner,
@@ -31,6 +30,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AuthenticatedAvatarImage } from '../../../components/authenticated-media';
import {
EmoteRoomsContent,
ImagePack,
@@ -190,7 +190,7 @@ function GlobalPackSelector({
<Box alignItems="Center" gap="300">
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
@@ -389,7 +389,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
)}
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Icon, Icons, Text } from 'folds';
import { Avatar, AvatarFallback, Box, Button, Icon, Icons, Text } from 'folds';
import { useUserImagePack } from '../../../hooks/useImagePacks';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
@@ -8,6 +8,7 @@ import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { AuthenticatedAvatarImage } from '../../../components/authenticated-media';
type UserPackProps = {
onViewPack: (imagePack: ImagePack) => void;
@@ -44,7 +45,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
before={
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
/**
* Fetches media with authentication and returns a blob URL.
* This is needed because service workers may not work reliably in Tauri/WebKit environments,
* and cross-origin image requests cannot include Authorization headers.
*/
export const useAuthenticatedMediaUrl = (
src: string | undefined,
useAuthentication: boolean
): string | undefined => {
const mx = useMatrixClient();
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
useEffect(() => {
if (!src) {
setBlobUrl(undefined);
return;
}
// If not using authentication, just return the original URL
if (!useAuthentication) {
setBlobUrl(src);
return;
}
// Check if this is an authenticated media URL
const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') ||
src.includes('/_matrix/client/v1/media/thumbnail');
if (!isAuthenticatedMediaUrl) {
setBlobUrl(src);
return;
}
let cancelled = false;
let objectUrl: string | undefined;
const fetchMedia = async () => {
try {
const accessToken = mx.getAccessToken();
const response = await fetch(src, {
method: 'GET',
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`);
// Fall back to original URL in case server doesn't require auth
if (!cancelled) setBlobUrl(src);
return;
}
const blob = await response.blob();
if (!cancelled) {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
} catch (error) {
console.warn('Error fetching authenticated media:', error);
// Fall back to original URL
if (!cancelled) setBlobUrl(src);
}
};
fetchMedia();
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src, useAuthentication, mx]);
return blobUrl;
};
/**
* Creates an authenticated fetch function for media URLs.
* Useful for components that need to load multiple images.
*/
export const useAuthenticatedMediaFetch = () => {
const mx = useMatrixClient();
return useCallback(
async (src: string): Promise<string> => {
const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') ||
src.includes('/_matrix/client/v1/media/thumbnail');
if (!isAuthenticatedMediaUrl) {
return src;
}
try {
const accessToken = mx.getAccessToken();
const response = await fetch(src, {
method: 'GET',
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`);
return src;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.warn('Error fetching authenticated media:', error);
return src;
}
},
[mx]
);
};

View File

@@ -41,6 +41,7 @@ import {
import { onEnterOrSpace } from '../utils/keyboard';
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
import { AuthenticatedImg } from '../components/authenticated-media';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
@@ -485,12 +486,12 @@ export const getReactCustomHtmlParser = (
return (
<span className={css.EmoticonBase}>
<span className={css.Emoticon()}>
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
<AuthenticatedImg {...props} className={css.EmoticonImg} src={htmlSrc} />
</span>
</span>
);
}
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} />;
if (htmlSrc) return <AuthenticatedImg {...props} className={css.Img} src={htmlSrc} />;
}
}

View File

@@ -296,18 +296,39 @@ export const mxcUrlToHttp = (
useAuthentication
);
export const downloadMedia = async (src: string): Promise<Blob> => {
// this request is authenticated by service worker
const res = await fetch(src, { method: 'GET' });
/**
* Check if a URL is an authenticated media endpoint.
*/
export const isAuthenticatedMediaUrl = (url: string): boolean =>
url.includes('/_matrix/client/v1/media/download') ||
url.includes('/_matrix/client/v1/media/thumbnail');
/**
* Downloads media with optional authentication.
* For authenticated media URLs, the access token is required.
*/
export const downloadMedia = async (src: string, accessToken?: string | null): Promise<Blob> => {
const needsAuth = isAuthenticatedMediaUrl(src);
const headers: HeadersInit = {};
if (needsAuth && accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const res = await fetch(src, { method: 'GET', headers });
if (!res.ok) {
throw new Error(`Failed to download media: ${res.status}`);
}
const blob = await res.blob();
return blob;
};
export const downloadEncryptedMedia = async (
src: string,
decryptContent: (buf: ArrayBuffer) => Promise<Blob>
decryptContent: (buf: ArrayBuffer) => Promise<Blob>,
accessToken?: string | null
): Promise<Blob> => {
const encryptedContent = await downloadMedia(src);
const encryptedContent = await downloadMedia(src, accessToken);
const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer());
return decryptedContent;