Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 170 additions & 59 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<SignedUrlResponsePayload> {
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<SignedUrlResponsePayload> = 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<SignedUrlResponsePayload> = 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 {
Expand Down Expand Up @@ -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<SignedUrlResponsePayload>,
downloadResult: PromiseSettledResult<SignedUrlResponsePayload>,
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<AbortController | null>(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;
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1830,6 +1940,7 @@ export function DocumentViewer({
registerAuthRequest,
isAuthEpochCurrent,
previewAttempt,
applySignedUrlResults,
]);

useEffect(() => {
Expand Down Expand Up @@ -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
Expand Down