import React, { useState, useEffect, useRef, useCallback } from 'react'; import { buildResourceUrl } from '../../utils/url'; import { withImageFetchSlot } from '../../utils/imageFetchQueue'; import { getActiveGallerySlug, getGalleryToken, inferGallerySlugFromLocation, resolveSlugFromRequestUrl, } from '../../utils/galleryAuthStorage'; interface AuthenticatedImageProps extends Omit, 'onLoad'> { src: string; fallbackSrc?: string; isGallery?: boolean; slug?: string; useCanvasRendering?: boolean; /** Fired when the canvas branch blocks a context-menu attempt. The only * protection callback this component actually implements (#1297). */ onProtectionViolation?: (violationType: string) => void; /** Dimensions of the loaded rendition, which can differ from the original. */ onLoad?: (dimensions: { width: number; height: number }) => void; /** * Priority in the shared fetch queue (#1287). NOT the native `fetchPriority` * DOM attribute, which stays available on this component and takes * "low"|"high"|"auto" — hence the distinct name. * * 'high' the image the user is looking at now (current lightbox slide) * 'prefetch' one interaction away (lightbox neighbours) * 'normal' grid thumbnails */ queuePriority?: 'high' | 'prefetch' | 'normal'; } /** * Retry budget for a fetch that rejects (#1287). Three attempts with a * doubling delay — 2 s, 4 s, 8 s — and each one waits until the placeholder * is actually on screen and the document is visible before it fires. */ const MAX_RETRIES = 3; const RETRY_BASE_DELAY_MS = 2000; /** A non-OK response, with its status so the retry can tell transient from final. */ class HttpError extends Error { status: number; /** Server-stated cooldown from `Retry-After`, in ms; 0 when absent. */ retryAfterMs: number; constructor(status: number, statusText: string, retryAfter: string | null) { super(`Failed to fetch image: ${status} ${statusText}`); this.status = status; this.retryAfterMs = parseRetryAfter(retryAfter); } } /** `Retry-After` is either delay-seconds or an HTTP date. */ function parseRetryAfter(value: string | null): number { if (!value) return 0; const seconds = Number(value); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const at = Date.parse(value); return Number.isFinite(at) ? Math.max(0, at - Date.now()) : 0; } /** * A 4xx other than 408 (timeout) and 429 (rate limited) is the server's final * answer for this URL — an expired gallery token, a missing photo — and asking * again cannot change it. Everything else (network failure, 5xx, aborts that * were not ours) may. */ const isFinalStatus = (status: number) => status >= 400 && status < 500 && status !== 408 && status !== 429; /** * Fetches an image with the gallery's bearer token and renders it. * * IMAGE PROTECTION IS NOT IMPLEMENTED HERE (#1297). This component used to * accept the whole protection prop surface — protectFromDownload, * watermarkText, fragmentGrid, blockKeyboardShortcuts, detectPrintScreen, * detectDevTools, protectionLevel and the rest — and discard every one of * them in a `void unusedProps` block. Callers computed them from the event's * protection level and passed them in good faith, so raising that level * produced canvas rendering (via the layouts' own OR on * `protectionLevel === 'maximum'`) and nothing else it implies. * * They are removed rather than implemented, so the interface states what the * component actually does. The implementation those props describe already * exists in `ProtectedImage` — which is exported and currently rendered * nowhere. Wiring that in is a deliberate product decision about what * protection level should mean, not a silent side effect of a cleanup. * * Two props survive because they are real: * useCanvasRendering draws to a canvas instead of an * onProtectionViolation fires from the canvas context-menu handler below * * `useWatermark` was removed too. #1297 did not list it — it sat outside the * `unusedProps` block — but it was equally inert: declared, defaulted, never * read. */ export const AuthenticatedImage: React.FC = ({ src, fallbackSrc, alt, isGallery = false, slug, useCanvasRendering, onProtectionViolation, onLoad, queuePriority = 'normal', ...props }) => { const [imageSrc, setImageSrc] = useState(''); const [error, setError] = useState(false); const [isLoading, setIsLoading] = useState(true); const [canvasReady, setCanvasReady] = useState(false); const [canvasFailed, setCanvasFailed] = useState(false); const canvasRef = useRef(null); const imageRef = useRef(null); // Release the backing store immediately when a lightbox slide stops being // a canvas, including when the carousel retains its detached DOM node. const setCanvasRef = useCallback((canvas: HTMLCanvasElement | null) => { if (canvasRef.current && canvasRef.current !== canvas) { canvasRef.current.width = 0; canvasRef.current.height = 0; } canvasRef.current = canvas; }, []); // Retry state (#1287). The nonce is a dependency of the fetch effect, so // bumping it is the retry; the counter is per src, so a new image gets a // fresh budget without an extra effect run to reset it. const [retryNonce, setRetryNonce] = useState(0); const attemptsRef = useRef(0); // Cooldown the server asked for on the last failure (#1287). The backoff // alone would spend all three retries inside a 15-minute rate-limit window // and leave the tile blank after the limit had actually lifted. const retryAfterRef = useRef(0); const lastSrcRef = useRef(undefined); const retryRef = useRef(null); // Draw image to canvas when canvas rendering is enabled // Returns whether the pixels actually made it onto the canvas, so the // caller knows if the source image is still needed (#1287). const drawToCanvas = useCallback(() => { if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return false; const canvas = canvasRef.current; const img = imageRef.current; const ctx = canvas.getContext('2d'); if (!ctx || !img.complete || img.naturalWidth === 0) return false; // Set canvas dimensions to match image canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; // Draw the image ctx.drawImage(img, 0, 0); setCanvasReady(true); return true; }, [useCanvasRendering]); useEffect(() => { let aborted = false; const objectUrls: string[] = []; // #1287 — the previous cleanup only set a flag. The request itself kept // running, holding a connection slot for a tile that is no longer on // screen, which on a several-hundred-photo gallery is most of them. const controller = new AbortController(); if (lastSrcRef.current !== src) { lastSrcRef.current = src; attemptsRef.current = 0; } // Determine which token to use based on context if (!src) { setImageSrc(fallbackSrc || ''); setError(false); setIsLoading(false); return; } setIsLoading(true); setError(false); setCanvasFailed(false); setCanvasReady(false); 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. Only relative paths are app-owned; // an absolute URL is passed through untouched. const isRelative = rawUrl.startsWith('/'); const fullImageUrl = rawUrl.startsWith('/admin') ? buildResourceUrl(`/api${rawUrl}`) : isRelative ? buildResourceUrl(rawUrl) : rawUrl; const headers: Record = {}; // Attach the gallery bearer token ONLY to relative (same-app) image // paths. Never send it to an absolute/external URL — that would leak // gallery credentials cross-origin. AuthenticatedImage does not // support external URLs by design. if (isRelative) { const slugForRequest = resolveSlug(rawUrl); const token = getGalleryToken(slugForRequest); if (token) { headers.Authorization = `Bearer ${token}`; } } // Queued (#1287). Without a cap, a 546-photo grid hands the browser // several hundred simultaneous fetches and some never come back — // pending forever, so nothing is logged and nothing is "failed". // // The BODY read has to happen inside the slot. `fetch` resolves as soon // as the headers arrive, so releasing there would free the slot while // the image bytes are still streaming on that connection — the cap // would bound header round-trips and nothing else, which is not the // workload that stalls a large gallery. const blob = await withImageFetchSlot(async () => { const response = await fetch(fullImageUrl, { credentials: 'include', headers: Object.keys(headers).length ? headers : undefined, signal: controller.signal, }); if (!response.ok) { throw new HttpError( response.status, response.statusText, response.headers?.get?.('Retry-After') ?? null, ); } return await response.blob(); }, { priority: queuePriority }); const objectUrl = URL.createObjectURL(blob); // The effect may have been torn down while this was in flight. Revoke // immediately rather than pushing onto an array nobody will read again. if (aborted) { URL.revokeObjectURL(objectUrl); throw new Error('aborted'); } objectUrls.push(objectUrl); return objectUrl; }; const fetchImage = async () => { try { const primaryUrl = await fetchWithAuth(src); if (!aborted) { setImageSrc(primaryUrl); setError(false); } } catch (err) { // Torn down mid-flight (#1287): the abort is expected, not a failure. // Returning here also stops the fallback below from firing a second // request against an already-aborted signal. if (aborted) return; // A final 4xx exhausts the retry budget: on a 68-tile viewport, three // retries per tile against an expired token would be ~200 requests // that cannot succeed. if (err instanceof HttpError && isFinalStatus(err.status)) { attemptsRef.current = MAX_RETRIES; } retryAfterRef.current = err instanceof HttpError ? err.retryAfterMs : 0; 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); } }; fetchImage(); // Cleanup function return () => { aborted = true; // Free the connection slot rather than leaving the request to run for // a tile that is gone (#1287). controller.abort(); objectUrls.forEach((url) => URL.revokeObjectURL(url)); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [src, fallbackSrc, slug, queuePriority, retryNonce]); // Retry a failed fetch once the tile is back on screen (#1287). // // Before this, a rejected fetch set `error` and nothing ever asked again: // the effect above only re-runs when its inputs change, and for a grid // tile they never do. On the original reporter's install that was the // difference between a transient failure and a permanently blank tile — // a hiccup on cellular, or Safari cancelling loads when the tab goes to // the background, left a tile with no image, no request in flight and // nothing in any log, for as long as the gallery stayed open. That is the // retry-on-scrolling-back-into-view the reporter asked for in the issue. // // Bounded, and gated on visibility. The delay doubles per attempt so a // server that is actually down is not hammered, and an attempt does not // fire until the placeholder intersects the viewport and the document is // visible — a tile that failed while backgrounded retries when the user // comes back, not while they are still away. Without IntersectionObserver // the placeholder counts as visible. const retryable = error && !fallbackSrc && attemptsRef.current < MAX_RETRIES; useEffect(() => { if (!retryable) return; const el = retryRef.current; if (!el) return; let cancelled = false; let delayElapsed = false; let onScreen = typeof IntersectionObserver === 'undefined'; const retry = () => { if (cancelled || !delayElapsed || !onScreen) return; if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; cancelled = true; attemptsRef.current += 1; setRetryNonce((n) => n + 1); }; const timer = setTimeout(() => { delayElapsed = true; retry(); }, Math.max(RETRY_BASE_DELAY_MS * 2 ** attemptsRef.current, retryAfterRef.current)); let observer: IntersectionObserver | null = null; if (typeof IntersectionObserver !== 'undefined') { observer = new IntersectionObserver((entries) => { onScreen = entries.some((entry) => entry.isIntersecting); retry(); }); observer.observe(el); } document.addEventListener('visibilitychange', retry); return () => { cancelled = true; clearTimeout(timer); observer?.disconnect(); document.removeEventListener('visibilitychange', retry); }; }, [retryable, retryNonce]); // Effect to draw to canvas when image is loaded and canvas rendering is enabled useEffect(() => { if (!useCanvasRendering || !imageSrc) return; // Create a hidden image to load and then draw to canvas const img = new Image(); // Only set crossOrigin for non-blob URLs (blob URLs are same-origin) // Setting crossOrigin on blob URLs can cause silent failures if (!imageSrc.startsWith('blob:')) { img.crossOrigin = 'anonymous'; } img.onload = () => { imageRef.current = img; const dimensions = { width: img.naturalWidth, height: img.naturalHeight }; const drawn = drawToCanvas(); // Once drawImage has copied the pixels into the canvas the source // decode is dead weight, so drop it here rather than at unmount. The // grid is not virtualised — a 546-photo event mounts 546 of these and // none of them unmount while the gallery is open — so a cleanup-only // release never actually runs for the case it was meant to fix // (#1287). Nothing redraws from `imageRef` afterwards: drawToCanvas // has this one caller. if (drawn) { // Handlers off BEFORE the src goes. Measured in Chromium and WebKit: // neither fires `error` when the attribute is removed after a // successful load, so this is not fixing an observed bug — but if any // engine ever did, `onerror` would set canvasFailed, swap the canvas // for a plain , and decode the image a second time, which is the // exact opposite of what this release is for. The ordering is free. img.onload = null; img.onerror = null; imageRef.current = null; img.removeAttribute('src'); } onLoad?.(dimensions); }; img.onerror = (e) => { // Fall back to regular img if canvas loading fails console.warn('Canvas image load failed, falling back to img tag:', e); setCanvasFailed(true); }; img.src = imageSrc; return () => { img.onload = null; img.onerror = null; // Fallback release for the paths the onload handler above cannot // cover: the draw failed, or the source changed / the component // unmounted before onload ever fired. `imageRef` is what drawToCanvas // reads and it was never cleared, so a detached Image — and the decode // behind it — stayed pinned by a live JS reference. A decoded in // the document is evictable under memory pressure; one held by a ref // is not. if (imageRef.current === img) { imageRef.current = null; } img.removeAttribute('src'); }; }, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]); if (isLoading) { return (
{/* Show a placeholder while loading */}
); } if (error && fallbackSrc) { return {alt}; } if (error) { // Same box as the loading placeholder, and the element the retry effect // observes. Returning null here (as this used to) left nothing to watch // and nothing for the user to see either. return (
); } if (!imageSrc) { return null; } // Canvas rendering mode - only if enabled and not failed if (useCanvasRendering && !canvasFailed) { return ( { e.preventDefault(); onProtectionViolation?.('canvas_context_menu'); return false; }} // Prevent drag onDragStart={(e) => { e.preventDefault(); return false; }} aria-label={alt} role="img" /> ); } return {alt} onLoad?.({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight, })} {...props} />; };