499 lines
18 KiB
TypeScript
499 lines
18 KiB
TypeScript
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<React.ImgHTMLAttributes<HTMLImageElement>, '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 <img>
|
|
* 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<AuthenticatedImageProps> = ({
|
|
src,
|
|
fallbackSrc,
|
|
alt,
|
|
isGallery = false,
|
|
slug,
|
|
useCanvasRendering,
|
|
onProtectionViolation,
|
|
onLoad,
|
|
queuePriority = 'normal',
|
|
...props
|
|
}) => {
|
|
|
|
const [imageSrc, setImageSrc] = useState<string>('');
|
|
const [error, setError] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [canvasReady, setCanvasReady] = useState(false);
|
|
const [canvasFailed, setCanvasFailed] = useState(false);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const imageRef = useRef<HTMLImageElement | null>(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<string | undefined>(undefined);
|
|
const retryRef = useRef<HTMLDivElement | null>(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<string> => {
|
|
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<string, string> = {};
|
|
// 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 <img>, 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 <img> 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 (
|
|
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
|
|
{/* Show a placeholder while loading */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && fallbackSrc) {
|
|
return <img src={fallbackSrc} alt={alt} {...props} />;
|
|
}
|
|
|
|
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 (
|
|
<div ref={retryRef} className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }} />
|
|
);
|
|
}
|
|
|
|
if (!imageSrc) {
|
|
return null;
|
|
}
|
|
|
|
// Canvas rendering mode - only if enabled and not failed
|
|
if (useCanvasRendering && !canvasFailed) {
|
|
return (
|
|
<canvas
|
|
ref={setCanvasRef}
|
|
className={props.className}
|
|
style={{
|
|
...props.style,
|
|
// Hide canvas until it's ready to prevent flash
|
|
opacity: canvasReady ? 1 : 0,
|
|
transition: 'opacity 0.2s ease-in-out',
|
|
}}
|
|
// Prevent context menu on canvas
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
onProtectionViolation?.('canvas_context_menu');
|
|
return false;
|
|
}}
|
|
// Prevent drag
|
|
onDragStart={(e) => {
|
|
e.preventDefault();
|
|
return false;
|
|
}}
|
|
aria-label={alt}
|
|
role="img"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <img src={imageSrc} alt={alt} onLoad={(event) => onLoad?.({
|
|
width: event.currentTarget.naturalWidth,
|
|
height: event.currentTarget.naturalHeight,
|
|
})} {...props} />;
|
|
};
|