mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-02 08:45:42 +08:00
feat(frontend): add AuthenticatedImg component for authorized image requests (#16525)
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user