feat: implement fallback to unauthenticated requests for media fetching on 401 errors

This commit is contained in:
2026-03-13 01:19:58 +11:00
parent 4756bfdc57
commit c07cf58086
8 changed files with 82 additions and 40 deletions

View File

@@ -35,15 +35,23 @@ import { validBlurHash } from '../../../utils/blurHash';
/** /**
* Fetches media with authentication headers and returns a blob URL. * Fetches media with authentication headers and returns a blob URL.
* This is needed because service workers don't work reliably in Tauri/WebKit. * This is needed because service workers don't work reliably in Tauri/WebKit.
* Falls back to unauthenticated request if authenticated request fails with 401.
*/ */
const fetchAuthenticatedMedia = async ( const fetchAuthenticatedMedia = async (
url: string, url: string,
accessToken: string | null accessToken: string | null
): Promise<string> => { ): Promise<string> => {
const response = await fetch(url, { let response = await fetch(url, {
method: 'GET', method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
}); });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[ImageContent] Auth failed (401), attempting unauthenticated fallback for:', url);
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`); throw new Error(`Failed to fetch media: ${response.status}`);
} }

View File

@@ -8,15 +8,23 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
/** /**
* Fetches media with authentication headers and returns a blob URL. * Fetches media with authentication headers and returns a blob URL.
* Falls back to unauthenticated request if authenticated request fails with 401.
*/ */
const fetchAuthenticatedMedia = async ( const fetchAuthenticatedMedia = async (
url: string, url: string,
accessToken: string | null accessToken: string | null
): Promise<string> => { ): Promise<string> => {
const response = await fetch(url, { let response = await fetch(url, {
method: 'GET', method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
}); });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[ThumbnailContent] Auth failed (401), attempting unauthenticated fallback for:', url);
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`); throw new Error(`Failed to fetch media: ${response.status}`);
} }

View File

@@ -1,4 +1,4 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react'; import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
import { Room, EventTimeline } from 'matrix-js-sdk'; import { Room, EventTimeline } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { import {
@@ -283,7 +283,8 @@ export function RoomNavItem({
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
// Get first participated thread for preview // Get first participated thread for preview
const firstThreadPreview = (() => { const firstThreadPreview = useMemo(() => {
try {
const threads = room.getThreads(); const threads = room.getThreads();
const participatedThreads = threads.filter((thread) => thread.hasCurrentUserParticipated); const participatedThreads = threads.filter((thread) => thread.hasCurrentUserParticipated);
if (participatedThreads.length === 0) return undefined; if (participatedThreads.length === 0) return undefined;
@@ -294,7 +295,10 @@ export function RoomNavItem({
const rootBody = rootEvent.getContent()?.body ?? ''; const rootBody = rootEvent.getContent()?.body ?? '';
return rootBody; return rootBody;
})(); } catch (error) {
return undefined;
}
}, [room]);
// Get parent space for DMs // Get parent space for DMs
const parentSpaceInfo = (() => { const parentSpaceInfo = (() => {

View File

@@ -41,13 +41,19 @@ export const useAuthenticatedMediaUrl = (
const fetchMedia = async () => { const fetchMedia = async () => {
try { try {
const accessToken = mx.getAccessToken(); const accessToken = mx.getAccessToken();
const response = await fetch(src, { let response = await fetch(src, {
method: 'GET', method: 'GET',
headers: accessToken headers: accessToken
? { Authorization: `Bearer ${accessToken}` } ? { Authorization: `Bearer ${accessToken}` }
: undefined, : undefined,
}); });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[useAuthenticatedMediaUrl] Auth failed (401), attempting unauthenticated fallback for:', src);
response = await fetch(src, { method: 'GET' });
}
if (!response.ok) { if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`); console.warn(`Failed to fetch authenticated media: ${response.status}`);
// Fall back to original URL in case server doesn't require auth // Fall back to original URL in case server doesn't require auth
@@ -99,13 +105,19 @@ export const useAuthenticatedMediaFetch = () => {
try { try {
const accessToken = mx.getAccessToken(); const accessToken = mx.getAccessToken();
const response = await fetch(src, { let response = await fetch(src, {
method: 'GET', method: 'GET',
headers: accessToken headers: accessToken
? { Authorization: `Bearer ${accessToken}` } ? { Authorization: `Bearer ${accessToken}` }
: undefined, : undefined,
}); });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[useAuthenticatedMediaFetch] Auth failed (401), attempting unauthenticated fallback');
response = await fetch(src, { method: 'GET' });
}
if (!response.ok) { if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`); console.warn(`Failed to fetch authenticated media: ${response.status}`);
return src; return src;

View File

@@ -26,10 +26,16 @@ async function fetchAvatarData(
try { try {
const accessToken = mx.getAccessToken(); const accessToken = mx.getAccessToken();
const response = await fetch(url, { let response = await fetch(url, {
method: 'GET', method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
}); });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return null; if (!response.ok) return null;
return await response.arrayBuffer(); return await response.arrayBuffer();
} catch { } catch {
@@ -112,8 +118,6 @@ export function useUserBanner(): [
throw new Error('No user ID'); throw new Error('No user ID');
} }
console.log('[useUserBanner] Starting banner update:', newBanner);
const profile = await mx.getProfileInfo(userId); const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url; const avatarUrl = profile.avatar_url;
@@ -121,19 +125,14 @@ export function useUserBanner(): [
throw new Error('No avatar set. Please upload an avatar first.'); throw new Error('No avatar set. Please upload an avatar first.');
} }
console.log('[useUserBanner] Current avatar URL:', avatarUrl);
// Fetch current avatar // Fetch current avatar
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
if (!avatarData) { if (!avatarData) {
throw new Error('Failed to fetch current avatar'); throw new Error('Failed to fetch current avatar');
} }
console.log('[useUserBanner] Fetched avatar data, size:', avatarData.byteLength);
// Detect image format // Detect image format
const format = detectImageFormat(avatarData); const format = detectImageFormat(avatarData);
console.log('[useUserBanner] Detected format:', format);
if (format === 'unknown') { if (format === 'unknown') {
throw new Error('Unsupported avatar image format'); throw new Error('Unsupported avatar image format');
@@ -141,7 +140,6 @@ export function useUserBanner(): [
// Get existing metadata to preserve // Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData); const existingMetadata = extractMetadataFromImage(avatarData);
console.log('[useUserBanner] Existing metadata:', existingMetadata);
// Modify image metadata with new banner, preserving color // Modify image metadata with new banner, preserving color
const newMetadata: ImageMetadata = { const newMetadata: ImageMetadata = {
@@ -149,18 +147,14 @@ export function useUserBanner(): [
banner: newBanner, banner: newBanner,
}; };
console.log('[useUserBanner] Embedding metadata:', newMetadata);
const newAvatarData = embedMetadataInImage(avatarData, newMetadata); const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
if (!newAvatarData) { if (!newAvatarData) {
throw new Error('Failed to embed banner in avatar metadata'); throw new Error('Failed to embed banner in avatar metadata');
} }
console.log('[useUserBanner] New avatar data size:', newAvatarData.byteLength);
// Verify the banner was embedded correctly before uploading // Verify the banner was embedded correctly before uploading
const verifyMetadata = extractMetadataFromImage(newAvatarData); const verifyMetadata = extractMetadataFromImage(newAvatarData);
console.log('[useUserBanner] Verification metadata:', verifyMetadata);
if (newBanner && verifyMetadata.banner !== newBanner) { if (newBanner && verifyMetadata.banner !== newBanner) {
throw new Error('Banner verification failed'); throw new Error('Banner verification failed');
@@ -171,22 +165,19 @@ export function useUserBanner(): [
const extension = getExtension(format); const extension = getExtension(format);
const blob = new Blob([newAvatarData], { type: mimeType }); const blob = new Blob([newAvatarData], { type: mimeType });
console.log('[useUserBanner] Uploading avatar blob:', blob.size, 'bytes, type:', mimeType);
const uploadResponse = await mx.uploadContent(blob, { const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`, name: `avatar.${extension}`,
type: mimeType, type: mimeType,
}); });
console.log('[useUserBanner] Upload response:', uploadResponse.content_uri);
// Update profile with new avatar // Update profile with new avatar
console.log('[useUserBanner] Calling setAvatarUrl...');
try { try {
await Promise.race([ await Promise.race([
mx.setAvatarUrl(uploadResponse.content_uri), mx.setAvatarUrl(uploadResponse.content_uri),
new Promise((_, reject) => setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000)) new Promise((_, reject) => {
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
})
]); ]);
console.log('[useUserBanner] Avatar URL updated successfully');
// Manually sync user object to ensure event listeners are triggered // Manually sync user object to ensure event listeners are triggered
const user = mx.getUser(userId); const user = mx.getUser(userId);
@@ -194,7 +185,6 @@ export function useUserBanner(): [
user.setAvatarUrl(uploadResponse.content_uri); user.setAvatarUrl(uploadResponse.content_uri);
} }
} catch (error) { } catch (error) {
console.error('[useUserBanner] setAvatarUrl failed:', error);
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
@@ -225,7 +215,7 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
if (!avatarMxc) { if (!avatarMxc) {
setBannerBlobUrl(undefined); setBannerBlobUrl(undefined);
return; return undefined;
} }
const loadBanner = async () => { const loadBanner = async () => {
@@ -275,7 +265,6 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
const response = await fetch(bannerHttpUrl, { headers }); const response = await fetch(bannerHttpUrl, { headers });
if (!response.ok) { if (!response.ok) {
console.warn('Failed to fetch banner image:', response.status);
setBannerBlobUrl(undefined); setBannerBlobUrl(undefined);
return; return;
} }

View File

@@ -26,10 +26,17 @@ async function fetchAvatarData(
try { try {
const accessToken = mx.getAccessToken(); const accessToken = mx.getAccessToken();
const response = await fetch(url, { let response = await fetch(url, {
method: 'GET', method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
}); });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
console.warn('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback');
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return null; if (!response.ok) return null;
return await response.arrayBuffer(); return await response.arrayBuffer();
} catch { } catch {

View File

@@ -27,10 +27,16 @@ async function fetchAvatarData(
try { try {
const accessToken = mx.getAccessToken(); const accessToken = mx.getAccessToken();
const response = await fetch(url, { let response = await fetch(url, {
method: 'GET', method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
}); });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return null; if (!response.ok) return null;
return await response.arrayBuffer(); return await response.arrayBuffer();
} catch { } catch {

View File

@@ -306,6 +306,7 @@ export const isAuthenticatedMediaUrl = (url: string): boolean =>
/** /**
* Downloads media with optional authentication. * Downloads media with optional authentication.
* For authenticated media URLs, the access token is required. * For authenticated media URLs, the access token is required.
* Falls back to unauthenticated request if authenticated request fails with 401.
*/ */
export const downloadMedia = async (src: string, accessToken?: string | null): Promise<Blob> => { export const downloadMedia = async (src: string, accessToken?: string | null): Promise<Blob> => {
const needsAuth = isAuthenticatedMediaUrl(src); const needsAuth = isAuthenticatedMediaUrl(src);
@@ -315,7 +316,14 @@ export const downloadMedia = async (src: string, accessToken?: string | null): P
headers.Authorization = `Bearer ${accessToken}`; headers.Authorization = `Bearer ${accessToken}`;
} }
const res = await fetch(src, { method: 'GET', headers }); let res = await fetch(src, { method: 'GET', headers });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!res.ok && res.status === 401 && needsAuth && accessToken) {
console.warn('[downloadMedia] Auth failed (401), attempting unauthenticated fallback');
res = await fetch(src, { method: 'GET' });
}
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to download media: ${res.status}`); throw new Error(`Failed to download media: ${res.status}`);
} }