feat(frontend): add AuthenticatedImg component for authorized image requests (#16525)

This commit is contained in:
euvre
2026-07-01 17:02:44 +08:00
committed by GitHub
parent 97a4c64cc8
commit 81cfcdf2d3
8 changed files with 117 additions and 21 deletions

View File

@@ -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<string>('');
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(() => {

View File

@@ -23,9 +23,11 @@ const DocumentPreview = function ({
setWidthAndHeight,
url,
}: PreviewProps & Partial<IProps>) {
const isPdf = fileType === 'pdf';
return (
<>
{fileType === 'pdf' && highlights && setWidthAndHeight && (
{isPdf && (
<section className="h-full">
<PdfPreviewer
className={className}

View File

@@ -2,6 +2,7 @@ import { getExtension } from '@/utils/document-util';
import SvgIcon from '../svg-icon';
import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request';
import { useAuthenticatedImageUrl } from '@/components/image';
import { useEffect } from 'react';
import styles from './index.module.less';
@@ -16,6 +17,7 @@ const FileIcon = ({ name, id }: IProps) => {
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 ? (
<img src={fileThumbnail} className={styles.thumbnailImg}></img>
return blobUrl ? (
<img src={blobUrl} className={styles.thumbnailImg}></img>
) : (
<SvgIcon name={`file-icon/${fileExtension}`} width={24}></SvgIcon>
);

View File

@@ -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 && (
<section className="flex gap-1 justify-center">
{fileThumbnail ? (
<img
<AuthenticatedImg
src={fileThumbnail}
alt={document?.doc_name}
className="w-6 h-6 rounded"

View File

@@ -1,7 +1,6 @@
import { Authorization } from '@/constants/authorization';
import { restAPIv1 } from '@/utils/api';
import { getAuthorization } from '@/utils/authorization-util';
import { getSearchValue } from '@/utils/common-util';
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
@@ -82,19 +81,36 @@ const fetchDocumentImage = (url: string, authorization: string) => {
};
};
// 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<string>('');
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 <img> 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<string>('');
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 <img> tag with proper authentication for API URLs.
* Use this instead of <img src={apiUrl}> when the URL requires authentication.
*/
export const AuthenticatedImg = ({
src,
alt,
className,
fallback,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
fallback?: React.ReactNode;
}) => {
const authenticatedSrc = useAuthenticatedImageUrl(src);
if (!authenticatedSrc) return fallback ?? null;
return (
<img
src={authenticatedSrc}
alt={alt}
className={className}
{...props}
/>
);
};
const Image = ({ id, t, label, className, ...props }: IImage) => {
const src = useDocumentImageUrl(id, t);
const imageElement = (

View File

@@ -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 && (
<section className="flex gap-1">
{fileThumbnail ? (
<img
<AuthenticatedImg
src={fileThumbnail}
alt=""
className={styles.fileThumbnail}

View File

@@ -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 { IReferenceChunk, IReferenceObject } from '@/interfaces/database/chat';
@@ -296,7 +296,7 @@ function MarkdownContent({
{documentId && (
<div className="flex gap-1">
{fileThumbnail ? (
<img
<AuthenticatedImg
src={fileThumbnail}
alt=""
className={styles.fileThumbnail}

View File

@@ -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';
@@ -185,7 +185,7 @@ const MarkdownContent = ({
{documentId && (
<div className="flex gap-2">
{fileThumbnail ? (
<img
<AuthenticatedImg
src={fileThumbnail}
alt=""
className={styles.fileThumbnail}