diff --git a/web/src/components/document-preview/hooks.ts b/web/src/components/document-preview/hooks.ts index b6bcf67761..921b83ed13 100644 --- a/web/src/components/document-preview/hooks.ts +++ b/web/src/components/document-preview/hooks.ts @@ -129,10 +129,10 @@ export const useFetchExcel = (filePath: string) => { myExcelPreviewer ?.preview(jsonFile.data) .then(() => { - console.log('succeed'); setStatus(true); }) .catch((e) => { + // eslint-disable-next-line no-console console.warn('failed', e); myExcelPreviewer.destroy(); setStatus(false); @@ -155,9 +155,19 @@ export const useCatchDocumentError = (url: string) => { const [error, setError] = useState(''); const fetchDocument = useCallback(async () => { - const { data } = await axios.get(url, { headers: httpHeaders }); - if (data.code !== 0) { - setError(data?.message); + try { + const { data } = await axios.get(url, { headers: httpHeaders }); + // Only treat as error if response is JSON with an error code + // Binary data (like PDF) won't have a code property + if (data && typeof data === 'object' && 'code' in data && data.code !== 0) { + setError(data?.message || 'Failed to load document'); + } + } catch (e) { + // Network errors or non-2xx responses + const errMsg = e instanceof Error ? e.message : 'Failed to load document'; + if (errMsg) { + setError(errMsg); + } } }, [url, httpHeaders]); useEffect(() => { diff --git a/web/src/components/document-preview/index.tsx b/web/src/components/document-preview/index.tsx index dc764ce6f1..e94d2e7347 100644 --- a/web/src/components/document-preview/index.tsx +++ b/web/src/components/document-preview/index.tsx @@ -23,9 +23,11 @@ const DocumentPreview = function ({ setWidthAndHeight, url, }: PreviewProps & Partial) { + const isPdf = fileType === 'pdf'; + return ( <> - {fileType === 'pdf' && highlights && setWidthAndHeight && ( + {isPdf && (
{ const { data: fileThumbnails, setDocumentIds } = useFetchDocumentThumbnailsByIds(); const fileThumbnail = fileThumbnails[id]; + const blobUrl = useAuthenticatedImageUrl(fileThumbnail); useEffect(() => { if (id) { @@ -23,8 +25,8 @@ const FileIcon = ({ name, id }: IProps) => { } }, [id, setDocumentIds]); - return fileThumbnail ? ( - + return blobUrl ? ( + ) : ( ); diff --git a/web/src/components/floating-chat-widget-markdown.tsx b/web/src/components/floating-chat-widget-markdown.tsx index 88f5ed7ce1..4bd9e5cbb0 100644 --- a/web/src/components/floating-chat-widget-markdown.tsx +++ b/web/src/components/floating-chat-widget-markdown.tsx @@ -1,4 +1,4 @@ -import Image from '@/components/image'; +import Image, { AuthenticatedImg } from '@/components/image'; import SvgIcon from '@/components/svg-icon'; import { MarkdownRemarkPlugins } from '@/constants/markdown-remark-plugins'; @@ -197,7 +197,7 @@ const FloatingChatWidgetMarkdown = ({ {documentId && (
{fileThumbnail ? ( - {document?.doc_name} { }; }; +// Check if a URL requires authentication (internal API URLs) +// Only attach Authorization headers to same-origin requests to prevent token leakage +const isAuthRequiredUrl = (url: string): boolean => { + try { + const parsedUrl = new URL(url, window.location.origin); + if (parsedUrl.origin !== window.location.origin) { + return false; + } + return ( + parsedUrl.pathname.startsWith('/api/v1/') || + parsedUrl.pathname.includes('/documents/images/') + ); + } catch { + return false; + } +}; + export const useDocumentImageUrl = (id: string, t?: string | number) => { const directUrl = useMemo(() => buildDocumentImageUrl(id, t), [id, t]); - const [imageUrl, setImageUrl] = useState(() => - getAuthorization() && getSearchValue('shared_id') ? '' : directUrl, - ); + const [imageUrl, setImageUrl] = useState(''); useEffect(() => { - const authorization = getAuthorization(); - if (!authorization || !getSearchValue('shared_id')) { + // For non-API URLs (e.g., base64, external URLs), use directly + if (!isAuthRequiredUrl(directUrl)) { setImageUrl(directUrl); return; } + // For API URLs that require authentication, always fetch with auth headers + const authorization = getAuthorization(); let ignore = false; setImageUrl(''); const { promise, release } = fetchDocumentImage(directUrl, authorization); @@ -120,6 +136,72 @@ export const useDocumentImageUrl = (id: string, t?: string | number) => { return imageUrl; }; +/** + * Hook to convert any authenticated URL to a blob URL for use in tags. + * Use this for thumbnail URLs or any other API URLs that require authentication. + */ +export const useAuthenticatedImageUrl = (url: string | undefined | null) => { + const [imageUrl, setImageUrl] = useState(''); + + useEffect(() => { + if (!url || !isAuthRequiredUrl(url)) { + setImageUrl(url || ''); + return; + } + + const authorization = getAuthorization(); + let cancelled = false; + setImageUrl(''); + + const { promise, release } = fetchDocumentImage(url, authorization); + promise + .then((blobUrl) => { + if (!cancelled) { + setImageUrl(blobUrl); + } + }) + .catch(() => { + if (!cancelled) { + setImageUrl(''); + } + }); + + return () => { + cancelled = true; + release(); + }; + }, [url]); + + return imageUrl; +}; + +/** + * Component that renders an tag with proper authentication for API URLs. + * Use this instead of when the URL requires authentication. + */ +export const AuthenticatedImg = ({ + src, + alt, + className, + fallback, + ...props +}: React.ImgHTMLAttributes & { + fallback?: React.ReactNode; +}) => { + const authenticatedSrc = useAuthenticatedImageUrl(src); + + if (!authenticatedSrc) return fallback ?? null; + + return ( + {alt} + ); +}; + const Image = ({ id, t, label, className, ...props }: IImage) => { const src = useDocumentImageUrl(id, t); const imageElement = ( diff --git a/web/src/components/markdown-content/index.tsx b/web/src/components/markdown-content/index.tsx index 7eb6398e37..f33fb745d7 100644 --- a/web/src/components/markdown-content/index.tsx +++ b/web/src/components/markdown-content/index.tsx @@ -1,4 +1,4 @@ -import Image from '@/components/image'; +import Image, { AuthenticatedImg } from '@/components/image'; import SvgIcon from '@/components/svg-icon'; import { MarkdownRemarkPlugins } from '@/constants/markdown-remark-plugins'; import { IReference, IReferenceChunk } from '@/interfaces/database/chat'; @@ -203,7 +203,7 @@ const MarkdownContent = ({ {documentId && (
{fileThumbnail ? ( - {fileThumbnail ? ( - {fileThumbnail ? ( -