Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s

This commit is contained in:
Paul Nothaft
2025-10-13 20:28:53 +02:00
parent 8c690155bf
commit df10fc677e
@@ -1,5 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { buildResourceUrl } from '../../utils/url'; import { buildResourceUrl } from '../../utils/url';
import {
getActiveGallerySlug,
getGalleryToken,
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string; src: string;
@@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
}) => { }) => {
const unusedProps = { const unusedProps = {
protectFromDownload, protectFromDownload,
slug,
photoId, photoId,
requiresToken, requiresToken,
secureUrlTemplate, secureUrlTemplate,
@@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
let objectUrl: string | null = null; let aborted = false;
const objectUrls: string[] = [];
// Determine which token to use based on context // Determine which token to use based on context
if (!src) { if (!src) {
@@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
setIsLoading(true); setIsLoading(true);
setError(false); 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<string> => {
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<string, string> = {};
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 () => { const fetchImage = async () => {
try { try {
// Use the src as-is since it should already be the correct endpoint const primaryUrl = await fetchWithAuth(src);
let imageUrl = src; if (!aborted) {
setImageSrc(primaryUrl);
// Build full URL for the image setError(false);
// 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 blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageSrc(objectUrl);
setIsLoading(false);
} catch (err) { } catch (err) {
// Image loading failed - use fallback setIsLoading(false);
setError(true); if (fallbackSrc && fallbackSrc !== src) {
setImageSrc(fallbackSrc || ''); 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); setIsLoading(false);
} }
}; };
@@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// Cleanup function // Cleanup function
return () => { return () => {
if (objectUrl) { aborted = true;
URL.revokeObjectURL(objectUrl); objectUrls.forEach((url) => URL.revokeObjectURL(url));
}
}; };
}, [src, fallbackSrc, useWatermark, isGallery]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, fallbackSrc, slug]);
if (isLoading) { if (isLoading) {
return ( return (