🪐 refactor: Migrate Share Functionality to Type-Safe Methods (#7903)

* chore: Update import for isEnabled utility in convoAccess middleware

* refactor: Migrate Share functionality to new methods structure in `@librechat/data-schemas`

- Deleted the old Share.js model and moved its functionality to a new share.ts file within the data-schemas package.
- Updated imports across the codebase to reflect the new structure.
- Enhanced error handling and logging in shared link operations.
- Introduced TypeScript types for shared links and related operations to improve type safety and maintainability.

* chore: Update promptGroupSchema validation with typing

* fix: error handling and logging in createSharedLink

* fix: don't allow empty shared link or shared link without messages

* ci: add tests for shared link methods

* chore: Bump version of @librechat/data-schemas to 0.0.9 in package.json and package-lock.json

* chore: Add nanoid as peer dependency

- Introduced `nanoid` as a dependency in `package.json` and `package-lock.json`.
- Replaced UUID generation with `nanoid` for creating unique conversation and message IDs in share methods tests.
This commit is contained in:
Danny Avila 2025-06-14 11:24:30 -04:00 committed by GitHub
parent 0103b4b08a
commit 3af2666890
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1720 additions and 411 deletions

View file

@ -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,
};

View file

@ -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} */

View file

@ -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 ?? {};

View file

@ -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' });
}
});

154
package-lock.json generated
View file

@ -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"

View file

@ -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"

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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<string, string>();
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<t.IConversation> & Partial<t.ISharedLink>) {
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<string, string>();
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<t.SharedMessagesResult | null> {
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
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<t.SharedLinksResult> {
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods;
const query: FilterQuery<t.ISharedLink> = { 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<string, 1 | -1> = {};
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<t.DeleteAllSharesResult> {
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
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<t.CreateShareResult> {
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<t.ISharedLink>;
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<t.ISharedLink | null>,
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<t.GetShareLinkResult> {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
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<t.UpdateShareResult> {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
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<t.DeleteShareResult | null> {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const SharedLink = mongoose.models.SharedLink as Model<t.ISharedLink>;
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<typeof createShareMethods>;

View file

@ -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<string, unknown>;
addObjectToMeili?: (next: CallbackWithoutResultAndOptionalError) => Promise<void>;
@ -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<string, unknown>;
}
interface SchemaWithMeiliMethods extends Model<DocumentWithMeiliIndex> {
export type DocumentWithMeiliIndex = _DocumentWithMeiliIndex & IConversation & Partial<IMessage>;
export interface SchemaWithMeiliMethods extends Model<DocumentWithMeiliIndex> {
syncWithMeili(): Promise<void>;
setMeiliIndexSettings(settings: Record<string, unknown>): Promise<unknown>;
meiliSearch(q: string, params: Record<string, unknown>, populate: boolean): Promise<unknown>;
meiliSearch(
q: string,
params?: Record<string, unknown>,
populate?: boolean,
): Promise<SearchResponse<MeiliIndexable, Record<string, unknown>>>;
}
// Environment flags
@ -247,7 +248,7 @@ const createMeiliMongooseModel = ({
q: string,
params: Record<string, unknown>,
populate: boolean,
): Promise<unknown> {
): Promise<SearchResponse<MeiliIndexable, Record<string, unknown>>> {
const data = await index.search(q, params);
if (populate) {

View file

@ -63,11 +63,11 @@ const promptGroupSchema = new Schema<IPromptGroupDocument>(
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,

View file

@ -13,5 +13,6 @@ export * from './role';
export * from './action';
export * from './assistant';
export * from './file';
export * from './share';
/* Memories */
export * from './memory';

View file

@ -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<IMessage>;
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;
}