diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 18003e586..09d276f2b 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -26,7 +26,7 @@ import { Trash2, X, } from "lucide-react"; -import { type FormEvent, memo, useEffect, useMemo, useRef, useState } from "react"; +import { type FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AccessibleTable, hasRenderableAccessibleTable } from "@/components/AccessibleTable"; import { documentDisplayTitle, documentOrganizationProfile } from "@/components/DocumentOrganizationBadges"; import { formatDocumentLabelDisplay } from "@/lib/document-tags"; @@ -196,6 +196,67 @@ function getDefaultPdfViewerMode(): boolean { return false; } +type SignedUrlResponsePayload = { + url?: string; + caption?: string; + mimeType?: string; + fileType?: string; + expiresAt?: string; + error?: string; +}; + +// Single signed-URL GET: parse JSON, mark the session expired on 401, and throw +// a message on failure. Shared by the initial load and the expiry refresh so the +// fetch/auth handling lives in exactly one place. +async function requestSignedUrlPayload( + endpoint: string, + options: { + signal: AbortSignal; + headers: HeadersInit | undefined; + onUnauthorized: () => void; + errorMessage: string; + }, +): Promise { + const response = await fetch(endpoint, { signal: options.signal, headers: options.headers }); + const payload: SignedUrlResponsePayload = await response.json(); + if (response.status === 401) options.onUnauthorized(); + if (!response.ok) throw new Error(payload?.error || options.errorMessage); + return payload; +} + +// Fetch the preview + download signed URLs together (serving the client cache +// first when allowed). Returns their settled results for the caller to apply. +function fetchSignedUrlPair( + signedUrlEndpoint: string, + downloadSignedUrlEndpoint: string, + options: { + signal: AbortSignal; + headers: HeadersInit | undefined; + onUnauthorized: () => void; + useCache: boolean; + }, +) { + const cachedSigned = options.useCache ? getCachedSignedUrl(signedUrlEndpoint) : null; + const cachedDownload = options.useCache ? getCachedSignedUrl(downloadSignedUrlEndpoint) : null; + const signedUrlRequest: Promise = cachedSigned + ? Promise.resolve(cachedSigned) + : requestSignedUrlPayload(signedUrlEndpoint, { + signal: options.signal, + headers: options.headers, + onUnauthorized: options.onUnauthorized, + errorMessage: "Source preview could not be loaded.", + }); + const signedDownloadUrlRequest: Promise = cachedDownload + ? Promise.resolve(cachedDownload) + : requestSignedUrlPayload(downloadSignedUrlEndpoint, { + signal: options.signal, + headers: options.headers, + onUnauthorized: options.onUnauthorized, + errorMessage: "Download URL could not be loaded.", + }); + return Promise.allSettled([signedUrlRequest, signedDownloadUrlRequest]); +} + function getInitialPdfViewerMode() { if (typeof window === "undefined") { return { @@ -1666,6 +1727,102 @@ export function DocumentViewer({ }; }, [authorizationHeader, isConfigured]); + // Apply the settled preview + download signed-URL results to state. Shared by + // the initial load effect and the expired-URL refresh so a re-issued URL takes + // the exact same path (cache write, previewError, download fallback). + const applySignedUrlResults = useCallback( + ( + signedUrlResult: PromiseSettledResult, + downloadResult: PromiseSettledResult, + signedUrlEndpoint: string, + downloadSignedUrlEndpoint: string, + ) => { + if (signedUrlResult.status === "fulfilled") { + const payload = signedUrlResult.value; + if (payload?.url) setCachedSignedUrl(signedUrlEndpoint, { ...payload, url: payload.url }); + setSignedUrl(payload?.url ?? null); + setPreviewError(null); + } else { + setSignedUrl(null); + setDownloadSignedUrl(null); + setPreviewError( + signedUrlResult.reason instanceof Error + ? signedUrlResult.reason.message + : "Source preview could not be loaded.", + ); + } + + if (downloadResult.status === "fulfilled") { + const payload = downloadResult.value; + if (payload?.url) { + setCachedSignedUrl(downloadSignedUrlEndpoint, { ...payload, url: payload.url }); + setDownloadSignedUrl(payload.url); + return; + } + } + + if (signedUrlResult.status === "fulfilled") { + setDownloadSignedUrl(signedUrlResult.value?.url ?? null); + } else { + setDownloadSignedUrl(null); + } + }, + [], + ); + + // Re-issue only the signed URLs (no document-detail refetch) when a PDF's URL + // expires mid-session, so the viewer refreshes in place without the full + // reload/flicker. Its AbortController is cancelled on the next refresh and on unmount. + const refreshControllerRef = useRef(null); + const refreshSignedUrls = useCallback(() => { + const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`; + const downloadSignedUrlEndpoint = `${signedUrlEndpoint}?download=true`; + + refreshControllerRef.current?.abort(); + const controller = new AbortController(); + refreshControllerRef.current = controller; + const authRequest = registerAuthRequest(controller); + + readLocalProjectIdentity() + .then((identity) => { + if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) { + throw new DOMException("Stale authentication epoch", "AbortError"); + } + if (!identity?.localServer?.safeLocalOrigin) { + throw new Error(unsafeLocalProjectMessage(identity)); + } + // handleSignedUrlExpired already cleared the cache, so always mint fresh. + return fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, { + signal: controller.signal, + headers: clientDemoMode ? undefined : authorizationHeader, + onUnauthorized: markSessionExpired, + useCache: false, + }); + }) + .then(([signedUrlResult, downloadResult]) => { + if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; + applySignedUrlResults(signedUrlResult, downloadResult, signedUrlEndpoint, downloadSignedUrlEndpoint); + }) + .catch((error) => { + if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; + setPreviewError(error instanceof Error ? error.message : "Source preview could not be loaded."); + }) + .finally(() => { + authRequest.release(); + if (refreshControllerRef.current === controller) refreshControllerRef.current = null; + }); + }, [ + documentId, + registerAuthRequest, + isAuthEpochCurrent, + clientDemoMode, + authorizationHeader, + markSessionExpired, + applySignedUrlResults, + ]); + + useEffect(() => () => refreshControllerRef.current?.abort(), []); + useEffect(() => { if (!canViewSourceDocuments && authStatus === "loading") { return () => undefined; @@ -1714,34 +1871,16 @@ export function DocumentViewer({ if (!response.ok) throw new Error(payload.error || "Document details could not be loaded."); return payload; }); - const cachedSignedUrl = getCachedSignedUrl(signedUrlEndpoint); - const cachedDownloadSignedUrl = getCachedSignedUrl(downloadSignedUrlEndpoint); - const signedUrlRequest = cachedSignedUrl - ? Promise.resolve(cachedSignedUrl) - : fetch(signedUrlEndpoint, { - signal: controller.signal, - headers: clientDemoMode ? undefined : authorizationHeader, - }).then(async (response) => { - const payload = await response.json(); - if (response.status === 401) markSessionExpired(); - if (!response.ok) throw new Error(payload.error || "Source preview could not be loaded."); - return payload; - }); - const signedDownloadUrlRequest = cachedDownloadSignedUrl - ? Promise.resolve(cachedDownloadSignedUrl) - : fetch(downloadSignedUrlEndpoint, { - signal: controller.signal, - headers: clientDemoMode ? undefined : authorizationHeader, - }).then(async (response) => { - const payload = await response.json(); - if (response.status === 401) markSessionExpired(); - if (!response.ok) throw new Error(payload.error || "Download URL could not be loaded."); - return payload; - }); - - return Promise.allSettled([detailRequest, signedUrlRequest, signedDownloadUrlRequest]); + const signedUrlPair = fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, { + signal: controller.signal, + headers: clientDemoMode ? undefined : authorizationHeader, + onUnauthorized: markSessionExpired, + useCache: true, + }); + + return Promise.all([Promise.allSettled([detailRequest]), signedUrlPair]); }) - .then(([detailResult, signedUrlResult, signedDownloadUrlResult]) => { + .then(([[detailResult], [signedUrlResult, signedDownloadUrlResult]]) => { if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; if (detailResult.status === "fulfilled") { @@ -1772,36 +1911,7 @@ export function DocumentViewer({ } } - if (signedUrlResult.status === "fulfilled") { - const payload = signedUrlResult.value; - if (payload?.url) setCachedSignedUrl(signedUrlEndpoint, payload); - setSignedUrl(payload?.url ?? null); - setPreviewError(null); - } else { - setSignedUrl(null); - setDownloadSignedUrl(null); - setPreviewError( - signedUrlResult.reason instanceof Error - ? signedUrlResult.reason.message - : "Source preview could not be loaded.", - ); - } - - if (signedDownloadUrlResult.status === "fulfilled") { - const payload = signedDownloadUrlResult.value; - if (payload?.url) { - setCachedSignedUrl(downloadSignedUrlEndpoint, payload); - setDownloadSignedUrl(payload.url); - return; - } - } - - if (signedUrlResult.status === "fulfilled") { - const payload = signedUrlResult.value; - setDownloadSignedUrl(payload?.url ?? null); - } else { - setDownloadSignedUrl(null); - } + applySignedUrlResults(signedUrlResult, signedDownloadUrlResult, signedUrlEndpoint, downloadSignedUrlEndpoint); }) .catch((error) => { if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; @@ -1830,6 +1940,7 @@ export function DocumentViewer({ registerAuthRequest, isAuthEpochCurrent, previewAttempt, + applySignedUrlResults, ]); useEffect(() => { @@ -2086,7 +2197,7 @@ export function DocumentViewer({ const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`; clearCachedSignedUrl(signedUrlEndpoint); clearCachedSignedUrl(`${signedUrlEndpoint}?download=true`); - setPreviewAttempt((current) => current + 1); + refreshSignedUrls(); }; // A successful reload means the refreshed URL was accepted, so the recovery // worked — restore the budget for the next (unrelated) TTL expiry. A broken