From df10fc677e00e8d16253561bb3f9bcd8ce58731b Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 13 Oct 2025 20:28:53 +0200 Subject: [PATCH] Send gallery image requests with bearer token fallback (#31) --- .../components/common/AuthenticatedImage.tsx | 114 +++++++++++++----- 1 file changed, 81 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx index b9cd671..645f82a 100644 --- a/frontend/src/components/common/AuthenticatedImage.tsx +++ b/frontend/src/components/common/AuthenticatedImage.tsx @@ -1,5 +1,11 @@ import React, { useState, useEffect } from 'react'; import { buildResourceUrl } from '../../utils/url'; +import { + getActiveGallerySlug, + getGalleryToken, + inferGallerySlugFromLocation, + resolveSlugFromRequestUrl, +} from '../../utils/galleryAuthStorage'; interface AuthenticatedImageProps extends React.ImgHTMLAttributes { src: string; @@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC = ({ }) => { const unusedProps = { protectFromDownload, - slug, photoId, requiresToken, secureUrlTemplate, @@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC = ({ const [isLoading, setIsLoading] = useState(true); useEffect(() => { - let objectUrl: string | null = null; + let aborted = false; + const objectUrls: string[] = []; // Determine which token to use based on context if (!src) { @@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC = ({ setIsLoading(true); setError(false); - // Create a new URL with auth header + const resolveSlug = (candidateSrc?: string): string | null => { + if (slug) { + return slug; + } + const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null; + if (fromUrl) { + return fromUrl; + } + return getActiveGallerySlug() || inferGallerySlugFromLocation(); + }; + + const fetchWithAuth = async (rawUrl: string | undefined | null): Promise => { + if (!rawUrl) { + throw new Error('No URL provided'); + } + + // Build full URL for the image + const fullImageUrl = rawUrl.startsWith('/admin') + ? buildResourceUrl(`/api${rawUrl}`) + : rawUrl.startsWith('/') + ? buildResourceUrl(rawUrl) + : rawUrl; + + const headers: Record = {}; + const slugForRequest = resolveSlug(rawUrl); + const token = getGalleryToken(slugForRequest); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(fullImageUrl, { + credentials: 'include', + headers: Object.keys(headers).length ? headers : undefined, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + objectUrls.push(objectUrl); + return objectUrl; + }; + const fetchImage = async () => { try { - // Use the src as-is since it should already be the correct endpoint - let imageUrl = src; - - // Build full URL for the image - // For API paths that start with /admin, we need to prepend /api - const fullImageUrl = imageUrl.startsWith('/admin') - ? buildResourceUrl(`/api${imageUrl}`) - : imageUrl.startsWith('/') - ? buildResourceUrl(imageUrl) - : imageUrl; - - // Fetch authenticated image - const response = await fetch(fullImageUrl, { - credentials: 'include' - }); - - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + const primaryUrl = await fetchWithAuth(src); + if (!aborted) { + setImageSrc(primaryUrl); + setError(false); } - - const blob = await response.blob(); - objectUrl = URL.createObjectURL(blob); - setImageSrc(objectUrl); - setIsLoading(false); } catch (err) { - // Image loading failed - use fallback - setError(true); - setImageSrc(fallbackSrc || ''); + setIsLoading(false); + if (fallbackSrc && fallbackSrc !== src) { + try { + const fallbackUrl = await fetchWithAuth(fallbackSrc); + if (!aborted) { + setImageSrc(fallbackUrl); + setError(false); + } + return; + } catch (fallbackError) { + // Swallow and mark error below + } + } + if (!aborted) { + setError(true); + setImageSrc(''); + } + return; + } + if (!aborted) { setIsLoading(false); } }; @@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC = ({ // Cleanup function return () => { - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } + aborted = true; + objectUrls.forEach((url) => URL.revokeObjectURL(url)); }; - }, [src, fallbackSrc, useWatermark, isGallery]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src, fallbackSrc, slug]); if (isLoading) { return (