From d178c1008793c02886e513b780e1711d95acad94 Mon Sep 17 00:00:00 2001 From: swarnendusahu10-dotcom Date: Sat, 24 Oct 2026 13:30:23 +0530 Subject: [PATCH] fix: authenticate image attachment preview urls to prevent 403 errors --- .../src/hooks/useAuthenticatedMediaUrl.js | 109 ++++++++++ .../AttachmentHandler/ImageAttachment.js | 192 +++++++++++------- 2 files changed, 229 insertions(+), 72 deletions(-) create mode 100644 packages/react/src/hooks/useAuthenticatedMediaUrl.js diff --git a/packages/react/src/hooks/useAuthenticatedMediaUrl.js b/packages/react/src/hooks/useAuthenticatedMediaUrl.js new file mode 100644 index 0000000000..2a23fcb190 --- /dev/null +++ b/packages/react/src/hooks/useAuthenticatedMediaUrl.js @@ -0,0 +1,109 @@ +import { useState, useEffect, useContext } from 'react'; +import RCContext from '../context/RCInstance'; + +/** + * Builds an authenticated URL for Rocket.Chat media uploads. + * Only appends credentials if the target is an internal /file-upload/ path. + * External URLs are strictly returned untouched to prevent credential leakage. + */ +export const buildAuthenticatedUrl = (mediaPath, host, token, userId) => { + 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); + const hostUrl = host ? new URL(host) : null; + + const isSameHost = hostUrl + ? targetUrl.origin === hostUrl.origin + : !isHttpUrl; + const isFileUpload = targetUrl.pathname.startsWith('/file-upload/'); + + if (isFileUpload && isSameHost && token && userId) { + targetUrl.searchParams.set('rc_token', token); + targetUrl.searchParams.set('rc_uid', userId); + return targetUrl.toString(); + } + + return isHttpUrl ? mediaPath : targetUrl.toString(); + } catch (e) { + return isHttpUrl ? mediaPath : `${host || ''}${mediaPath}`; + } +}; + +const getCredentialsFromUser = (user) => { + if (!user) return { token: null, userId: null }; + const token = user.authToken || user.data?.authToken || null; + const userId = user.userId || user.data?.userId || user._id || null; + return { token, userId }; +}; + +const useAuthenticatedMediaUrl = (mediaPath, host) => { + const { RCInstance } = useContext(RCContext); + const effectiveHost = host || RCInstance?.getHost?.() || ''; + + const currentUser = RCInstance?.auth?.currentUser; + const initialCreds = getCredentialsFromUser(currentUser); + + const [authenticatedUrl, setAuthenticatedUrl] = useState(() => + buildAuthenticatedUrl( + mediaPath, + effectiveHost, + initialCreds.token, + initialCreds.userId + ) + ); + + useEffect(() => { + let isMounted = true; + + const updateUrl = (user) => { + if (!isMounted) return; + const { token, userId } = getCredentialsFromUser(user); + const newUrl = buildAuthenticatedUrl( + mediaPath, + effectiveHost, + token, + userId + ); + setAuthenticatedUrl(newUrl); + }; + + if (RCInstance?.auth?.getCurrentUser) { + RCInstance.auth + .getCurrentUser() + .then((user) => { + updateUrl(user); + }) + .catch(() => { + // Gracefully maintain existing url state on failure + }); + } + + const handleAuthChange = (user) => { + updateUrl(user); + }; + + if (RCInstance?.auth?.onAuthChange) { + RCInstance.auth.onAuthChange(handleAuthChange); + } + + return () => { + isMounted = false; + if (RCInstance?.auth?.removeAuthListener) { + RCInstance.auth.removeAuthListener(handleAuthChange); + } + }; + }, [RCInstance, mediaPath, effectiveHost]); + + return authenticatedUrl; +}; + +export default useAuthenticatedMediaUrl; diff --git a/packages/react/src/views/AttachmentHandler/ImageAttachment.js b/packages/react/src/views/AttachmentHandler/ImageAttachment.js index 84516ce65a..6b50710635 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, };