diff --git a/api/models/Share.js b/api/models/Share.js deleted file mode 100644 index f8712c36a..000000000 --- a/api/models/Share.js +++ /dev/null @@ -1,346 +0,0 @@ -const { nanoid } = require('nanoid'); -const { logger } = require('@librechat/data-schemas'); -const { Constants } = require('librechat-data-provider'); -const { Conversation, SharedLink } = require('~/db/models'); -const { getMessages } = require('./Message'); - -class ShareServiceError extends Error { - constructor(message, code) { - super(message); - this.name = 'ShareServiceError'; - this.code = code; - } -} - -const memoizedAnonymizeId = (prefix) => { - const memo = new Map(); - return (id) => { - if (!memo.has(id)) { - memo.set(id, `${prefix}_${nanoid()}`); - } - return memo.get(id); - }; -}; - -const anonymizeConvoId = memoizedAnonymizeId('convo'); -const anonymizeAssistantId = memoizedAnonymizeId('a'); -const anonymizeMessageId = (id) => - id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id); - -function anonymizeConvo(conversation) { - if (!conversation) { - return null; - } - - const newConvo = { ...conversation }; - if (newConvo.assistant_id) { - newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id); - } - return newConvo; -} - -function anonymizeMessages(messages, newConvoId) { - if (!Array.isArray(messages)) { - return []; - } - - const idMap = new Map(); - return messages.map((message) => { - const newMessageId = anonymizeMessageId(message.messageId); - idMap.set(message.messageId, newMessageId); - - const anonymizedAttachments = message.attachments?.map((attachment) => { - return { - ...attachment, - messageId: newMessageId, - conversationId: newConvoId, - }; - }); - - return { - ...message, - messageId: newMessageId, - parentMessageId: - idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId), - conversationId: newConvoId, - model: message.model?.startsWith('asst_') - ? anonymizeAssistantId(message.model) - : message.model, - attachments: anonymizedAttachments, - }; - }); -} - -async function getSharedMessages(shareId) { - try { - const share = await SharedLink.findOne({ shareId, isPublic: true }) - .populate({ - path: 'messages', - select: '-_id -__v -user', - }) - .select('-_id -__v -user') - .lean(); - - if (!share?.conversationId || !share.isPublic) { - return null; - } - - const newConvoId = anonymizeConvoId(share.conversationId); - const result = { - ...share, - conversationId: newConvoId, - messages: anonymizeMessages(share.messages, newConvoId), - }; - - return result; - } catch (error) { - logger.error('[getShare] Error getting share link', { - error: error.message, - shareId, - }); - throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR'); - } -} - -async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) { - try { - const query = { user, isPublic }; - - if (pageParam) { - if (sortDirection === 'desc') { - query[sortBy] = { $lt: pageParam }; - } else { - query[sortBy] = { $gt: pageParam }; - } - } - - if (search && search.trim()) { - try { - const searchResults = await Conversation.meiliSearch(search); - - if (!searchResults?.hits?.length) { - return { - links: [], - nextCursor: undefined, - hasNextPage: false, - }; - } - - const conversationIds = searchResults.hits.map((hit) => hit.conversationId); - query['conversationId'] = { $in: conversationIds }; - } catch (searchError) { - logger.error('[getSharedLinks] Meilisearch error', { - error: searchError.message, - user, - }); - return { - links: [], - nextCursor: undefined, - hasNextPage: false, - }; - } - } - - const sort = {}; - sort[sortBy] = sortDirection === 'desc' ? -1 : 1; - - if (Array.isArray(query.conversationId)) { - query.conversationId = { $in: query.conversationId }; - } - - const sharedLinks = await SharedLink.find(query) - .sort(sort) - .limit(pageSize + 1) - .select('-__v -user') - .lean(); - - const hasNextPage = sharedLinks.length > pageSize; - const links = sharedLinks.slice(0, pageSize); - - const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined; - - return { - links: links.map((link) => ({ - shareId: link.shareId, - title: link?.title || 'Untitled', - isPublic: link.isPublic, - createdAt: link.createdAt, - conversationId: link.conversationId, - })), - nextCursor, - hasNextPage, - }; - } catch (error) { - logger.error('[getSharedLinks] Error getting shares', { - error: error.message, - user, - }); - throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR'); - } -} - -async function deleteAllSharedLinks(user) { - try { - const result = await SharedLink.deleteMany({ user }); - return { - message: 'All shared links deleted successfully', - deletedCount: result.deletedCount, - }; - } catch (error) { - logger.error('[deleteAllSharedLinks] Error deleting shared links', { - error: error.message, - user, - }); - throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR'); - } -} - -async function createSharedLink(user, conversationId) { - if (!user || !conversationId) { - throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); - } - try { - const [existingShare, conversationMessages] = await Promise.all([ - SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(), - getMessages({ conversationId }), - ]); - - if (existingShare && existingShare.isPublic) { - throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); - } else if (existingShare) { - await SharedLink.deleteOne({ conversationId }); - } - - const conversation = await Conversation.findOne({ conversationId }).lean(); - const title = conversation?.title || 'Untitled'; - - const shareId = nanoid(); - await SharedLink.create({ - shareId, - conversationId, - messages: conversationMessages, - title, - user, - }); - - return { shareId, conversationId }; - } catch (error) { - logger.error('[createSharedLink] Error creating shared link', { - error: error.message, - user, - conversationId, - }); - throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); - } -} - -async function getSharedLink(user, conversationId) { - if (!user || !conversationId) { - throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); - } - - try { - const share = await SharedLink.findOne({ conversationId, user, isPublic: true }) - .select('shareId -_id') - .lean(); - - if (!share) { - return { shareId: null, success: false }; - } - - return { shareId: share.shareId, success: true }; - } catch (error) { - logger.error('[getSharedLink] Error getting shared link', { - error: error.message, - user, - conversationId, - }); - throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR'); - } -} - -async function updateSharedLink(user, shareId) { - if (!user || !shareId) { - throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); - } - - try { - const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean(); - - if (!share) { - throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND'); - } - - const [updatedMessages] = await Promise.all([ - getMessages({ conversationId: share.conversationId }), - ]); - - const newShareId = nanoid(); - const update = { - messages: updatedMessages, - user, - shareId: newShareId, - }; - - const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, { - new: true, - upsert: false, - runValidators: true, - }).lean(); - - if (!updatedShare) { - throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR'); - } - - anonymizeConvo(updatedShare); - - return { shareId: newShareId, conversationId: updatedShare.conversationId }; - } catch (error) { - logger.error('[updateSharedLink] Error updating shared link', { - error: error.message, - user, - shareId, - }); - throw new ShareServiceError( - error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link', - error.code || 'SHARE_UPDATE_ERROR', - ); - } -} - -async function deleteSharedLink(user, shareId) { - if (!user || !shareId) { - throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); - } - - try { - const result = await SharedLink.findOneAndDelete({ shareId, user }).lean(); - - if (!result) { - return null; - } - - return { - success: true, - shareId, - message: 'Share deleted successfully', - }; - } catch (error) { - logger.error('[deleteSharedLink] Error deleting shared link', { - error: error.message, - user, - shareId, - }); - throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR'); - } -} - -module.exports = { - getSharedLink, - getSharedLinks, - createSharedLink, - updateSharedLink, - deleteSharedLink, - getSharedMessages, - deleteAllSharedLinks, -}; diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index 4577d2015..bcffb2189 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -21,8 +21,8 @@ const { verifyEmail, resendVerificationEmail } = require('~/server/services/Auth const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud'); const { processDeleteRequest } = require('~/server/services/Files/process'); const { Transaction, Balance, User } = require('~/db/models'); -const { deleteAllSharedLinks } = require('~/models/Share'); const { deleteToolCalls } = require('~/models/ToolCall'); +const { deleteAllSharedLinks } = require('~/models'); const getUserController = async (req, res) => { /** @type {MongoUser} */ diff --git a/api/server/middleware/validate/convoAccess.js b/api/server/middleware/validate/convoAccess.js index 43cca0097..afd2aeace 100644 --- a/api/server/middleware/validate/convoAccess.js +++ b/api/server/middleware/validate/convoAccess.js @@ -1,8 +1,8 @@ +const { isEnabled } = require('@librechat/api'); const { Constants, ViolationTypes, Time } = require('librechat-data-provider'); const { searchConversation } = require('~/models/Conversation'); const denyRequest = require('~/server/middleware/denyRequest'); const { logViolation, getLogStores } = require('~/cache'); -const { isEnabled } = require('~/server/utils'); const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {}; diff --git a/api/server/routes/share.js b/api/server/routes/share.js index e551f4a35..14c25271f 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -1,15 +1,15 @@ const express = require('express'); - +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { - getSharedLink, getSharedMessages, createSharedLink, updateSharedLink, - getSharedLinks, deleteSharedLink, -} = require('~/models/Share'); + getSharedLinks, + getSharedLink, +} = require('~/models'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); -const { isEnabled } = require('~/server/utils'); const router = express.Router(); /** @@ -35,6 +35,7 @@ if (allowSharedLinks) { res.status(404).end(); } } catch (error) { + logger.error('Error getting shared messages:', error); res.status(500).json({ message: 'Error getting shared messages' }); } }, @@ -54,9 +55,7 @@ router.get('/', requireJwtAuth, async (req, res) => { sortDirection: ['asc', 'desc'].includes(req.query.sortDirection) ? req.query.sortDirection : 'desc', - search: req.query.search - ? decodeURIComponent(req.query.search.trim()) - : undefined, + search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined, }; const result = await getSharedLinks( @@ -75,7 +74,7 @@ router.get('/', requireJwtAuth, async (req, res) => { hasNextPage: result.hasNextPage, }); } catch (error) { - console.error('Error getting shared links:', error); + logger.error('Error getting shared links:', error); res.status(500).json({ message: 'Error getting shared links', error: error.message, @@ -93,6 +92,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { conversationId: req.params.conversationId, }); } catch (error) { + logger.error('Error getting shared link:', error); res.status(500).json({ message: 'Error getting shared link' }); } }); @@ -106,6 +106,7 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => { res.status(404).end(); } } catch (error) { + logger.error('Error creating shared link:', error); res.status(500).json({ message: 'Error creating shared link' }); } }); @@ -119,6 +120,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => { res.status(404).end(); } } catch (error) { + logger.error('Error updating shared link:', error); res.status(500).json({ message: 'Error updating shared link' }); } }); @@ -133,7 +135,8 @@ router.delete('/:shareId', requireJwtAuth, async (req, res) => { return res.status(200).json(result); } catch (error) { - return res.status(400).json({ message: error.message }); + logger.error('Error deleting shared link:', error); + return res.status(400).json({ message: 'Error deleting shared link' }); } }); diff --git a/package-lock.json b/package-lock.json index 62ff31176..50dacbb5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2142,27 +2142,6 @@ "node": ">= 0.6" } }, - "api/node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "api/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -27220,9 +27199,10 @@ } }, "node_modules/bson": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", - "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", "engines": { "node": ">=16.20.1" } @@ -31002,15 +30982,16 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -36978,11 +36959,57 @@ "node": "*" } }, + "node_modules/mongodb": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", + "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/mongodb-connection-string-url": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", - "peer": true, "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" @@ -36992,7 +37019,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -37004,7 +37030,6 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -37013,6 +37038,69 @@ "node": ">=18" } }, + "node_modules/mongodb-memory-server": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.1.4.tgz", + "integrity": "sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "10.1.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.1.4.tgz", + "integrity": "sha512-o8fgY7ZalEd8pGps43fFPr/hkQu1L8i6HFEGbsTfA2zDOW0TopgpswaBCqDr0qD7ptibyPfB5DmC+UlIxbThzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.3.7", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.9", + "https-proxy-agent": "^7.0.5", + "mongodb": "^6.9.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.6.3", + "tar-stream": "^3.1.7", + "tslib": "^2.7.0", + "yauzl": "^3.1.3" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", @@ -46196,7 +46284,7 @@ "passport-facebook": "^3.0.0" }, "devDependencies": { - "@librechat/data-schemas": "^0.0.8", + "@librechat/data-schemas": "^0.0.9", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.1.0", @@ -46364,7 +46452,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.8", + "version": "0.0.9", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", @@ -46381,6 +46469,7 @@ "@types/traverse": "^0.6.37", "jest": "^29.5.0", "jest-junit": "^16.0.0", + "mongodb-memory-server": "^10.1.4", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-generate-package-json": "^3.2.0", @@ -46397,6 +46486,7 @@ "lodash": "^4.17.21", "meilisearch": "^0.38.0", "mongoose": "^8.12.1", + "nanoid": "^3.3.7", "traverse": "^0.6.11", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0" diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 8d625fa83..9234db403 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/data-schemas", - "version": "0.0.8", + "version": "0.0.9", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs", @@ -51,6 +51,7 @@ "@types/traverse": "^0.6.37", "jest": "^29.5.0", "jest-junit": "^16.0.0", + "mongodb-memory-server": "^10.1.4", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-generate-package-json": "^3.2.0", @@ -60,13 +61,14 @@ "typescript": "^5.0.4" }, "peerDependencies": { - "keyv": "^5.3.2", - "mongoose": "^8.12.1", - "librechat-data-provider": "*", "jsonwebtoken": "^9.0.2", + "keyv": "^5.3.2", "klona": "^2.0.6", + "librechat-data-provider": "*", "lodash": "^4.17.21", "meilisearch": "^0.38.0", + "mongoose": "^8.12.1", + "nanoid": "^3.3.7", "traverse": "^0.6.11", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0" diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 33dcdc918..67f8255c8 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -4,6 +4,7 @@ import { createTokenMethods, type TokenMethods } from './token'; import { createRoleMethods, type RoleMethods } from './role'; /* Memories */ import { createMemoryMethods, type MemoryMethods } from './memory'; +import { createShareMethods, type ShareMethods } from './share'; /** * Creates all database methods for all collections @@ -15,8 +16,14 @@ export function createMethods(mongoose: typeof import('mongoose')) { ...createTokenMethods(mongoose), ...createRoleMethods(mongoose), ...createMemoryMethods(mongoose), + ...createShareMethods(mongoose), }; } -export type { MemoryMethods }; -export type AllMethods = UserMethods & SessionMethods & TokenMethods & RoleMethods & MemoryMethods; +export type { MemoryMethods, ShareMethods }; +export type AllMethods = UserMethods & + SessionMethods & + TokenMethods & + RoleMethods & + MemoryMethods & + ShareMethods; diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts new file mode 100644 index 000000000..45b7faeb1 --- /dev/null +++ b/packages/data-schemas/src/methods/share.test.ts @@ -0,0 +1,1043 @@ +import { nanoid } from 'nanoid'; +import mongoose from 'mongoose'; +import { Constants } from 'librechat-data-provider'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createShareMethods, type ShareMethods } from './share'; +import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; +import type * as t from '~/types'; + +describe('Share Methods', () => { + let mongoServer: MongoMemoryServer; + let shareMethods: ShareMethods; + let SharedLink: mongoose.Model; + let Message: mongoose.Model; + let Conversation: SchemaWithMeiliMethods; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + + // Create schemas + const sharedLinkSchema = new mongoose.Schema( + { + conversationId: { type: String, required: true }, + title: { type: String, index: true }, + user: { type: String, index: true }, + messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }], + shareId: { type: String, index: true }, + isPublic: { type: Boolean, default: true }, + }, + { timestamps: true }, + ); + + const messageSchema = new mongoose.Schema( + { + messageId: { type: String, required: true }, + conversationId: { type: String, required: true }, + user: { type: String, required: true }, + text: String, + isCreatedByUser: Boolean, + model: String, + parentMessageId: String, + attachments: [mongoose.Schema.Types.Mixed], + content: [mongoose.Schema.Types.Mixed], + }, + { timestamps: true }, + ); + + const conversationSchema = new mongoose.Schema( + { + conversationId: { type: String, required: true }, + title: String, + user: String, + }, + { timestamps: true }, + ); + + // Register models + SharedLink = + mongoose.models.SharedLink || mongoose.model('SharedLink', sharedLinkSchema); + Message = mongoose.models.Message || mongoose.model('Message', messageSchema); + Conversation = (mongoose.models.Conversation || + mongoose.model( + 'Conversation', + conversationSchema, + )) as SchemaWithMeiliMethods; + + // Create share methods + shareMethods = createShareMethods(mongoose); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await SharedLink.deleteMany({}); + await Message.deleteMany({}); + await Conversation.deleteMany({}); + }); + + describe('createSharedLink', () => { + test('should create a new shared link', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + // Create test conversation + await Conversation.create({ + conversationId, + title: 'Test Conversation', + user: userId, + }); + + // Create test messages + await Message.create([ + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Hello', + isCreatedByUser: true, + }, + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'World', + isCreatedByUser: false, + model: 'gpt-4', + }, + ]); + + const result = await shareMethods.createSharedLink(userId, conversationId); + + expect(result).toBeDefined(); + expect(result.shareId).toBeDefined(); + expect(result.conversationId).toBe(conversationId); + + // Verify the share was created in the database + const savedShare = await SharedLink.findOne({ shareId: result.shareId }); + expect(savedShare).toBeDefined(); + expect(savedShare?.user).toBe(userId); + expect(savedShare?.title).toBe('Test Conversation'); + expect(savedShare?.messages).toHaveLength(2); + }); + + test('should throw error if share already exists', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + await Conversation.create({ + conversationId, + title: 'Test Conversation', + user: userId, + }); + + // Create messages so we can create a share + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Test message', + isCreatedByUser: true, + }); + + // Create first share + await shareMethods.createSharedLink(userId, conversationId); + + // Try to create duplicate + await expect(shareMethods.createSharedLink(userId, conversationId)).rejects.toThrow( + 'Share already exists', + ); + }); + + test('should throw error with missing parameters', async () => { + await expect(shareMethods.createSharedLink('', 'conv123')).rejects.toThrow( + 'Missing required parameters', + ); + + await expect(shareMethods.createSharedLink('user123', '')).rejects.toThrow( + 'Missing required parameters', + ); + }); + + test('should only include messages from the same user', async () => { + const userId1 = new mongoose.Types.ObjectId().toString(); + const userId2 = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + await Conversation.create({ + conversationId, + title: 'Test Conversation', + user: userId1, + }); + + // Create messages from different users + await Message.create([ + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId1, + text: 'User 1 message', + isCreatedByUser: true, + }, + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId2, + text: 'User 2 message', + isCreatedByUser: true, + }, + ]); + + const result = await shareMethods.createSharedLink(userId1, conversationId); + + const savedShare = await SharedLink.findOne({ shareId: result.shareId }).populate('messages'); + expect(savedShare?.messages).toHaveLength(1); + expect((savedShare?.messages?.[0] as unknown as t.IMessage | undefined)?.text).toBe( + 'User 1 message', + ); + }); + + test('should not allow user to create shared link for conversation they do not own', async () => { + const ownerUserId = new mongoose.Types.ObjectId().toString(); + const otherUserId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + // Create conversation owned by ownerUserId + await Conversation.create({ + conversationId, + title: 'Owner Conversation', + user: ownerUserId, + }); + + // Create messages for the conversation + await Message.create([ + { + messageId: `msg_${nanoid()}`, + conversationId, + user: ownerUserId, + text: 'Owner message', + isCreatedByUser: true, + }, + ]); + + // Try to create a shared link as a different user + await expect(shareMethods.createSharedLink(otherUserId, conversationId)).rejects.toThrow( + 'Conversation not found or access denied', + ); + + // Verify no share was created + const shares = await SharedLink.find({ conversationId }); + expect(shares).toHaveLength(0); + }); + + test('should not allow creating share for conversation with no messages', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + // Create conversation without any messages + await Conversation.create({ + conversationId, + title: 'Empty Conversation', + user: userId, + }); + + // Try to create a shared link for conversation with no messages + await expect(shareMethods.createSharedLink(userId, conversationId)).rejects.toThrow( + 'No messages to share', + ); + + // Verify no share was created + const shares = await SharedLink.find({ conversationId }); + expect(shares).toHaveLength(0); + }); + }); + + describe('getSharedMessages', () => { + test('should retrieve and anonymize shared messages', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + // Create messages + const messages = await Message.create([ + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Hello', + isCreatedByUser: true, + parentMessageId: Constants.NO_PARENT, + }, + { + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'World', + isCreatedByUser: false, + model: 'gpt-4', + parentMessageId: Constants.NO_PARENT, + }, + ]); + + // Create shared link + await SharedLink.create({ + shareId, + conversationId, + user: userId, + title: 'Test Share', + messages: messages.map((m) => m._id), + isPublic: true, + }); + + const result = await shareMethods.getSharedMessages(shareId); + + expect(result).toBeDefined(); + expect(result?.shareId).toBe(shareId); + expect(result?.conversationId).not.toBe(conversationId); // Should be anonymized + expect(result?.messages).toHaveLength(2); + + // Check anonymization + result?.messages.forEach((msg) => { + expect(msg.messageId).toMatch(/^msg_/); // Should be anonymized with msg_ prefix + expect(msg.messageId).not.toBe(messages[0].messageId); // Should be different from original + expect(msg.conversationId).toBe(result.conversationId); + expect(msg.user).toBeUndefined(); // User should be removed + }); + }); + + test('should return null for non-public share', async () => { + const shareId = `share_${nanoid()}`; + + await SharedLink.create({ + shareId, + conversationId: 'conv123', + user: 'user123', + isPublic: false, + }); + + const result = await shareMethods.getSharedMessages(shareId); + expect(result).toBeNull(); + }); + + test('should return null for non-existent share', async () => { + const result = await shareMethods.getSharedMessages('non_existent_share'); + expect(result).toBeNull(); + }); + + test('should handle messages with attachments', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + const message = await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Message with attachment', + isCreatedByUser: true, + attachments: [ + { + file_id: 'file123', + filename: 'test.pdf', + type: 'application/pdf', + }, + ], + }); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [message._id], + isPublic: true, + }); + + const result = await shareMethods.getSharedMessages(shareId); + + expect(result?.messages[0].attachments).toHaveLength(1); + expect( + (result?.messages[0].attachments?.[0] as unknown as t.IMessage | undefined)?.messageId, + ).toBe(result?.messages[0].messageId); + expect( + (result?.messages[0].attachments?.[0] as unknown as t.IMessage | undefined)?.conversationId, + ).toBe(result?.conversationId); + }); + }); + + describe('getSharedLinks', () => { + test('should retrieve paginated shared links for a user', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + + // Create multiple shared links + const sharePromises = Array.from({ length: 15 }, (_, i) => + SharedLink.create({ + shareId: `share_${i}`, + conversationId: `conv_${i}`, + user: userId, + title: `Share ${i}`, + isPublic: true, + createdAt: new Date(Date.now() - i * 1000 * 60), // Different timestamps + }), + ); + + await Promise.all(sharePromises); + + const result = await shareMethods.getSharedLinks(userId, undefined, 10); + + expect(result.links).toHaveLength(10); + expect(result.hasNextPage).toBe(true); + expect(result.nextCursor).toBeDefined(); + + // Check ordering (newest first by default) + expect(result.links[0].title).toBe('Share 0'); + expect(result.links[9].title).toBe('Share 9'); + }); + + test('should filter by isPublic parameter', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + + await SharedLink.create([ + { + shareId: 'public_share', + conversationId: 'conv1', + user: userId, + title: 'Public Share', + isPublic: true, + }, + { + shareId: 'private_share', + conversationId: 'conv2', + user: userId, + title: 'Private Share', + isPublic: false, + }, + ]); + + const publicResults = await shareMethods.getSharedLinks(userId, undefined, 10, true); + const privateResults = await shareMethods.getSharedLinks(userId, undefined, 10, false); + + expect(publicResults.links).toHaveLength(1); + expect(publicResults.links[0].title).toBe('Public Share'); + + expect(privateResults.links).toHaveLength(1); + expect(privateResults.links[0].title).toBe('Private Share'); + }); + + test('should handle search with mocked meiliSearch', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + + // Mock meiliSearch method + Conversation.meiliSearch = jest.fn().mockResolvedValue({ + hits: [{ conversationId: 'conv1' }], + }); + + await SharedLink.create([ + { + shareId: 'share1', + conversationId: 'conv1', + user: userId, + title: 'Matching Share', + isPublic: true, + }, + { + shareId: 'share2', + conversationId: 'conv2', + user: userId, + title: 'Non-matching Share', + isPublic: true, + }, + ]); + + const result = await shareMethods.getSharedLinks( + userId, + undefined, + 10, + true, + 'createdAt', + 'desc', + 'search term', + ); + + expect(result.links).toHaveLength(1); + expect(result.links[0].title).toBe('Matching Share'); + }); + + test('should handle empty results', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const result = await shareMethods.getSharedLinks(userId); + + expect(result.links).toHaveLength(0); + expect(result.hasNextPage).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); + + test('should only return shares for the specified user', async () => { + const userId1 = new mongoose.Types.ObjectId().toString(); + const userId2 = new mongoose.Types.ObjectId().toString(); + + // Create shares for different users + await SharedLink.create([ + { + shareId: 'share1', + conversationId: 'conv1', + user: userId1, + title: 'User 1 Share', + isPublic: true, + }, + { + shareId: 'share2', + conversationId: 'conv2', + user: userId2, + title: 'User 2 Share', + isPublic: true, + }, + { + shareId: 'share3', + conversationId: 'conv3', + user: userId1, + title: 'Another User 1 Share', + isPublic: true, + }, + ]); + + const result1 = await shareMethods.getSharedLinks(userId1); + const result2 = await shareMethods.getSharedLinks(userId2); + + expect(result1.links).toHaveLength(2); + expect(result1.links.every((link) => link.title.includes('User 1'))).toBe(true); + + expect(result2.links).toHaveLength(1); + expect(result2.links[0].title).toBe('User 2 Share'); + }); + }); + + describe('updateSharedLink', () => { + test('should update shared link with new messages', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const oldShareId = `share_${nanoid()}`; + + // Create initial messages + const initialMessages = await Message.create([ + { + messageId: `msg_1`, + conversationId, + user: userId, + text: 'Initial message', + isCreatedByUser: true, + }, + ]); + + // Create shared link + await SharedLink.create({ + shareId: oldShareId, + conversationId, + user: userId, + messages: initialMessages.map((m) => m._id), + isPublic: true, + }); + + // Add new message + await Message.create({ + messageId: `msg_2`, + conversationId, + user: userId, + text: 'New message', + isCreatedByUser: false, + }); + + const result = await shareMethods.updateSharedLink(userId, oldShareId); + + expect(result.shareId).not.toBe(oldShareId); // Should generate new shareId + expect(result.conversationId).toBe(conversationId); + + // Verify updated share + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }).populate( + 'messages', + ); + expect(updatedShare?.messages).toHaveLength(2); + }); + + test('should throw error if share not found', async () => { + await expect(shareMethods.updateSharedLink('user123', 'non_existent')).rejects.toThrow( + 'Share not found', + ); + }); + + test('should throw error with missing parameters', async () => { + await expect(shareMethods.updateSharedLink('', 'share123')).rejects.toThrow( + 'Missing required parameters', + ); + }); + + test('should only update with messages from the same user', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const otherUserId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + // Create initial share + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [], + isPublic: true, + }); + + // Add messages from different users + await Message.create([ + { + messageId: `msg_1`, + conversationId, + user: userId, + text: 'User message', + isCreatedByUser: true, + }, + { + messageId: `msg_2`, + conversationId, + user: otherUserId, + text: 'Other user message', + isCreatedByUser: true, + }, + ]); + + const result = await shareMethods.updateSharedLink(userId, shareId); + + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }).populate( + 'messages', + ); + expect(updatedShare?.messages).toHaveLength(1); + expect((updatedShare?.messages?.[0] as unknown as t.IMessage | undefined)?.text).toBe( + 'User message', + ); + }); + + test('should not allow user to update shared link they do not own', async () => { + const ownerUserId = new mongoose.Types.ObjectId().toString(); + const otherUserId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + // Create shared link owned by ownerUserId + await SharedLink.create({ + shareId, + conversationId, + user: ownerUserId, + messages: [], + isPublic: true, + }); + + // Try to update as a different user + await expect(shareMethods.updateSharedLink(otherUserId, shareId)).rejects.toThrow( + 'Share not found', + ); + + // Verify the original share still exists and is unchanged + const originalShare = await SharedLink.findOne({ shareId }); + expect(originalShare).toBeDefined(); + expect(originalShare?.user).toBe(ownerUserId); + }); + }); + + describe('deleteSharedLink', () => { + test('should delete shared link', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const shareId = `share_${nanoid()}`; + + await SharedLink.create({ + shareId, + conversationId: 'conv123', + user: userId, + isPublic: true, + }); + + const result = await shareMethods.deleteSharedLink(userId, shareId); + + expect(result).toBeDefined(); + expect(result?.success).toBe(true); + expect(result?.shareId).toBe(shareId); + + // Verify deletion + const deletedShare = await SharedLink.findOne({ shareId }); + expect(deletedShare).toBeNull(); + }); + + test('should return null if share not found', async () => { + const result = await shareMethods.deleteSharedLink('user123', 'non_existent'); + expect(result).toBeNull(); + }); + + test('should not delete share from different user', async () => { + const userId1 = new mongoose.Types.ObjectId().toString(); + const userId2 = new mongoose.Types.ObjectId().toString(); + const shareId = `share_${nanoid()}`; + + await SharedLink.create({ + shareId, + conversationId: 'conv123', + user: userId1, + isPublic: true, + }); + + const result = await shareMethods.deleteSharedLink(userId2, shareId); + expect(result).toBeNull(); + + // Verify share still exists + const share = await SharedLink.findOne({ shareId }); + expect(share).toBeDefined(); + }); + + test('should handle missing parameters for deleteSharedLink', async () => { + await expect(shareMethods.deleteSharedLink('', 'share123')).rejects.toThrow( + 'Missing required parameters', + ); + + await expect(shareMethods.deleteSharedLink('user123', '')).rejects.toThrow( + 'Missing required parameters', + ); + }); + }); + + describe('getSharedLink', () => { + test('should retrieve existing shared link', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + isPublic: true, + }); + + const result = await shareMethods.getSharedLink(userId, conversationId); + + expect(result.success).toBe(true); + expect(result.shareId).toBe(shareId); + }); + + test('should return null shareId if not found', async () => { + const result = await shareMethods.getSharedLink('user123', 'conv123'); + + expect(result.success).toBe(false); + expect(result.shareId).toBeNull(); + }); + + test('should not return share from different user', async () => { + const userId1 = new mongoose.Types.ObjectId().toString(); + const userId2 = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + await SharedLink.create({ + shareId: 'share123', + conversationId, + user: userId1, + isPublic: true, + }); + + const result = await shareMethods.getSharedLink(userId2, conversationId); + + expect(result.success).toBe(false); + expect(result.shareId).toBeNull(); + }); + + test('should handle missing parameters for getSharedLink', async () => { + await expect(shareMethods.getSharedLink('', 'conv123')).rejects.toThrow( + 'Missing required parameters', + ); + + await expect(shareMethods.getSharedLink('user123', '')).rejects.toThrow( + 'Missing required parameters', + ); + }); + + test('should only return public shares', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + // Create a non-public share + await SharedLink.create({ + shareId, + conversationId, + user: userId, + isPublic: false, + }); + + const result = await shareMethods.getSharedLink(userId, conversationId); + + expect(result.success).toBe(false); + expect(result.shareId).toBeNull(); + }); + }); + + describe('deleteAllSharedLinks', () => { + test('should delete all shared links for a user', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const otherUserId = new mongoose.Types.ObjectId().toString(); + + // Create shares for different users + await SharedLink.create([ + { shareId: 'share1', conversationId: 'conv1', user: userId }, + { shareId: 'share2', conversationId: 'conv2', user: userId }, + { shareId: 'share3', conversationId: 'conv3', user: otherUserId }, + ]); + + const result = await shareMethods.deleteAllSharedLinks(userId); + + expect(result.deletedCount).toBe(2); + expect(result.message).toContain('successfully'); + + // Verify only user's shares were deleted + const remainingShares = await SharedLink.find({}); + expect(remainingShares).toHaveLength(1); + expect(remainingShares[0].user).toBe(otherUserId); + }); + + test('should handle when no shares exist', async () => { + const result = await shareMethods.deleteAllSharedLinks('user123'); + + expect(result.deletedCount).toBe(0); + expect(result.message).toContain('successfully'); + }); + + test('should only delete shares belonging to the specified user', async () => { + const userId1 = new mongoose.Types.ObjectId().toString(); + const userId2 = new mongoose.Types.ObjectId().toString(); + const userId3 = new mongoose.Types.ObjectId().toString(); + + // Create multiple shares for different users + await SharedLink.create([ + { shareId: 'share1', conversationId: 'conv1', user: userId1, isPublic: true }, + { shareId: 'share2', conversationId: 'conv2', user: userId1, isPublic: false }, + { shareId: 'share3', conversationId: 'conv3', user: userId2, isPublic: true }, + { shareId: 'share4', conversationId: 'conv4', user: userId2, isPublic: true }, + { shareId: 'share5', conversationId: 'conv5', user: userId3, isPublic: true }, + ]); + + // Delete all shares for userId1 + const result = await shareMethods.deleteAllSharedLinks(userId1); + expect(result.deletedCount).toBe(2); + + // Verify shares for other users still exist + const remainingShares = await SharedLink.find({}); + expect(remainingShares).toHaveLength(3); + expect(remainingShares.every((share) => share.user !== userId1)).toBe(true); + + // Verify specific users' shares remain + const user2Shares = await SharedLink.find({ user: userId2 }); + expect(user2Shares).toHaveLength(2); + + const user3Shares = await SharedLink.find({ user: userId3 }); + expect(user3Shares).toHaveLength(1); + }); + }); + + describe('Edge Cases and Error Handling', () => { + test('should handle conversation with special characters in ID', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = 'conv|with|pipes'; + + await Conversation.create({ + conversationId, + title: 'Special Conversation', + user: userId, + }); + + // Create a message so we can create a share + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Test message', + isCreatedByUser: true, + }); + + const result = await shareMethods.createSharedLink(userId, conversationId); + + expect(result).toBeDefined(); + expect(result.conversationId).toBe(conversationId); + }); + + test('should handle messages with assistant_id', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + const message = await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Assistant message', + isCreatedByUser: false, + model: 'asst_123456', + }); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [message._id], + isPublic: true, + }); + + const result = await shareMethods.getSharedMessages(shareId); + + expect(result?.messages[0].model).toMatch(/^a_/); // Should be anonymized + }); + + test('should handle concurrent operations', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationIds = Array.from({ length: 5 }, () => `conv_${nanoid()}`); + + // Create conversations and messages + await Promise.all( + conversationIds.map(async (id) => { + await Conversation.create({ + conversationId: id, + title: `Conversation ${id}`, + user: userId, + }); + // Create a message for each conversation + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId: id, + user: userId, + text: `Message for ${id}`, + isCreatedByUser: true, + }); + }), + ); + + // Concurrent share creation + const createPromises = conversationIds.map((id) => shareMethods.createSharedLink(userId, id)); + + const results = await Promise.all(createPromises); + + expect(results).toHaveLength(5); + results.forEach((result, index) => { + expect(result.shareId).toBeDefined(); + expect(result.conversationId).toBe(conversationIds[index]); + }); + }); + + test('should handle database errors gracefully', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + // Create conversation and message first + await Conversation.create({ + conversationId, + title: 'Test Conversation', + user: userId, + }); + + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Test message', + isCreatedByUser: true, + }); + + // Mock a database error + const originalCreate = SharedLink.create; + SharedLink.create = jest.fn().mockRejectedValue(new Error('Database error')); + + await expect(shareMethods.createSharedLink(userId, conversationId)).rejects.toThrow( + 'Error creating shared link', + ); + + SharedLink.create = originalCreate; + }); + }); + + describe('Anonymization', () => { + beforeEach(() => { + // Ensure any mocks are restored before each test + jest.restoreAllMocks(); + }); + + test('should consistently anonymize IDs', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + const messageId1 = `msg_${nanoid()}`; + const messageId2 = `msg_${nanoid()}`; + + const messages = await Message.create([ + { + messageId: messageId1, + conversationId, + user: userId, + text: 'First message', + isCreatedByUser: true, + parentMessageId: Constants.NO_PARENT, + }, + { + messageId: messageId2, + conversationId, + user: userId, + text: 'Second message', + isCreatedByUser: false, + parentMessageId: messageId1, // Reference to first message + }, + ]); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: messages.map((m) => m._id), + isPublic: true, + }); + + const result = await shareMethods.getSharedMessages(shareId); + + // Check that anonymization is consistent within the same result + expect(result?.messages).toHaveLength(2); + + // The second message's parentMessageId should match the first message's anonymized ID + expect(result?.messages[1].parentMessageId).toBe(result?.messages[0].messageId); + + // Both messages should have the same anonymized conversationId + expect(result?.messages[0].conversationId).toBe(result?.conversationId); + expect(result?.messages[1].conversationId).toBe(result?.conversationId); + }); + + test('should handle NO_PARENT constant correctly', async () => { + const { Constants } = await import('librechat-data-provider'); + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + + const message = await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Root message', + isCreatedByUser: true, + parentMessageId: Constants.NO_PARENT, + }); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [message._id], + isPublic: true, + }); + + const result = await shareMethods.getSharedMessages(shareId); + + expect(result?.messages[0].parentMessageId).toBe(Constants.NO_PARENT); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts new file mode 100644 index 000000000..7c16ead20 --- /dev/null +++ b/packages/data-schemas/src/methods/share.ts @@ -0,0 +1,442 @@ +import { nanoid } from 'nanoid'; +import { Constants } from 'librechat-data-provider'; +import type { FilterQuery, Model } from 'mongoose'; +import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; +import type * as t from '~/types'; +import logger from '~/config/winston'; + +class ShareServiceError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.name = 'ShareServiceError'; + this.code = code; + } +} + +function memoizedAnonymizeId(prefix: string) { + const memo = new Map(); + return (id: string) => { + if (!memo.has(id)) { + memo.set(id, `${prefix}_${nanoid()}`); + } + return memo.get(id) as string; + }; +} + +const anonymizeConvoId = memoizedAnonymizeId('convo'); +const anonymizeAssistantId = memoizedAnonymizeId('a'); +const anonymizeMessageId = (id: string) => + id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id); + +function anonymizeConvo(conversation: Partial & Partial) { + if (!conversation) { + return null; + } + + const newConvo = { ...conversation }; + if (newConvo.assistant_id) { + newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id); + } + return newConvo; +} + +function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.IMessage[] { + if (!Array.isArray(messages)) { + return []; + } + + const idMap = new Map(); + return messages.map((message) => { + const newMessageId = anonymizeMessageId(message.messageId); + idMap.set(message.messageId, newMessageId); + + type MessageAttachment = { + messageId?: string; + conversationId?: string; + [key: string]: unknown; + }; + + const anonymizedAttachments = (message.attachments as MessageAttachment[])?.map( + (attachment) => { + return { + ...attachment, + messageId: newMessageId, + conversationId: newConvoId, + }; + }, + ); + + return { + ...message, + messageId: newMessageId, + parentMessageId: + idMap.get(message.parentMessageId || '') || + anonymizeMessageId(message.parentMessageId || ''), + conversationId: newConvoId, + model: message.model?.startsWith('asst_') + ? anonymizeAssistantId(message.model) + : message.model, + attachments: anonymizedAttachments, + } as t.IMessage; + }); +} + +/** Factory function that takes mongoose instance and returns the methods */ +export function createShareMethods(mongoose: typeof import('mongoose')) { + /** + * Get shared messages for a public share link + */ + async function getSharedMessages(shareId: string): Promise { + try { + const SharedLink = mongoose.models.SharedLink as Model; + const share = (await SharedLink.findOne({ shareId, isPublic: true }) + .populate({ + path: 'messages', + select: '-_id -__v -user', + }) + .select('-_id -__v -user') + .lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null; + + if (!share?.conversationId || !share.isPublic) { + return null; + } + + const newConvoId = anonymizeConvoId(share.conversationId); + const result: t.SharedMessagesResult = { + shareId: share.shareId || shareId, + title: share.title, + isPublic: share.isPublic, + createdAt: share.createdAt, + updatedAt: share.updatedAt, + conversationId: newConvoId, + messages: anonymizeMessages(share.messages, newConvoId), + }; + + return result; + } catch (error) { + logger.error('[getSharedMessages] Error getting share link', { + error: error instanceof Error ? error.message : 'Unknown error', + shareId, + }); + throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR'); + } + } + + /** + * Get shared links for a specific user with pagination and search + */ + async function getSharedLinks( + user: string, + pageParam?: Date, + pageSize: number = 10, + isPublic: boolean = true, + sortBy: string = 'createdAt', + sortDirection: string = 'desc', + search?: string, + ): Promise { + try { + const SharedLink = mongoose.models.SharedLink as Model; + const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; + const query: FilterQuery = { user, isPublic }; + + if (pageParam) { + if (sortDirection === 'desc') { + query[sortBy] = { $lt: pageParam }; + } else { + query[sortBy] = { $gt: pageParam }; + } + } + + if (search && search.trim()) { + try { + const searchResults = await Conversation.meiliSearch(search); + + if (!searchResults?.hits?.length) { + return { + links: [], + nextCursor: undefined, + hasNextPage: false, + }; + } + + const conversationIds = searchResults.hits.map((hit) => hit.conversationId); + query['conversationId'] = { $in: conversationIds }; + } catch (searchError) { + logger.error('[getSharedLinks] Meilisearch error', { + error: searchError instanceof Error ? searchError.message : 'Unknown error', + user, + }); + return { + links: [], + nextCursor: undefined, + hasNextPage: false, + }; + } + } + + const sort: Record = {}; + sort[sortBy] = sortDirection === 'desc' ? -1 : 1; + + const sharedLinks = await SharedLink.find(query) + .sort(sort) + .limit(pageSize + 1) + .select('-__v -user') + .lean(); + + const hasNextPage = sharedLinks.length > pageSize; + const links = sharedLinks.slice(0, pageSize); + + const nextCursor = hasNextPage + ? (links[links.length - 1][sortBy as keyof t.ISharedLink] as Date) + : undefined; + + return { + links: links.map((link) => ({ + shareId: link.shareId || '', + title: link?.title || 'Untitled', + isPublic: link.isPublic, + createdAt: link.createdAt || new Date(), + conversationId: link.conversationId, + })), + nextCursor, + hasNextPage, + }; + } catch (error) { + logger.error('[getSharedLinks] Error getting shares', { + error: error instanceof Error ? error.message : 'Unknown error', + user, + }); + throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR'); + } + } + + /** + * Delete all shared links for a user + */ + async function deleteAllSharedLinks(user: string): Promise { + try { + const SharedLink = mongoose.models.SharedLink as Model; + const result = await SharedLink.deleteMany({ user }); + return { + message: 'All shared links deleted successfully', + deletedCount: result.deletedCount, + }; + } catch (error) { + logger.error('[deleteAllSharedLinks] Error deleting shared links', { + error: error instanceof Error ? error.message : 'Unknown error', + user, + }); + throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR'); + } + } + + /** + * Create a new shared link for a conversation + */ + async function createSharedLink( + user: string, + conversationId: string, + ): Promise { + if (!user || !conversationId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); + } + try { + const Message = mongoose.models.Message as SchemaWithMeiliMethods; + const SharedLink = mongoose.models.SharedLink as Model; + const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; + + const [existingShare, conversationMessages] = await Promise.all([ + SharedLink.findOne({ conversationId, user, isPublic: true }) + .select('-_id -__v -user') + .lean() as Promise, + Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), + ]); + + if (existingShare && existingShare.isPublic) { + logger.error('[createSharedLink] Share already exists', { + user, + conversationId, + }); + throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); + } else if (existingShare) { + await SharedLink.deleteOne({ conversationId, user }); + } + + const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { + title?: string; + } | null; + + // Check if user owns the conversation + if (!conversation) { + throw new ShareServiceError( + 'Conversation not found or access denied', + 'CONVERSATION_NOT_FOUND', + ); + } + + // Check if there are any messages to share + if (!conversationMessages || conversationMessages.length === 0) { + throw new ShareServiceError('No messages to share', 'NO_MESSAGES'); + } + + const title = conversation.title || 'Untitled'; + + const shareId = nanoid(); + await SharedLink.create({ + shareId, + conversationId, + messages: conversationMessages, + title, + user, + }); + + return { shareId, conversationId }; + } catch (error) { + if (error instanceof ShareServiceError) { + throw error; + } + logger.error('[createSharedLink] Error creating shared link', { + error: error instanceof Error ? error.message : 'Unknown error', + user, + conversationId, + }); + throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); + } + } + + /** + * Get a shared link for a conversation + */ + async function getSharedLink( + user: string, + conversationId: string, + ): Promise { + if (!user || !conversationId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); + } + + try { + const SharedLink = mongoose.models.SharedLink as Model; + const share = (await SharedLink.findOne({ conversationId, user, isPublic: true }) + .select('shareId -_id') + .lean()) as { shareId?: string } | null; + + if (!share) { + return { shareId: null, success: false }; + } + + return { shareId: share.shareId || null, success: true }; + } catch (error) { + logger.error('[getSharedLink] Error getting shared link', { + error: error instanceof Error ? error.message : 'Unknown error', + user, + conversationId, + }); + throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR'); + } + } + + /** + * Update a shared link with new messages + */ + async function updateSharedLink(user: string, shareId: string): Promise { + if (!user || !shareId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); + } + + try { + const SharedLink = mongoose.models.SharedLink as Model; + const Message = mongoose.models.Message as SchemaWithMeiliMethods; + const share = (await SharedLink.findOne({ shareId, user }) + .select('-_id -__v -user') + .lean()) as t.ISharedLink | null; + + if (!share) { + throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND'); + } + + const updatedMessages = await Message.find({ conversationId: share.conversationId, user }) + .sort({ createdAt: 1 }) + .lean(); + + const newShareId = nanoid(); + const update = { + messages: updatedMessages, + user, + shareId: newShareId, + }; + + const updatedShare = (await SharedLink.findOneAndUpdate({ shareId, user }, update, { + new: true, + upsert: false, + runValidators: true, + }).lean()) as t.ISharedLink | null; + + if (!updatedShare) { + throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR'); + } + + anonymizeConvo(updatedShare); + + return { shareId: newShareId, conversationId: updatedShare.conversationId }; + } catch (error) { + logger.error('[updateSharedLink] Error updating shared link', { + error: error instanceof Error ? error.message : 'Unknown error', + user, + shareId, + }); + throw new ShareServiceError( + error instanceof ShareServiceError ? error.message : 'Error updating shared link', + error instanceof ShareServiceError ? error.code : 'SHARE_UPDATE_ERROR', + ); + } + } + + /** + * Delete a shared link + */ + async function deleteSharedLink( + user: string, + shareId: string, + ): Promise { + if (!user || !shareId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); + } + + try { + const SharedLink = mongoose.models.SharedLink as Model; + const result = await SharedLink.findOneAndDelete({ shareId, user }).lean(); + + if (!result) { + return null; + } + + return { + success: true, + shareId, + message: 'Share deleted successfully', + }; + } catch (error) { + logger.error('[deleteSharedLink] Error deleting shared link', { + error: error instanceof Error ? error.message : 'Unknown error', + user, + shareId, + }); + throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR'); + } + } + + // Return all methods + return { + getSharedLink, + getSharedLinks, + createSharedLink, + updateSharedLink, + deleteSharedLink, + getSharedMessages, + deleteAllSharedLinks, + }; +} + +export type ShareMethods = ReturnType; diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts index 77597cdf6..d44dfd806 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; -import { MeiliSearch, Index } from 'meilisearch'; +import { MeiliSearch } from 'meilisearch'; +import type { SearchResponse, Index } from 'meilisearch'; import type { CallbackWithoutResultAndOptionalError, FilterQuery, @@ -9,6 +10,7 @@ import type { Types, Model, } from 'mongoose'; +import type { IConversation, IMessage } from '~/types'; import logger from '~/config/meiliLogger'; interface MongoMeiliOptions { @@ -29,7 +31,7 @@ interface ContentItem { text?: string; } -interface DocumentWithMeiliIndex extends Document { +interface _DocumentWithMeiliIndex extends Document { _meiliIndex?: boolean; preprocessObjectForIndex?: () => Record; addObjectToMeili?: (next: CallbackWithoutResultAndOptionalError) => Promise; @@ -38,19 +40,18 @@ interface DocumentWithMeiliIndex extends Document { postSaveHook?: (next: CallbackWithoutResultAndOptionalError) => void; postUpdateHook?: (next: CallbackWithoutResultAndOptionalError) => void; postRemoveHook?: (next: CallbackWithoutResultAndOptionalError) => void; - conversationId?: string; - content?: ContentItem[]; - messageId?: string; - unfinished?: boolean; - messages?: unknown[]; - title?: string; - toJSON(): Record; } -interface SchemaWithMeiliMethods extends Model { +export type DocumentWithMeiliIndex = _DocumentWithMeiliIndex & IConversation & Partial; + +export interface SchemaWithMeiliMethods extends Model { syncWithMeili(): Promise; setMeiliIndexSettings(settings: Record): Promise; - meiliSearch(q: string, params: Record, populate: boolean): Promise; + meiliSearch( + q: string, + params?: Record, + populate?: boolean, + ): Promise>>; } // Environment flags @@ -247,7 +248,7 @@ const createMeiliMongooseModel = ({ q: string, params: Record, populate: boolean, - ): Promise { + ): Promise>> { const data = await index.search(q, params); if (populate) { diff --git a/packages/data-schemas/src/schema/promptGroup.ts b/packages/data-schemas/src/schema/promptGroup.ts index b13a83724..ed5f88fe0 100644 --- a/packages/data-schemas/src/schema/promptGroup.ts +++ b/packages/data-schemas/src/schema/promptGroup.ts @@ -63,11 +63,11 @@ const promptGroupSchema = new Schema( type: String, index: true, validate: { - validator: function (v: unknown): boolean { + validator: function (v: string | undefined | null): boolean { return v === undefined || v === null || v === '' || /^[a-z0-9-]+$/.test(v); }, - message: (props: unknown) => - `${props.value} is not a valid command. Only lowercase alphanumeric characters and hyphens are allowed.`, + message: (props: { value?: string } | undefined) => + `${props?.value ?? 'Value'} is not a valid command. Only lowercase alphanumeric characters and hyphens are allowed.`, }, maxlength: [ Constants.COMMANDS_MAX_LENGTH as number, diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 7b7037c8e..3dfe1334e 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -13,5 +13,6 @@ export * from './role'; export * from './action'; export * from './assistant'; export * from './file'; +export * from './share'; /* Memories */ export * from './memory'; diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts new file mode 100644 index 000000000..3db1a360c --- /dev/null +++ b/packages/data-schemas/src/types/share.ts @@ -0,0 +1,66 @@ +import type { Types } from 'mongoose'; +import type { IMessage } from './message'; + +export interface ISharedLink { + _id?: Types.ObjectId; + conversationId: string; + title?: string; + user?: string; + messages?: Types.ObjectId[]; + shareId?: string; + isPublic: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +export interface ShareServiceError extends Error { + code: string; +} + +export interface SharedLinksResult { + links: Array<{ + shareId: string; + title: string; + isPublic: boolean; + createdAt: Date; + conversationId: string; + }>; + nextCursor?: Date; + hasNextPage: boolean; +} + +export interface SharedMessagesResult { + conversationId: string; + messages: Array; + shareId: string; + title?: string; + isPublic: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +export interface CreateShareResult { + shareId: string; + conversationId: string; +} + +export interface UpdateShareResult { + shareId: string; + conversationId: string; +} + +export interface DeleteShareResult { + success: boolean; + shareId: string; + message: string; +} + +export interface GetShareLinkResult { + shareId: string | null; + success: boolean; +} + +export interface DeleteAllSharesResult { + message: string; + deletedCount: number; +}