🛡️ feat: Optimize and Improve Anonymity of SharedLinks (#3543)

* ci: add spec for validateImageRequest

* chore: install nanoid and run npm audit fix

* refactor: optimize and further anonymize shared links data

* ci: add SharedLink specs

* feat: anonymize asst_id's

* ci: tests actually failing, to revisit later

* fix: do not anonymize authenticated shared links query

* refactor: Update ShareView component styling

* refactor: remove nested ternary

* refactor: no nested ternaries

* fix(ShareView): eslint warnings

* fix(eslint): remove nested terns
This commit is contained in:
Danny Avila 2024-08-05 03:34:00 -04:00 committed by GitHub
parent 3e0f95458f
commit 458dc9c88e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2340 additions and 890 deletions

View file

@ -66,6 +66,7 @@ module.exports = {
'no-restricted-syntax': 'off', 'no-restricted-syntax': 'off',
'react/prop-types': ['off'], 'react/prop-types': ['off'],
'react/display-name': ['off'], 'react/display-name': ['off'],
'no-nested-ternary': 'error',
'no-unused-vars': ['error', { varsIgnorePattern: '^_' }], 'no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
quotes: ['error', 'single'], quotes: ['error', 'single'],
}, },

View file

@ -38,7 +38,12 @@ const run = async () => {
"On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish. In a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains." "On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish. In a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains."
`; `;
const model = 'gpt-3.5-turbo'; const model = 'gpt-3.5-turbo';
const maxContextTokens = model === 'gpt-4' ? 8191 : model === 'gpt-4-32k' ? 32767 : 4095; // 1 less than maximum let maxContextTokens = 4095;
if (model === 'gpt-4') {
maxContextTokens = 8191;
} else if (model === 'gpt-4-32k') {
maxContextTokens = 32767;
}
const clientOptions = { const clientOptions = {
reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null, reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null,
maxContextTokens, maxContextTokens,

View file

@ -151,7 +151,16 @@ module.exports = {
getConversationTags: async (user) => { getConversationTags: async (user) => {
try { try {
const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean(); const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean();
cTags.sort((a, b) => (a.tag === SAVED_TAG ? -1 : b.tag === SAVED_TAG ? 1 : 0));
cTags.sort((a, b) => {
if (a.tag === SAVED_TAG) {
return -1;
}
if (b.tag === SAVED_TAG) {
return 1;
}
return 0;
});
return cTags; return cTags;
} catch (error) { } catch (error) {

View file

@ -1,117 +1,252 @@
const crypto = require('crypto'); const { nanoid } = require('nanoid');
const { getMessages } = require('./Message'); const { Constants } = require('librechat-data-provider');
const SharedLink = require('./schema/shareSchema'); const SharedLink = require('./schema/shareSchema');
const { getMessages } = require('./Message');
const logger = require('~/config/winston'); const logger = require('~/config/winston');
module.exports = { /**
SharedLink, * Anonymizes a conversation ID
getSharedMessages: async (shareId) => { * @returns {string} The anonymized conversation ID
try { */
const share = await SharedLink.findOne({ shareId }) function anonymizeConvoId() {
.populate({ return `convo_${nanoid()}`;
path: 'messages', }
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share || !share.conversationId || !share.isPublic) { /**
return null; * Anonymizes an assistant ID
} * @returns {string} The anonymized assistant ID
*/
function anonymizeAssistantId() {
return `a_${nanoid()}`;
}
return share; /**
} catch (error) { * Anonymizes a message ID
logger.error('[getShare] Error getting share link', error); * @param {string} id - The original message ID
throw new Error('Error getting share link'); * @returns {string} The anonymized message ID
*/
function anonymizeMessageId(id) {
return id === Constants.NO_PARENT ? id : `msg_${nanoid()}`;
}
/**
* Anonymizes a conversation object
* @param {object} conversation - The conversation object
* @returns {object} The anonymized conversation object
*/
function anonymizeConvo(conversation) {
const newConvo = { ...conversation };
if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId();
}
return newConvo;
}
/**
* Anonymizes messages in a conversation
* @param {TMessage[]} messages - The original messages
* @param {string} newConvoId - The new conversation ID
* @returns {TMessage[]} The anonymized messages
*/
function anonymizeMessages(messages, newConvoId) {
const idMap = new Map();
return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const anonymizedMessage = Object.assign(message, {
messageId: newMessageId,
parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId,
});
if (anonymizedMessage.model && anonymizedMessage.model.startsWith('asst_')) {
anonymizedMessage.model = anonymizeAssistantId();
} }
},
getSharedLinks: async (user, pageNumber = 1, pageSize = 25, isPublic = true) => { return anonymizedMessage;
const query = { user, isPublic }; });
try { }
const totalConvos = (await SharedLink.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize); /**
const shares = await SharedLink.find(query) * Retrieves shared messages for a given share ID
* @param {string} shareId - The share ID
* @returns {Promise<object|null>} The shared conversation data or null if not found
*/
async function getSharedMessages(shareId) {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share || !share.conversationId || !share.isPublic) {
return null;
}
const newConvoId = anonymizeConvoId();
return Object.assign(share, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
});
} catch (error) {
logger.error('[getShare] Error getting share link', error);
throw new Error('Error getting share link');
}
}
/**
* Retrieves shared links for a user
* @param {string} user - The user ID
* @param {number} [pageNumber=1] - The page number
* @param {number} [pageSize=25] - The page size
* @param {boolean} [isPublic=true] - Whether to retrieve public links only
* @returns {Promise<object>} The shared links and pagination data
*/
async function getSharedLinks(user, pageNumber = 1, pageSize = 25, isPublic = true) {
const query = { user, isPublic };
try {
const [totalConvos, sharedLinks] = await Promise.all([
SharedLink.countDocuments(query),
SharedLink.find(query)
.sort({ updatedAt: -1 }) .sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize) .skip((pageNumber - 1) * pageSize)
.limit(pageSize) .limit(pageSize)
.select('-_id -__v -user') .select('-_id -__v -user')
.lean(); .lean(),
]);
return { sharedLinks: shares, pages: totalPages, pageNumber, pageSize }; const totalPages = Math.ceil((totalConvos || 1) / pageSize);
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
throw new Error('Error getting shares');
}
},
createSharedLink: async (user, { conversationId, ...shareData }) => { return {
try { sharedLinks,
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean(); pages: totalPages,
if (share) { pageNumber,
return share; pageSize,
} };
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
throw new Error('Error getting shares');
}
}
const shareId = crypto.randomUUID(); /**
const messages = await getMessages({ conversationId }); * Creates a new shared link
const update = { ...shareData, shareId, messages, user }; * @param {string} user - The user ID
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, { * @param {object} shareData - The share data
new: true, * @param {string} shareData.conversationId - The conversation ID
upsert: true, * @returns {Promise<object>} The created shared link
*/
async function createSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(share);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
}); });
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', error);
throw new Error('Error creating shared link');
} }
},
updateSharedLink: async (user, { conversationId, ...shareData }) => { const shareId = nanoid();
try { const messages = await getMessages({ conversationId });
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean(); const update = { ...shareData, shareId, messages, user };
if (!share) { const newShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
return { message: 'Share not found' }; new: true,
} upsert: true,
}).lean();
// update messages to the latest const newConvoId = anonymizeConvoId();
const messages = await getMessages({ conversationId }); const sharedConvo = anonymizeConvo(newShare);
const update = { ...shareData, messages, user }; return Object.assign(sharedConvo, {
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, { conversationId: newConvoId,
new: true, messages: anonymizeMessages(newShare.messages, newConvoId),
upsert: false, });
}); } catch (error) {
} catch (error) { logger.error('[createSharedLink] Error creating shared link', error);
logger.error('[updateSharedLink] Error updating shared link', error); throw new Error('Error creating shared link');
throw new Error('Error updating shared link'); }
} }
},
deleteSharedLink: async (user, { shareId }) => { /**
try { * Updates an existing shared link
const share = await SharedLink.findOne({ shareId, user }); * @param {string} user - The user ID
if (!share) { * @param {object} shareData - The share data to update
return { message: 'Share not found' }; * @param {string} shareData.conversationId - The conversation ID
} * @returns {Promise<object>} The updated shared link
return await SharedLink.findOneAndDelete({ shareId, user }); */
} catch (error) { async function updateSharedLink(user, { conversationId, ...shareData }) {
logger.error('[deleteSharedLink] Error deleting shared link', error); try {
throw new Error('Error deleting shared link'); const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
} }
},
/** const messages = await getMessages({ conversationId });
* Deletes all shared links for a specific user. const update = { ...shareData, messages, user };
* @param {string} user - The user ID. const updatedShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
* @returns {Promise<{ message: string, deletedCount?: number }>} A result object indicating success or error message. new: true,
*/ upsert: false,
deleteAllSharedLinks: async (user) => { }).lean();
try {
const result = await SharedLink.deleteMany({ user }); const newConvoId = anonymizeConvoId();
return { const sharedConvo = anonymizeConvo(updatedShare);
message: 'All shared links have been deleted successfully', return Object.assign(sharedConvo, {
deletedCount: result.deletedCount, conversationId: newConvoId,
}; messages: anonymizeMessages(updatedShare.messages, newConvoId),
} catch (error) { });
logger.error('[deleteAllSharedLinks] Error deleting shared links', error); } catch (error) {
throw new Error('Error deleting shared links'); logger.error('[updateSharedLink] Error updating shared link', error);
} throw new Error('Error updating shared link');
}, }
}
/**
* Deletes a shared link
* @param {string} user - The user ID
* @param {object} params - The deletion parameters
* @param {string} params.shareId - The share ID to delete
* @returns {Promise<object>} The result of the deletion
*/
async function deleteSharedLink(user, { shareId }) {
try {
const result = await SharedLink.findOneAndDelete({ shareId, user });
return result ? { message: 'Share deleted successfully' } : { message: 'Share not found' };
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', error);
throw new Error('Error deleting shared link');
}
}
/**
* Deletes all shared links for a specific user
* @param {string} user - The user ID
* @returns {Promise<object>} The result of the deletion
*/
async function deleteAllSharedLinks(user) {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links have been deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
throw new Error('Error deleting shared links');
}
}
module.exports = {
SharedLink,
getSharedLinks,
createSharedLink,
updateSharedLink,
deleteSharedLink,
getSharedMessages,
deleteAllSharedLinks,
}; };

View file

@ -73,6 +73,7 @@
"module-alias": "^2.2.3", "module-alias": "^2.2.3",
"mongoose": "^7.1.1", "mongoose": "^7.1.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7",
"nodejs-gpt": "^1.37.4", "nodejs-gpt": "^1.37.4",
"nodemailer": "^6.9.4", "nodemailer": "^6.9.4",
"ollama": "^0.5.0", "ollama": "^0.5.0",

View file

@ -0,0 +1,112 @@
const jwt = require('jsonwebtoken');
const validateImageRequest = require('~/server/middleware/validateImageRequest');
describe('validateImageRequest middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
app: { locals: { secureImageLinks: true } },
headers: {},
originalUrl: '',
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret';
});
afterEach(() => {
jest.clearAllMocks();
});
test('should call next() if secureImageLinks is false', () => {
req.app.locals.secureImageLinks = false;
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 401 if refresh token is not provided', () => {
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
test('should return 403 if refresh token is invalid', () => {
req.headers.cookie = 'refreshToken=invalid-token';
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 403 if refresh token is expired', () => {
const expiredToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${expiredToken}`;
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should call next() for valid image path', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/example.jpg';
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid image path', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/456/example.jpg';
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
// File traversal tests
test('should prevent file traversal attempts', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
const traversalAttempts = [
'/images/123/../../../etc/passwd',
'/images/123/..%2F..%2F..%2Fetc%2Fpasswd',
'/images/123/image.jpg/../../../etc/passwd',
'/images/123/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd',
];
traversalAttempts.forEach((attempt) => {
req.originalUrl = attempt;
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
jest.clearAllMocks();
});
});
test('should handle URL encoded characters in valid paths', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/image%20with%20spaces.jpg';
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});

View file

@ -31,11 +31,12 @@ router.get('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid page size' }); return res.status(400).json({ error: 'Invalid page size' });
} }
const isArchived = req.query.isArchived === 'true'; const isArchived = req.query.isArchived === 'true';
const tags = req.query.tags let tags;
? Array.isArray(req.query.tags) if (req.query.tags) {
? req.query.tags tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
: [req.query.tags] } else {
: undefined; tags = undefined;
}
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags)); res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
}); });

View file

@ -41,20 +41,25 @@ function ChatView({ index = 0 }: { index?: number }) {
defaultValues: { text: '' }, defaultValues: { text: '' },
}); });
let content: JSX.Element | null | undefined;
if (isLoading && conversationId !== 'new') {
content = (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
);
} else if (messagesTree && messagesTree.length !== 0) {
content = <MessagesView messagesTree={messagesTree} Header={<Header />} />;
} else {
content = <Landing Header={<Header />} />;
}
return ( return (
<ChatFormProvider {...methods}> <ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}> <ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}> <AddedChatContext.Provider value={addedChatHelpers}>
<Presentation useSidePanel={true}> <Presentation useSidePanel={true}>
{isLoading && conversationId !== 'new' ? ( {content}
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent"> <div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} /> <ChatForm index={index} />
<Footer /> <Footer />

View file

@ -49,9 +49,9 @@ function SharedLinkDeleteButton({
<TooltipProvider delayDuration={250}> <TooltipProvider delayDuration={250}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span onClick={handleDelete}> <button id="delete-shared-link" aria-label="Delete shared link" onClick={handleDelete}>
<TrashIcon /> <TrashIcon />
</span> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" sideOffset={0}> <TooltipContent side="top" sideOffset={0}>
{localize('com_ui_delete')} {localize('com_ui_delete')}

View file

@ -18,52 +18,61 @@ function SharedView() {
// configure document title // configure document title
let docTitle = ''; let docTitle = '';
if (config?.appTitle && data?.title) { if (config?.appTitle != null && data?.title != null) {
docTitle = `${data?.title} | ${config.appTitle}`; docTitle = `${data.title} | ${config.appTitle}`;
} else { } else {
docTitle = data?.title || config?.appTitle || document.title; docTitle = data?.title ?? config?.appTitle ?? document.title;
} }
useDocumentTitle(docTitle); useDocumentTitle(docTitle);
let content: JSX.Element;
if (isLoading) {
content = (
<div className="flex h-screen items-center justify-center">
<Spinner className="" />
</div>
);
} else if (data && messagesTree && messagesTree.length !== 0) {
content = (
<>
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<h1 className="text-4xl font-bold">{data.title}</h1>
<div className="border-b border-border-medium pb-6 text-base text-text-secondary">
{new Date(data.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
</>
);
} else {
content = (
<div className="flex h-screen items-center justify-center ">
{localize('com_ui_shared_link_not_found')}
</div>
);
}
return ( return (
<ShareContext.Provider value={{ isSharedConvo: true }}> <ShareContext.Provider value={{ isSharedConvo: true }}>
<div <main
className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800" className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary"
style={{ paddingBottom: '50px' }} style={{ paddingBottom: '50px' }}
> >
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800"> <div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
<div className="flex h-full flex-col" role="presentation" tabIndex={0}> <div className="flex h-full flex-col text-text-primary" role="presentation">
{isLoading ? ( {content}
<div className="flex h-screen items-center justify-center"> <div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<Spinner className="" /> <Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-surface-secondary to-transparent px-2 pb-2 pt-8 text-xs text-text-secondary md:px-[60px]" />
</div>
) : data && messagesTree && messagesTree.length !== 0 ? (
<>
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<h1 className="text-4xl font-bold dark:text-white">{data.title}</h1>
<div className="border-b pb-6 text-base text-gray-300">
{new Date(data.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
</>
) : (
<div className="flex h-screen items-center justify-center">
{localize('com_ui_shared_link_not_found')}
</div>
)}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-gray-50 to-transparent px-2 pb-2 pt-8 text-xs text-gray-600 dark:from-gray-800 dark:text-gray-300 md:px-[60px]" />
</div> </div>
</div> </div>
</div> </div>
</div> </main>
</ShareContext.Provider> </ShareContext.Provider>
); );
} }

2656
package-lock.json generated

File diff suppressed because it is too large Load diff