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:
@@ -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);
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user