diff --git a/packages/react/src/hooks/useAuthenticatedMediaUrl.js b/packages/react/src/hooks/useAuthenticatedMediaUrl.js new file mode 100644 index 000000000..f645c9940 --- /dev/null +++ b/packages/react/src/hooks/useAuthenticatedMediaUrl.js @@ -0,0 +1,211 @@ +import { useState, useEffect, useContext, useRef } from 'react'; +import RCContext from '../context/RCInstance'; + +// In-memory cache for protected media blobs to avoid redundant network fetches +const mediaBlobCache = new Map(); +const MAX_CACHE_SIZE = 100; + +const cacheBlob = (url, blob) => { + if (mediaBlobCache.size >= MAX_CACHE_SIZE) { + const oldestKey = mediaBlobCache.keys().next().value; + mediaBlobCache.delete(oldestKey); + } + mediaBlobCache.set(url, blob); +}; + +export const clearMediaBlobCache = () => { + mediaBlobCache.clear(); +}; + +/** + * Resolves a media path relative to the host if needed. + */ +export const resolveMediaUrl = (mediaPath, host) => { + if (!mediaPath || typeof mediaPath !== 'string') { + return ''; + } + + const isHttpUrl = /^https?:\/\//i.test(mediaPath); + if (!isHttpUrl && !host) { + return mediaPath; + } + + try { + const targetUrl = isHttpUrl ? new URL(mediaPath) : new URL(mediaPath, host); + return targetUrl.toString(); + } catch (e) { + return isHttpUrl ? mediaPath : `${host || ''}${mediaPath}`; + } +}; + +/** + * Checks whether a media URL targets an internal Rocket.Chat /file-upload/ path. + */ +export const isProtectedFileUpload = (mediaPath, host) => { + if (!mediaPath || typeof mediaPath !== 'string') { + return false; + } + + const isHttpUrl = /^https?:\/\//i.test(mediaPath); + if (!isHttpUrl && !host) { + return false; + } + + try { + const targetUrl = isHttpUrl ? new URL(mediaPath) : new URL(mediaPath, host); + const hostUrl = host ? new URL(host) : null; + const isSameHost = hostUrl + ? targetUrl.origin === hostUrl.origin + : !isHttpUrl; + + return isSameHost && targetUrl.pathname.startsWith('/file-upload/'); + } catch (e) { + return false; + } +}; + +const getCredentialsFromUser = (user, RCInstance) => { + const token = + user?.authToken || user?.data?.authToken || RCInstance?.token || null; + const userId = + user?.userId || + user?.data?.userId || + user?._id || + RCInstance?.userId || + null; + return { token, userId }; +}; + +const useAuthenticatedMediaUrl = (mediaPath, host) => { + const { RCInstance } = useContext(RCContext); + const effectiveHost = host || RCInstance?.getHost?.() || ''; + + const [mediaUrl, setMediaUrl] = useState(() => + resolveMediaUrl(mediaPath, effectiveHost) + ); + + const activeBlobUrlRef = useRef(null); + + useEffect(() => { + let isMounted = true; + const controller = new AbortController(); + + if (!mediaPath || typeof mediaPath !== 'string') { + setMediaUrl(''); + return undefined; + } + + const cleanUrl = resolveMediaUrl(mediaPath, effectiveHost); + const isProtected = isProtectedFileUpload(mediaPath, effectiveHost); + + // External media or non-protected uploads are delivered directly with zero overhead + if (!isProtected) { + setMediaUrl(cleanUrl); + return undefined; + } + + const loadProtectedMedia = async (user) => { + const { token, userId } = getCredentialsFromUser(user, RCInstance); + + if (!token || !userId) { + if (isMounted) setMediaUrl(cleanUrl); + return; + } + + // Check in-memory blob cache first + if (mediaBlobCache.has(cleanUrl)) { + const cachedBlob = mediaBlobCache.get(cleanUrl); + const objectUrl = URL.createObjectURL(cachedBlob); + if (isMounted) { + if (activeBlobUrlRef.current) { + URL.revokeObjectURL(activeBlobUrlRef.current); + } + activeBlobUrlRef.current = objectUrl; + setMediaUrl(objectUrl); + } else { + URL.revokeObjectURL(objectUrl); + } + return; + } + + try { + const response = await fetch(cleanUrl, { + method: 'GET', + headers: { + 'X-Auth-Token': token, + 'X-User-Id': userId, + }, + credentials: 'include', + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Media fetch failed with status ${response.status}`); + } + + const blob = await response.blob(); + cacheBlob(cleanUrl, blob); + + const objectUrl = URL.createObjectURL(blob); + if (isMounted) { + if (activeBlobUrlRef.current) { + URL.revokeObjectURL(activeBlobUrlRef.current); + } + activeBlobUrlRef.current = objectUrl; + setMediaUrl(objectUrl); + } else { + URL.revokeObjectURL(objectUrl); + } + } catch (err) { + if (err.name !== 'AbortError') { + // Gracefully fallback to cleanUrl on fetch error + if (isMounted) setMediaUrl(cleanUrl); + } + } + }; + + const currentUser = RCInstance?.auth?.currentUser; + if (currentUser) { + loadProtectedMedia(currentUser); + } else if (RCInstance?.auth?.getCurrentUser) { + RCInstance.auth + .getCurrentUser() + .then((user) => { + if (isMounted) loadProtectedMedia(user); + }) + .catch(() => { + if (isMounted) setMediaUrl(cleanUrl); + }); + } else { + loadProtectedMedia(null); + } + + const handleAuthChange = (user) => { + if (!isMounted) return; + if (!user) { + clearMediaBlobCache(); + } + loadProtectedMedia(user); + }; + + if (RCInstance?.auth?.onAuthChange) { + RCInstance.auth.onAuthChange(handleAuthChange); + } + + return () => { + isMounted = false; + controller.abort(); + if (activeBlobUrlRef.current) { + URL.revokeObjectURL(activeBlobUrlRef.current); + activeBlobUrlRef.current = null; + } + if (RCInstance?.auth?.removeAuthListener) { + RCInstance.auth.removeAuthListener(handleAuthChange); + } + }; + }, [RCInstance, mediaPath, effectiveHost]); + + return mediaUrl; +}; + +export default useAuthenticatedMediaUrl; diff --git a/packages/react/src/views/AttachmentHandler/ImageAttachment.js b/packages/react/src/views/AttachmentHandler/ImageAttachment.js index 84516ce65..6b5071063 100644 --- a/packages/react/src/views/AttachmentHandler/ImageAttachment.js +++ b/packages/react/src/views/AttachmentHandler/ImageAttachment.js @@ -5,12 +5,107 @@ import { Box, Avatar, useTheme } from '@embeddedchat/ui-elements'; import AttachmentMetadata from './AttachmentMetadata'; import ImageGallery from '../ImageGallery/ImageGallery'; import RCContext from '../../context/RCInstance'; +import useAuthenticatedMediaUrl from '../../hooks/useAuthenticatedMediaUrl'; + +const NestedImageAttachment = ({ + nestedAttachment, + host, + variantStyles = {}, + theme, + getUserAvatarUrl, + extractIdFromUrl, + setShowGallery, + showGallery, +}) => { + const mediaUrl = useAuthenticatedMediaUrl(nestedAttachment.image_url, host); + const titleUrl = useAuthenticatedMediaUrl( + nestedAttachment.title_link || nestedAttachment.image_url, + host + ); + + return ( + + setShowGallery(true)} + css={[ + css` + cursor: pointer; + border-radius: inherit; + line-height: 0; + padding: 0.5rem; + `, + (nestedAttachment.attachments?.[0]?.type + ? variantStyles.pinnedContainer + : variantStyles.quoteContainer) || + css` + ${nestedAttachment.attachments?.[0]?.type === 'file' + ? `border: 2px solid ${theme.colors.border};` + : ''} + `, + ]} + > + {nestedAttachment.type === 'file' ? ( + + + @{nestedAttachment.author_name} + + ) : ( + '' + )} + + + + {showGallery && ( + + )} + + ); +}; + +NestedImageAttachment.propTypes = { + nestedAttachment: PropTypes.object, + host: PropTypes.string, + variantStyles: PropTypes.object, + theme: PropTypes.object, + getUserAvatarUrl: PropTypes.func, + extractIdFromUrl: PropTypes.func, + setShowGallery: PropTypes.func, + showGallery: PropTypes.bool, +}; const ImageAttachment = ({ attachment, host, type, - author, + author = {}, variantStyles = {}, msg, }) => { @@ -22,7 +117,7 @@ const ImageAttachment = ({ return URL; }; const extractIdFromUrl = (url) => { - const match = url.match(/\/file-upload\/(.*?)\//); + const match = url?.match(/\/file-upload\/(.*?)\//); return match ? match[1] : null; }; @@ -35,6 +130,12 @@ const ImageAttachment = ({ setIsExpanded((prevState) => !prevState); }; + const mediaUrl = useAuthenticatedMediaUrl(attachment.image_url, host); + const titleUrl = useAuthenticatedMediaUrl( + attachment.title_link || attachment.image_url, + host + ); + return ( setShowGallery(true)}> ( - - setShowGallery(true)} - css={[ - css` - cursor: pointer; - border-radius: inherit; - line-height: 0; - padding: 0.5rem; - `, - (nestedAttachment.attachments[0].type - ? variantStyles.pinnedContainer - : variantStyles.quoteContainer) || - css` - ${nestedAttachment.attachments[0].type === 'file' - ? `border: 2px solid ${theme.colors.border};` - : ''} - `, - ]} - > - {nestedAttachment.type === 'file' ? ( - <> - - - @{nestedAttachment.author_name} - - - ) : ( - '' - )} - - - - {showGallery && ( - - )} - + ))} {showGallery && ( @@ -193,4 +237,8 @@ export default ImageAttachment; ImageAttachment.propTypes = { attachment: PropTypes.object, host: PropTypes.string, + type: PropTypes.string, + author: PropTypes.object, + variantStyles: PropTypes.object, + msg: PropTypes.object, };