From 54f5fa95a6dd151b1a1d29757fafd81b7b371f4c Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Fri, 23 Jan 2026 22:31:37 +1100 Subject: [PATCH] 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. --- .../AuthenticatedAvatarImage.tsx | 22 ++++ .../AuthenticatedMedia.tsx | 45 +++++++ .../components/authenticated-media/index.ts | 4 + .../components/image-pack-view/PackMeta.tsx | 4 +- .../components/image-viewer/ImageViewer.tsx | 4 +- src/app/components/message/FileHeader.tsx | 5 +- .../message/content/AudioContent.tsx | 5 +- .../message/content/FileContent.tsx | 15 ++- .../message/content/ImageContent.tsx | 37 +++++- .../message/content/ThumbnailContent.tsx | 32 ++++- .../message/content/VideoContent.tsx | 5 +- src/app/components/room-avatar/RoomAvatar.tsx | 8 +- src/app/components/user-avatar/UserAvatar.tsx | 8 +- .../emojis-stickers/RoomPacks.tsx | 4 +- .../settings/emojis-stickers/GlobalPacks.tsx | 6 +- .../settings/emojis-stickers/UserPack.tsx | 5 +- src/app/hooks/useAuthenticatedMediaUrl.ts | 123 ++++++++++++++++++ src/app/plugins/react-custom-html-parser.tsx | 5 +- src/app/utils/matrix.ts | 31 ++++- 19 files changed, 330 insertions(+), 38 deletions(-) create mode 100644 src/app/components/authenticated-media/AuthenticatedAvatarImage.tsx create mode 100644 src/app/components/authenticated-media/AuthenticatedMedia.tsx create mode 100644 src/app/components/authenticated-media/index.ts create mode 100644 src/app/hooks/useAuthenticatedMediaUrl.ts diff --git a/src/app/components/authenticated-media/AuthenticatedAvatarImage.tsx b/src/app/components/authenticated-media/AuthenticatedAvatarImage.tsx new file mode 100644 index 0000000..0cf824b --- /dev/null +++ b/src/app/components/authenticated-media/AuthenticatedAvatarImage.tsx @@ -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; + +/** + * 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( + ({ src, ...props }, ref) => { + const useAuthentication = useMediaAuthentication(); + const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication); + + return ; + } +); + +AuthenticatedAvatarImage.displayName = 'AuthenticatedAvatarImage'; diff --git a/src/app/components/authenticated-media/AuthenticatedMedia.tsx b/src/app/components/authenticated-media/AuthenticatedMedia.tsx new file mode 100644 index 0000000..37e68b7 --- /dev/null +++ b/src/app/components/authenticated-media/AuthenticatedMedia.tsx @@ -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, '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( + ({ src, useAuthentication: useAuthOverride, ...props }, ref) => { + const useAuthFromServer = useMediaAuthentication(); + const useAuthentication = useAuthOverride ?? useAuthFromServer; + const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication); + + return ; + } +); + +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 {} + +export const AuthenticatedImg = forwardRef( + ({ src, ...props }, ref) => { + const useAuthentication = useMediaAuthentication(); + const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication); + + return ; + } +); + +AuthenticatedImg.displayName = 'AuthenticatedImg'; diff --git a/src/app/components/authenticated-media/index.ts b/src/app/components/authenticated-media/index.ts new file mode 100644 index 0000000..cb2fcc3 --- /dev/null +++ b/src/app/components/authenticated-media/index.ts @@ -0,0 +1,4 @@ +export { AuthenticatedMedia, AuthenticatedImg } from './AuthenticatedMedia'; +export type { AuthenticatedMediaProps, AuthenticatedImgProps } from './AuthenticatedMedia'; +export { AuthenticatedAvatarImage } from './AuthenticatedAvatarImage'; +export type { AuthenticatedAvatarImageProps } from './AuthenticatedAvatarImage'; diff --git a/src/app/components/image-pack-view/PackMeta.tsx b/src/app/components/image-pack-view/PackMeta.tsx index f091f30..b4cb48b 100644 --- a/src/app/components/image-pack-view/PackMeta.tsx +++ b/src/app/components/image-pack-view/PackMeta.tsx @@ -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 ( {url ? ( - + ) : ( {nameInitials(name ?? 'Unknown')} diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 4956a7b..bbc2a2c 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -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); }; diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 0248862..bb447b4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -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); diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 71551b1..414bd59 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -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]) ); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index ad54c53..4ab1446 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -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); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 84e3709..71bd6be 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -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 => { + 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]) ); diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index 0746137..74d015d 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -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 => { + 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]) ); diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 52073ac..9eef522 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -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]) ); diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 23f3998..f77d795 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -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 = (evt) => { evt.currentTarget.setAttribute('data-image-loaded', 'true'); }; - if (!src || error) { + if (!authenticatedSrc || error) { return ( setError(true)} onLoad={handleLoad} diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx index d9de9b7..04cff39 100644 --- a/src/app/components/user-avatar/UserAvatar.tsx +++ b/src/app/components/user-avatar/UserAvatar.tsx @@ -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 = (evt) => { evt.currentTarget.setAttribute('data-image-loaded', 'true'); }; - if (!src || error) { + if (!authenticatedSrc || error) { return ( setError(true)} onLoad={handleLoad} diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index fdbe546..8860280 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -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) { ))} {avatarUrl ? ( - + ) : ( diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index a928872..069c7e7 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -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({ {avatarUrl ? ( - + ) : ( @@ -389,7 +389,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { )} {avatarUrl ? ( - + ) : ( diff --git a/src/app/features/settings/emojis-stickers/UserPack.tsx b/src/app/features/settings/emojis-stickers/UserPack.tsx index c41c8e1..4e966e0 100644 --- a/src/app/features/settings/emojis-stickers/UserPack.tsx +++ b/src/app/features/settings/emojis-stickers/UserPack.tsx @@ -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={ {avatarUrl ? ( - + ) : ( diff --git a/src/app/hooks/useAuthenticatedMediaUrl.ts b/src/app/hooks/useAuthenticatedMediaUrl.ts new file mode 100644 index 0000000..2e2d601 --- /dev/null +++ b/src/app/hooks/useAuthenticatedMediaUrl.ts @@ -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(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 => { + 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] + ); +}; diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index ba40c97..ad249a6 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -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 ( - + ); } - if (htmlSrc) return ; + if (htmlSrc) return ; } } diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4c86c4e..4bc6c51 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -296,18 +296,39 @@ export const mxcUrlToHttp = ( useAuthentication ); -export const downloadMedia = async (src: string): Promise => { - // 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 => { + 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 + decryptContent: (buf: ArrayBuffer) => Promise, + accessToken?: string | null ): Promise => { - const encryptedContent = await downloadMedia(src); + const encryptedContent = await downloadMedia(src, accessToken); const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer()); return decryptedContent;