From 4049b5572cfb1e76211d11f083860eebe66d17d8 Mon Sep 17 00:00:00 2001 From: Cha Date: Thu, 29 May 2025 16:37:31 +0800 Subject: [PATCH] Move usermethods and models to data-schema --- .../clients/tools/util/handleTools.test.js | 40 +- api/cache/banViolation.js | 6 +- api/cache/banViolation.spec.js | 35 +- api/lib/db/connectDb.js | 28 +- api/lib/db/index.js | 4 +- api/lib/db/indexSync.js | 5 +- api/models/Agent.js | 18 +- api/models/Agent.spec.js | 9 +- api/models/Assistant.js | 13 +- api/models/Balance.js | 4 - api/models/Banner.js | 9 +- api/models/Conversation.js | 22 +- api/models/ConversationTag.js | 27 +- api/models/File.js | 21 +- api/models/Key.js | 4 - api/models/Message.js | 30 +- api/models/Message.spec.js | 29 +- api/models/Preset.js | 10 +- api/models/Project.js | 21 +- api/models/Prompt.js | 28 +- api/models/Role.js | 12 +- api/models/Role.spec.js | 9 +- api/models/Session.js | 275 ---------- api/models/Share.js | 19 +- api/models/Token.js | 136 +---- api/models/ToolCall.js | 17 +- api/models/Transaction.js | 125 ++--- api/models/Transaction.spec.js | 14 +- api/models/User.js | 6 - api/models/balanceMethods.js | 8 +- api/models/convoStructure.spec.js | 9 +- api/models/index.js | 49 +- api/models/inviteUser.js | 6 +- api/models/schema/convoSchema.js | 18 - api/models/schema/messageSchema.js | 3 +- api/models/schema/presetSchema.js | 6 - api/models/spendTokens.js | 12 +- api/models/spendTokens.spec.js | 17 +- api/models/userMethods.js | 162 ------ api/package.json | 2 +- api/server/controllers/AuthController.js | 11 +- api/server/controllers/Balance.js | 5 +- api/server/controllers/TwoFactorController.js | 16 +- api/server/controllers/UserController.js | 15 +- .../auth/TwoFactorAuthController.js | 4 +- api/server/index.js | 3 +- api/server/middleware/checkBan.js | 5 +- api/server/middleware/checkInviteUser.js | 4 +- api/server/middleware/setBalanceConfig.js | 6 +- api/server/routes/messages.js | 12 +- api/server/services/ActionService.js | 7 +- api/server/services/AuthService.js | 81 ++- api/server/services/Files/Azure/images.js | 5 +- api/server/services/Files/Firebase/images.js | 4 +- api/server/services/Files/Local/images.js | 4 +- api/server/services/Files/S3/images.js | 4 +- api/server/services/UserService.js | 18 +- api/server/services/twoFactorService.js | 4 +- api/server/utils/crypto.js | 4 +- api/server/utils/handleText.spec.js | 6 + api/strategies/appleStrategy.js | 8 +- api/strategies/appleStrategy.test.js | 40 +- api/strategies/jwtStrategy.js | 7 +- api/strategies/ldapStrategy.js | 15 +- api/strategies/localStrategy.js | 10 +- api/strategies/openidStrategy.js | 32 +- api/strategies/openidStrategy.spec.js | 80 ++- api/strategies/process.js | 12 +- api/strategies/socialLogin.js | 4 +- config/add-balance.js | 8 +- config/ban-user.js | 4 +- config/connect.js | 2 +- config/create-user.js | 4 +- config/delete-user.js | 6 +- config/invite-user.js | 4 +- config/list-balances.js | 4 +- config/list-users.js | 4 +- config/reset-terms.js | 4 +- config/set-balance.js | 9 +- config/update-banner.js | 3 +- config/user-stats.js | 5 +- e2e/setup/cleanupUser.ts | 13 +- package-lock.json | 355 ++++++++----- packages/data-schemas/package.json | 10 +- packages/data-schemas/src/config/parsers.ts | 228 ++++++++ packages/data-schemas/src/config/winston.ts | 129 +++++ packages/data-schemas/src/index.ts | 1 + packages/data-schemas/src/models/index.ts | 173 ++++++ .../src/models/plugins/mongoMeili.ts | 494 ++++++++++++++++++ packages/data-schemas/src/schema/session.ts | 223 ++++++++ packages/data-schemas/src/schema/token.ts | 113 ++++ packages/data-schemas/src/schema/user.ts | 170 +++++- packages/data-schemas/tsconfig.json | 3 +- 93 files changed, 2396 insertions(+), 1267 deletions(-) delete mode 100644 api/models/Balance.js delete mode 100644 api/models/Key.js delete mode 100644 api/models/Session.js delete mode 100644 api/models/User.js delete mode 100644 api/models/schema/convoSchema.js delete mode 100644 api/models/schema/presetSchema.js create mode 100644 packages/data-schemas/src/config/parsers.ts create mode 100644 packages/data-schemas/src/config/winston.ts create mode 100644 packages/data-schemas/src/models/index.ts create mode 100644 packages/data-schemas/src/models/plugins/mongoMeili.ts diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index 6538ce9aa4..5ad65227f0 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -10,18 +10,45 @@ const mockPluginService = { getUserPluginAuthValue: jest.fn(), }; -jest.mock('~/models/User', () => { - return function () { - return mockUser; +const mockModels = { + User: mockUser, +}; +jest.mock('~/lib/db/connectDb', () => { + return { + connectDb: jest.fn(), + get models() { + return mockModels; + }, }; }); +jest.mock('@librechat/data-schemas', () => { + const userModelMock = { + createUser: jest.fn(() => mockUser), + findUser: jest.fn(), + updateUser: jest.fn(), + }; + return { + registerModels: jest.fn().mockReturnValue({ + User: userModelMock, + }), + }; +}); + +jest.mock('~/models/Message', () => ({ + Message: jest.fn(), +})); +jest.mock('~/models/Conversation', () => ({ + Conversation: jest.fn(), +})); +jest.mock('~/models/File', () => ({ + File: jest.fn(), +})); jest.mock('~/server/services/PluginService', () => mockPluginService); const { BaseLLM } = require('@langchain/openai'); const { Calculator } = require('@langchain/community/tools/calculator'); -const User = require('~/models/User'); const PluginService = require('~/server/services/PluginService'); const { validateTools, loadTools, loadToolWithAuth } = require('./handleTools'); const { StructuredSD, availableTools, DALLE3 } = require('../'); @@ -36,6 +63,9 @@ describe('Tool Handlers', () => { const mainPlugin = availableTools.find((tool) => tool.pluginKey === pluginKey); const authConfigs = mainPlugin.authConfig; + const { registerModels } = require('@librechat/data-schemas'); + let User = registerModels().User; + beforeAll(async () => { mockUser.save.mockResolvedValue(undefined); @@ -52,7 +82,7 @@ describe('Tool Handlers', () => { }, ); - fakeUser = new User({ + fakeUser = await User.createUser({ name: 'Fake User', username: 'fakeuser', email: 'fakeuser@example.com', diff --git a/api/cache/banViolation.js b/api/cache/banViolation.js index cdbff85c54..6dfdee5c62 100644 --- a/api/cache/banViolation.js +++ b/api/cache/banViolation.js @@ -1,8 +1,8 @@ const { ViolationTypes } = require('librechat-data-provider'); const { isEnabled, math, removePorts } = require('~/server/utils'); -const { deleteAllUserSessions } = require('~/models'); const getLogStores = require('./getLogStores'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {}; const interval = math(BAN_INTERVAL, 20); @@ -32,7 +32,6 @@ const banViolation = async (req, res, errorMessage) => { if (!isEnabled(BAN_VIOLATIONS)) { return; } - if (!errorMessage) { return; } @@ -46,12 +45,11 @@ const banViolation = async (req, res, errorMessage) => { return; } - await deleteAllUserSessions({ userId: user_id }); + await db.models.Session.deleteAllUserSessions({ userId: user_id }); res.clearCookie('refreshToken'); const banLogs = getLogStores(ViolationTypes.BAN); const duration = errorMessage.duration || banLogs.opts.ttl; - if (duration <= 0) { return; } diff --git a/api/cache/banViolation.spec.js b/api/cache/banViolation.spec.js index 8fef16920f..ebe5ce8aa9 100644 --- a/api/cache/banViolation.spec.js +++ b/api/cache/banViolation.spec.js @@ -1,7 +1,40 @@ const banViolation = require('./banViolation'); +jest.mock('@librechat/data-schemas', () => { + const sessionModelMock = { + deleteAllUserSessions: jest.fn(), + }; + + return { + registerModels: jest.fn().mockReturnValue({ + Session: sessionModelMock, + }), + }; +}); + +const mockModels = { + Session: { + deleteAllUserSessions: jest.fn(), + }, +}; + +jest.mock('~/lib/db/connectDb', () => { + return { + connectDb: jest.fn(), + get models() { + return mockModels; + }, + }; +}); + +jest.mock('~/server/utils', () => ({ + isEnabled: jest.fn(() => true), // default to false, override per test if needed + math: jest.fn(() => 20), // default to false, override per test if needed + removePorts: jest.fn(), +})); + jest.mock('keyv'); -jest.mock('../models/Session'); +// jest.mock('../models/Session'); // Mocking the getLogStores function jest.mock('./getLogStores', () => { return jest.fn().mockImplementation(() => { diff --git a/api/lib/db/connectDb.js b/api/lib/db/connectDb.js index b8cbeb2adb..98b83c19c8 100644 --- a/api/lib/db/connectDb.js +++ b/api/lib/db/connectDb.js @@ -1,8 +1,8 @@ require('dotenv').config(); const mongoose = require('mongoose'); -const MONGO_URI = process.env.MONGO_URI; +const { registerModels } = require('@librechat/data-schemas'); -if (!MONGO_URI) { +if (!process.env.MONGO_URI) { throw new Error('Please define the MONGO_URI environment variable'); } @@ -17,7 +17,7 @@ if (!cached) { cached = global.mongoose = { conn: null, promise: null }; } -async function connectDb() { +async function connectDb(mongoUri = process.env.MONGO_URI) { if (cached.conn && cached.conn?._readyState === 1) { return cached.conn; } @@ -34,12 +34,30 @@ async function connectDb() { }; mongoose.set('strictQuery', true); - cached.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => { + cached.promise = mongoose.connect(mongoUri, opts).then((mongoose) => { return mongoose; }); } cached.conn = await cached.promise; + + // Register models once + if (!cached.models) { + cached.models = registerModels(mongoose); + } + return cached.conn; } -module.exports = connectDb; +function getModels() { + return cached.models; +} +module.exports = { + connectDb, + getModels, + get models() { + if (!cached.models) { + throw new Error('Models not registered. '); + } + return cached.models; + }, +}; diff --git a/api/lib/db/index.js b/api/lib/db/index.js index fa7a460d05..917fca9967 100644 --- a/api/lib/db/index.js +++ b/api/lib/db/index.js @@ -1,4 +1,4 @@ -const connectDb = require('./connectDb'); +const { connectDb, getModels} = require('./connectDb'); const indexSync = require('./indexSync'); -module.exports = { connectDb, indexSync }; +module.exports = { connectDb, getModels, indexSync }; diff --git a/api/lib/db/indexSync.js b/api/lib/db/indexSync.js index 75acd9d231..de1f880287 100644 --- a/api/lib/db/indexSync.js +++ b/api/lib/db/indexSync.js @@ -1,8 +1,7 @@ const { MeiliSearch } = require('meilisearch'); -const { Conversation } = require('~/models/Conversation'); -const { Message } = require('~/models/Message'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); const searchEnabled = isEnabled(process.env.SEARCH); const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC); @@ -29,7 +28,7 @@ async function indexSync() { if (!searchEnabled) { return; } - + const { Message, Conversation } = db.models; try { const client = MeiliSearchClient.getInstance(); diff --git a/api/models/Agent.js b/api/models/Agent.js index a2b325b5bf..62c0f1e125 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -1,6 +1,5 @@ const mongoose = require('mongoose'); const crypto = require('node:crypto'); -const { agentSchema } = require('@librechat/data-schemas'); const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider'); const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } = require('librechat-data-provider').Constants; @@ -15,7 +14,7 @@ const getLogStores = require('~/cache/getLogStores'); const { getActions } = require('./Action'); const { logger } = require('~/config'); -const Agent = mongoose.model('agent', agentSchema); +const db = require('~/lib/db/connectDb'); /** * Create an agent with the provided data. @@ -36,7 +35,7 @@ const createAgent = async (agentData) => { }, ], }; - return (await Agent.create(initialAgentData)).toObject(); + return (await db.models.Agent.create(initialAgentData)).toObject(); }; /** @@ -47,7 +46,7 @@ const createAgent = async (agentData) => { * @param {string} searchParameter.author - The user ID of the agent's author. * @returns {Promise} The agent document as a plain object, or null if not found. */ -const getAgent = async (searchParameter) => await Agent.findOne(searchParameter).lean(); +const getAgent = async (searchParameter) => await db.models.Agent.findOne(searchParameter).lean(); /** * Load an agent based on the provided ID @@ -269,6 +268,7 @@ const updateAgent = async (searchParameter, updateData, options = {}) => { const { updatingUserId = null, forceVersion = false } = options; const mongoOptions = { new: true, upsert: false }; + const Agent = db.models?.Agent; const currentAgent = await Agent.findOne(searchParameter); if (currentAgent) { const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject(); @@ -362,6 +362,7 @@ const updateAgent = async (searchParameter, updateData, options = {}) => { * @returns {Promise} The updated agent. */ const addAgentResourceFile = async ({ req, agent_id, tool_resource, file_id }) => { + const Agent = db.models?.Agent; const searchParameter = { id: agent_id }; let agent = await getAgent(searchParameter); if (!agent) { @@ -427,7 +428,7 @@ const removeAgentResourceFiles = async ({ agent_id, files }) => { } const updatePullData = { $pull: pullOps }; - const agentAfterPull = await Agent.findOneAndUpdate(searchParameter, updatePullData, { + const agentAfterPull = await db.models.Agent.findOneAndUpdate(searchParameter, updatePullData, { new: true, }).lean(); @@ -457,7 +458,7 @@ const removeAgentResourceFiles = async ({ agent_id, files }) => { * @returns {Promise} Resolves when the agent has been successfully deleted. */ const deleteAgent = async (searchParameter) => { - const agent = await Agent.findOneAndDelete(searchParameter); + const agent = await db.models.Agent.findOneAndDelete(searchParameter); if (agent) { await removeAgentFromAllProjects(agent.id); } @@ -481,9 +482,8 @@ const getListAgents = async (searchParameter) => { delete globalQuery.author; query = { $or: [globalQuery, query] }; } - const agents = ( - await Agent.find(query, { + await db.models.Agent.find(query, { id: 1, _id: 0, name: 1, @@ -580,6 +580,7 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds * @throws {Error} If the agent is not found or the specified version does not exist. */ const revertAgentVersion = async (searchParameter, versionIndex) => { + const Agent = db.models?.Agent; const agent = await Agent.findOne(searchParameter); if (!agent) { throw new Error('Agent not found'); @@ -662,7 +663,6 @@ const generateActionMetadataHash = async (actionIds, actions) => { */ module.exports = { - Agent, getAgent, loadAgent, createAgent, diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index 41b32ffa92..4bfe4243dd 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -10,7 +10,6 @@ const mongoose = require('mongoose'); const { v4: uuidv4 } = require('uuid'); const { MongoMemoryServer } = require('mongodb-memory-server'); const { - Agent, addAgentResourceFile, removeAgentResourceFiles, createAgent, @@ -20,6 +19,9 @@ const { getListAgents, updateAgentProjects, } = require('./Agent'); +const db = require('~/lib/db/connectDb'); + +let Agent; describe('Agent Resource File Operations', () => { let mongoServer; @@ -27,7 +29,9 @@ describe('Agent Resource File Operations', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); - await mongoose.connect(mongoUri); + await db.connectDb(mongoUri); + + Agent = db.models.Agent; }); afterAll(async () => { @@ -55,6 +59,7 @@ describe('Agent Resource File Operations', () => { test('should add tool_resource to tools if missing', async () => { const agent = await createBasicAgent(); + const fileId = uuidv4(); const toolResource = 'file_search'; diff --git a/api/models/Assistant.js b/api/models/Assistant.js index a8a5b98157..079867cf73 100644 --- a/api/models/Assistant.js +++ b/api/models/Assistant.js @@ -1,7 +1,4 @@ -const mongoose = require('mongoose'); -const { assistantSchema } = require('@librechat/data-schemas'); - -const Assistant = mongoose.model('assistant', assistantSchema); +const db = require('~/lib/db/connectDb'); /** * Update an assistant with new data without overwriting existing properties, @@ -15,7 +12,7 @@ const Assistant = mongoose.model('assistant', assistantSchema); */ const updateAssistantDoc = async (searchParams, updateData) => { const options = { new: true, upsert: true }; - return await Assistant.findOneAndUpdate(searchParams, updateData, options).lean(); + return await db.models.Assistant.findOneAndUpdate(searchParams, updateData, options).lean(); }; /** @@ -26,7 +23,7 @@ const updateAssistantDoc = async (searchParams, updateData) => { * @param {string} searchParams.user - The user ID of the assistant's author. * @returns {Promise} The assistant document as a plain object, or null if not found. */ -const getAssistant = async (searchParams) => await Assistant.findOne(searchParams).lean(); +const getAssistant = async (searchParams) => await db.models.Assistant.findOne(searchParams).lean(); /** * Retrieves all assistants that match the given search parameters. @@ -36,7 +33,7 @@ const getAssistant = async (searchParams) => await Assistant.findOne(searchParam * @returns {Promise>} A promise that resolves to an array of assistant documents as plain objects. */ const getAssistants = async (searchParams, select = null) => { - let query = Assistant.find(searchParams); + let query = db.models.Assistant.find(searchParams); if (select) { query = query.select(select); @@ -54,7 +51,7 @@ const getAssistants = async (searchParams, select = null) => { * @returns {Promise} Resolves when the assistant has been successfully deleted. */ const deleteAssistant = async (searchParams) => { - return await Assistant.findOneAndDelete(searchParams); + return await db.models.Assistant.findOneAndDelete(searchParams); }; module.exports = { diff --git a/api/models/Balance.js b/api/models/Balance.js deleted file mode 100644 index 226f6ef508..0000000000 --- a/api/models/Balance.js +++ /dev/null @@ -1,4 +0,0 @@ -const mongoose = require('mongoose'); -const { balanceSchema } = require('@librechat/data-schemas'); - -module.exports = mongoose.model('Balance', balanceSchema); diff --git a/api/models/Banner.js b/api/models/Banner.js index 399a8e72ee..79092c3bdb 100644 --- a/api/models/Banner.js +++ b/api/models/Banner.js @@ -1,8 +1,5 @@ -const mongoose = require('mongoose'); const logger = require('~/config/winston'); -const { bannerSchema } = require('@librechat/data-schemas'); - -const Banner = mongoose.model('Banner', bannerSchema); +const db = require('~/lib/db/connectDb'); /** * Retrieves the current active banner. @@ -11,7 +8,7 @@ const Banner = mongoose.model('Banner', bannerSchema); const getBanner = async (user) => { try { const now = new Date(); - const banner = await Banner.findOne({ + const banner = await db.models.Banner.findOne({ displayFrom: { $lte: now }, $or: [{ displayTo: { $gte: now } }, { displayTo: null }], type: 'banner', @@ -28,4 +25,4 @@ const getBanner = async (user) => { } }; -module.exports = { Banner, getBanner }; +module.exports = { getBanner }; diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 51081a6491..270da2c81f 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -1,7 +1,6 @@ -const Conversation = require('./schema/convoSchema'); const { getMessages, deleteMessages } = require('./Message'); const logger = require('~/config/winston'); - +const db = require('~/lib/db/connectDb'); /** * Searches for a conversation by conversationId and returns a lean document with only conversationId and user. * @param {string} conversationId - The conversation's ID. @@ -9,7 +8,7 @@ const logger = require('~/config/winston'); */ const searchConversation = async (conversationId) => { try { - return await Conversation.findOne({ conversationId }, 'conversationId user').lean(); + return await db.models.Conversation.findOne({ conversationId }, 'conversationId user').lean(); } catch (error) { logger.error('[searchConversation] Error searching conversation', error); throw new Error('Error searching conversation'); @@ -24,7 +23,7 @@ const searchConversation = async (conversationId) => { */ const getConvo = async (user, conversationId) => { try { - return await Conversation.findOne({ user, conversationId }).lean(); + return await db.models.Conversation.findOne({ user, conversationId }).lean(); } catch (error) { logger.error('[getConvo] Error getting single conversation', error); return { message: 'Error getting single conversation' }; @@ -41,7 +40,7 @@ const deleteNullOrEmptyConversations = async () => { ], }; - const result = await Conversation.deleteMany(filter); + const result = await db.models.Conversation.deleteMany(filter); // Delete associated messages const messageDeleteResult = await deleteMessages(filter); @@ -67,7 +66,7 @@ const deleteNullOrEmptyConversations = async () => { */ const getConvoFiles = async (conversationId) => { try { - return (await Conversation.findOne({ conversationId }, 'files').lean())?.files ?? []; + return (await db.models.Conversation.findOne({ conversationId }, 'files').lean())?.files ?? []; } catch (error) { logger.error('[getConvoFiles] Error getting conversation files', error); throw new Error('Error getting conversation files'); @@ -75,7 +74,6 @@ const getConvoFiles = async (conversationId) => { }; module.exports = { - Conversation, getConvoFiles, searchConversation, deleteNullOrEmptyConversations, @@ -114,7 +112,7 @@ module.exports = { } /** Note: the resulting Model object is necessary for Meilisearch operations */ - const conversation = await Conversation.findOneAndUpdate( + const conversation = await db.models.Conversation.findOneAndUpdate( { conversationId, user: req.user.id }, updateOperation, { @@ -143,7 +141,7 @@ module.exports = { }, })); - const result = await Conversation.bulkWrite(bulkOps); + const result = await db.models.Conversation.bulkWrite(bulkOps); return result; } catch (error) { logger.error('[saveBulkConversations] Error saving conversations in bulk', error); @@ -155,7 +153,7 @@ module.exports = { { cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {}, ) => { const filters = [{ user }]; - + const { Conversation } = db.models; if (isArchived) { filters.push({ isArchived: true }); } else { @@ -219,7 +217,7 @@ module.exports = { const conversationIds = convoIds.map((convo) => convo.conversationId); - const results = await Conversation.find({ + const results = await db.models.Conversation.find({ user, conversationId: { $in: conversationIds }, $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], @@ -288,7 +286,7 @@ module.exports = { deleteConvos: async (user, filter) => { try { const userFilter = { ...filter, user }; - + const { Conversation } = db.models; const conversations = await Conversation.find(userFilter).select('conversationId'); const conversationIds = conversations.map((c) => c.conversationId); diff --git a/api/models/ConversationTag.js b/api/models/ConversationTag.js index f0cac8620e..81716ff14e 100644 --- a/api/models/ConversationTag.js +++ b/api/models/ConversationTag.js @@ -1,10 +1,5 @@ -const mongoose = require('mongoose'); -const Conversation = require('./schema/convoSchema'); const logger = require('~/config/winston'); - -const { conversationTagSchema } = require('@librechat/data-schemas'); - -const ConversationTag = mongoose.model('ConversationTag', conversationTagSchema); +const db = require('~/lib/db/connectDb'); /** * Retrieves all conversation tags for a user. @@ -13,7 +8,7 @@ const ConversationTag = mongoose.model('ConversationTag', conversationTagSchema) */ const getConversationTags = async (user) => { try { - return await ConversationTag.find({ user }).sort({ position: 1 }).lean(); + return await db.models.ConversationTag.find({ user }).sort({ position: 1 }).lean(); } catch (error) { logger.error('[getConversationTags] Error getting conversation tags', error); throw new Error('Error getting conversation tags'); @@ -34,6 +29,7 @@ const createConversationTag = async (user, data) => { try { const { tag, description, addToConversation, conversationId } = data; + const { ConversationTag, Conversation } = db.models; const existingTag = await ConversationTag.findOne({ user, tag }).lean(); if (existingTag) { return existingTag; @@ -88,6 +84,7 @@ const updateConversationTag = async (user, oldTag, data) => { try { const { tag: newTag, description, position } = data; + const { ConversationTag, Conversation } = db.models; const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean(); if (!existingTag) { return null; @@ -140,15 +137,15 @@ const adjustPositions = async (user, oldPosition, newPosition) => { const position = oldPosition < newPosition ? { - $gt: Math.min(oldPosition, newPosition), - $lte: Math.max(oldPosition, newPosition), - } + $gt: Math.min(oldPosition, newPosition), + $lte: Math.max(oldPosition, newPosition), + } : { - $gte: Math.min(oldPosition, newPosition), - $lt: Math.max(oldPosition, newPosition), - }; + $gte: Math.min(oldPosition, newPosition), + $lt: Math.max(oldPosition, newPosition), + }; - await ConversationTag.updateMany( + await db.models.ConversationTag.updateMany( { user, position, @@ -165,6 +162,7 @@ const adjustPositions = async (user, oldPosition, newPosition) => { */ const deleteConversationTag = async (user, tag) => { try { + const { ConversationTag, Conversation } = db.models; const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean(); if (!deletedTag) { return null; @@ -193,6 +191,7 @@ const deleteConversationTag = async (user, tag) => { */ const updateTagsForConversation = async (user, conversationId, tags) => { try { + const { ConversationTag, Conversation } = db.models; const conversation = await Conversation.findOne({ user, conversationId }).lean(); if (!conversation) { throw new Error('Conversation not found'); diff --git a/api/models/File.js b/api/models/File.js index 4d94994478..7755a0d9c8 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -1,9 +1,7 @@ const mongoose = require('mongoose'); const { EToolResources } = require('librechat-data-provider'); -const { fileSchema } = require('@librechat/data-schemas'); const { logger } = require('~/config'); - -const File = mongoose.model('File', fileSchema); +const db = require('~/lib/db/connectDb'); /** * Finds a file by its file_id with additional query options. @@ -12,7 +10,7 @@ const File = mongoose.model('File', fileSchema); * @returns {Promise} A promise that resolves to the file document or null. */ const findFileById = async (file_id, options = {}) => { - return await File.findOne({ file_id, ...options }).lean(); + return await db.models.File.findOne({ file_id, ...options }).lean(); }; /** @@ -25,7 +23,7 @@ const findFileById = async (file_id, options = {}) => { */ const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => { const sortOptions = { updatedAt: -1, ..._sortOptions }; - return await File.find(filter).select(selectFields).sort(sortOptions).lean(); + return await db.models.File.find(filter).select(selectFields).sort(sortOptions).lean(); }; /** @@ -81,7 +79,7 @@ const createFile = async (data, disableTTL) => { delete fileData.expiresAt; } - return await File.findOneAndUpdate({ file_id: data.file_id }, fileData, { + return await db.models.File.findOneAndUpdate({ file_id: data.file_id }, fileData, { new: true, upsert: true, }).lean(); @@ -98,7 +96,7 @@ const updateFile = async (data) => { $set: update, $unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL }; - return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean(); + return await db.models.File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean(); }; /** @@ -112,7 +110,7 @@ const updateFileUsage = async (data) => { $inc: { usage: inc }, $unset: { expiresAt: '', temp_file_id: '' }, }; - return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean(); + return await db.models.File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean(); }; /** @@ -121,7 +119,7 @@ const updateFileUsage = async (data) => { * @returns {Promise} A promise that resolves to the deleted file document or null. */ const deleteFile = async (file_id) => { - return await File.findOneAndDelete({ file_id }).lean(); + return await db.models.File.findOneAndDelete({ file_id }).lean(); }; /** @@ -130,7 +128,7 @@ const deleteFile = async (file_id) => { * @returns {Promise} A promise that resolves to the deleted file document or null. */ const deleteFileByFilter = async (filter) => { - return await File.findOneAndDelete(filter).lean(); + return await db.models.File.findOneAndDelete(filter).lean(); }; /** @@ -143,7 +141,7 @@ const deleteFiles = async (file_ids, user) => { if (user) { deleteQuery = { user: user }; } - return await File.deleteMany(deleteQuery); + return await db.models.File.deleteMany(deleteQuery); }; /** @@ -169,7 +167,6 @@ async function batchUpdateFiles(updates) { } module.exports = { - File, findFileById, getFiles, getToolFilesByIds, diff --git a/api/models/Key.js b/api/models/Key.js deleted file mode 100644 index c69c350a42..0000000000 --- a/api/models/Key.js +++ /dev/null @@ -1,4 +0,0 @@ -const mongoose = require('mongoose'); -const { keySchema } = require('@librechat/data-schemas'); - -module.exports = mongoose.model('Key', keySchema); diff --git a/api/models/Message.js b/api/models/Message.js index 86fd2fd549..78b7976bec 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -1,7 +1,6 @@ const { z } = require('zod'); -const Message = require('./schema/messageSchema'); const { logger } = require('~/config'); - +const db = require('~/lib/db/connectDb'); const idSchema = z.string().uuid(); /** @@ -68,8 +67,7 @@ async function saveMessage(req, params, metadata) { logger.info(`---\`saveMessage\` context: ${metadata?.context}`); update.tokenCount = 0; } - - const message = await Message.findOneAndUpdate( + const message = await db.models.Message.findOneAndUpdate( { messageId: params.messageId, user: req.user.id }, update, { upsert: true, new: true }, @@ -87,7 +85,7 @@ async function saveMessage(req, params, metadata) { try { // Try to find the existing message with this ID - const existingMessage = await Message.findOne({ + const existingMessage = await db.models.Message.findOne({ messageId: params.messageId, user: req.user.id, }); @@ -140,8 +138,7 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) { upsert: true, }, })); - - const result = await Message.bulkWrite(bulkOps); + const result = await db.models.Message.bulkWrite(bulkOps); return result; } catch (err) { logger.error('Error saving messages in bulk:', err); @@ -183,7 +180,7 @@ async function recordMessage({ ...rest, }; - return await Message.findOneAndUpdate({ user, messageId }, message, { + return await db.models.Message.findOneAndUpdate({ user, messageId }, message, { upsert: true, new: true, }); @@ -207,7 +204,7 @@ async function recordMessage({ */ async function updateMessageText(req, { messageId, text }) { try { - await Message.updateOne({ messageId, user: req.user.id }, { text }); + await db.models?.Message.updateOne({ messageId, user: req.user.id }, { text }); } catch (err) { logger.error('Error updating message text:', err); throw err; @@ -235,7 +232,7 @@ async function updateMessageText(req, { messageId, text }) { async function updateMessage(req, message, metadata) { try { const { messageId, ...update } = message; - const updatedMessage = await Message.findOneAndUpdate( + const updatedMessage = await db.models.Message.findOneAndUpdate( { messageId, user: req.user.id }, update, { @@ -279,10 +276,10 @@ async function updateMessage(req, message, metadata) { */ async function deleteMessagesSince(req, { messageId, conversationId }) { try { - const message = await Message.findOne({ messageId, user: req.user.id }).lean(); + const message = await db.models.Message.findOne({ messageId, user: req.user.id }).lean(); if (message) { - const query = Message.find({ conversationId, user: req.user.id }); + const query = db.models.Message.find({ conversationId, user: req.user.id }); return await query.deleteMany({ createdAt: { $gt: message.createdAt }, }); @@ -306,10 +303,10 @@ async function deleteMessagesSince(req, { messageId, conversationId }) { async function getMessages(filter, select) { try { if (select) { - return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean(); + return await db.models.Message.find(filter).select(select).sort({ createdAt: 1 }).lean(); } - return await Message.find(filter).sort({ createdAt: 1 }).lean(); + return await db.models.Message.find(filter).sort({ createdAt: 1 }).lean(); } catch (err) { logger.error('Error getting messages:', err); throw err; @@ -326,7 +323,7 @@ async function getMessages(filter, select) { */ async function getMessage({ user, messageId }) { try { - return await Message.findOne({ + return await db.models.Message.findOne({ user, messageId, }).lean(); @@ -347,7 +344,7 @@ async function getMessage({ user, messageId }) { */ async function deleteMessages(filter) { try { - return await Message.deleteMany(filter); + return await db.models.Message.deleteMany(filter); } catch (err) { logger.error('Error deleting messages:', err); throw err; @@ -355,7 +352,6 @@ async function deleteMessages(filter) { } module.exports = { - Message, saveMessage, bulkSaveMessages, recordMessage, diff --git a/api/models/Message.spec.js b/api/models/Message.spec.js index a542130b59..25c2161b1e 100644 --- a/api/models/Message.spec.js +++ b/api/models/Message.spec.js @@ -1,5 +1,6 @@ const mongoose = require('mongoose'); const { v4: uuidv4 } = require('uuid'); +const db = require('~/lib/db/connectDb'); jest.mock('mongoose'); @@ -20,14 +21,28 @@ const mockSchema = { deleteMany: jest.fn(), }; -mongoose.model.mockReturnValue(mockSchema); - -jest.mock('~/models/schema/messageSchema', () => mockSchema); - jest.mock('~/config/winston', () => ({ error: jest.fn(), })); +const mockModels = { + Message: { + findOneAndUpdate: mockSchema.findOneAndUpdate, + updateOne: mockSchema.updateOne, + findOne: mockSchema.findOne, + find: mockSchema.find, + deleteMany: mockSchema.deleteMany, + }, +}; + +jest.mock('~/lib/db/connectDb', () => { + return { + get models() { + return mockModels; + }, + }; +}); + const { saveMessage, getMessages, @@ -153,7 +168,7 @@ describe('Message Operations', () => { }); describe('Conversation Hijacking Prevention', () => { - it('should not allow editing a message in another user\'s conversation', async () => { + it("should not allow editing a message in another user's conversation", async () => { const attackerReq = { user: { id: 'attacker123' } }; const victimConversationId = 'victim-convo-123'; const victimMessageId = 'victim-msg-123'; @@ -175,7 +190,7 @@ describe('Message Operations', () => { ); }); - it('should not allow deleting messages from another user\'s conversation', async () => { + it("should not allow deleting messages from another user's conversation", async () => { const attackerReq = { user: { id: 'attacker123' } }; const victimConversationId = 'victim-convo-123'; const victimMessageId = 'victim-msg-123'; @@ -193,7 +208,7 @@ describe('Message Operations', () => { }); }); - it('should not allow inserting a new message into another user\'s conversation', async () => { + it("should not allow inserting a new message into another user's conversation", async () => { const attackerReq = { user: { id: 'attacker123' } }; const victimConversationId = uuidv4(); // Use a valid UUID diff --git a/api/models/Preset.js b/api/models/Preset.js index 970b2958fb..0d550fcc11 100644 --- a/api/models/Preset.js +++ b/api/models/Preset.js @@ -1,9 +1,9 @@ -const Preset = require('./schema/presetSchema'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); const getPreset = async (user, presetId) => { try { - return await Preset.findOne({ user, presetId }).lean(); + return await db.models.Preset.findOne({ user, presetId }).lean(); } catch (error) { logger.error('[getPreset] Error getting single preset', error); return { message: 'Error getting single preset' }; @@ -11,11 +11,10 @@ const getPreset = async (user, presetId) => { }; module.exports = { - Preset, getPreset, getPresets: async (user, filter) => { try { - const presets = await Preset.find({ ...filter, user }).lean(); + const presets = await db.models.Preset.find({ ...filter, user }).lean(); const defaultValue = 10000; presets.sort((a, b) => { @@ -40,6 +39,7 @@ module.exports = { const setter = { $set: {} }; const { user: _, ...cleanPreset } = preset; const update = { presetId, ...cleanPreset }; + const Preset = db.models.Preset; if (preset.tools && Array.isArray(preset.tools)) { update.tools = preset.tools @@ -77,7 +77,7 @@ module.exports = { deletePresets: async (user, filter) => { // let toRemove = await Preset.find({ ...filter, user }).select('presetId'); // const ids = toRemove.map((instance) => instance.presetId); - let deleteCount = await Preset.deleteMany({ ...filter, user }); + let deleteCount = await db.models.Preset.deleteMany({ ...filter, user }); return deleteCount; }, }; diff --git a/api/models/Project.js b/api/models/Project.js index 43d7263723..1c39239f36 100644 --- a/api/models/Project.js +++ b/api/models/Project.js @@ -1,8 +1,5 @@ -const { model } = require('mongoose'); const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; -const { projectSchema } = require('@librechat/data-schemas'); - -const Project = model('Project', projectSchema); +const db = require('~/lib/db/connectDb'); /** * Retrieve a project by ID and convert the found project document to a plain object. @@ -12,7 +9,7 @@ const Project = model('Project', projectSchema); * @returns {Promise} A plain object representing the project document, or `null` if no project is found. */ const getProjectById = async function (projectId, fieldsToSelect = null) { - const query = Project.findById(projectId); + const query = db.models.Project.findById(projectId); if (fieldsToSelect) { query.select(fieldsToSelect); @@ -39,7 +36,7 @@ const getProjectByName = async function (projectName, fieldsToSelect = null) { select: fieldsToSelect, }; - return await Project.findOneAndUpdate(query, update, options); + return await db.models.Project.findOneAndUpdate(query, update, options); }; /** @@ -50,7 +47,7 @@ const getProjectByName = async function (projectName, fieldsToSelect = null) { * @returns {Promise} The updated project document. */ const addGroupIdsToProject = async function (projectId, promptGroupIds) { - return await Project.findByIdAndUpdate( + return await db.models.Project.findByIdAndUpdate( projectId, { $addToSet: { promptGroupIds: { $each: promptGroupIds } } }, { new: true }, @@ -65,7 +62,7 @@ const addGroupIdsToProject = async function (projectId, promptGroupIds) { * @returns {Promise} The updated project document. */ const removeGroupIdsFromProject = async function (projectId, promptGroupIds) { - return await Project.findByIdAndUpdate( + return await db.models.Project.findByIdAndUpdate( projectId, { $pull: { promptGroupIds: { $in: promptGroupIds } } }, { new: true }, @@ -79,7 +76,7 @@ const removeGroupIdsFromProject = async function (projectId, promptGroupIds) { * @returns {Promise} */ const removeGroupFromAllProjects = async (promptGroupId) => { - await Project.updateMany({}, { $pull: { promptGroupIds: promptGroupId } }); + await db.models.Project.updateMany({}, { $pull: { promptGroupIds: promptGroupId } }); }; /** @@ -90,7 +87,7 @@ const removeGroupFromAllProjects = async (promptGroupId) => { * @returns {Promise} The updated project document. */ const addAgentIdsToProject = async function (projectId, agentIds) { - return await Project.findByIdAndUpdate( + return await db.models.Project.findByIdAndUpdate( projectId, { $addToSet: { agentIds: { $each: agentIds } } }, { new: true }, @@ -105,7 +102,7 @@ const addAgentIdsToProject = async function (projectId, agentIds) { * @returns {Promise} The updated project document. */ const removeAgentIdsFromProject = async function (projectId, agentIds) { - return await Project.findByIdAndUpdate( + return await db.models.Project.findByIdAndUpdate( projectId, { $pull: { agentIds: { $in: agentIds } } }, { new: true }, @@ -119,7 +116,7 @@ const removeAgentIdsFromProject = async function (projectId, agentIds) { * @returns {Promise} */ const removeAgentFromAllProjects = async (agentId) => { - await Project.updateMany({}, { $pull: { agentIds: agentId } }); + await db.models.Project.updateMany({}, { $pull: { agentIds: agentId } }); }; module.exports = { diff --git a/api/models/Prompt.js b/api/models/Prompt.js index 43dc3ec22b..cd159b93fe 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -1,4 +1,3 @@ -const mongoose = require('mongoose'); const { ObjectId } = require('mongodb'); const { SystemRoles, SystemCategories, Constants } = require('librechat-data-provider'); const { @@ -7,12 +6,9 @@ const { removeGroupIdsFromProject, removeGroupFromAllProjects, } = require('./Project'); -const { promptGroupSchema, promptSchema } = require('@librechat/data-schemas'); const { escapeRegExp } = require('~/server/utils'); const { logger } = require('~/config'); - -const PromptGroup = mongoose.model('PromptGroup', promptGroupSchema); -const Prompt = mongoose.model('Prompt', promptSchema); +const db = require('~/lib/db/connectDb'); /** * Create a pipeline for the aggregation to get prompt groups @@ -137,7 +133,7 @@ const getAllPromptGroups = async (req, filter) => { } const promptGroupsPipeline = createAllGroupsPipeline(combinedQuery); - return await PromptGroup.aggregate(promptGroupsPipeline).exec(); + return await db.models.PromptGroup.aggregate(promptGroupsPipeline).exec(); } catch (error) { console.error('Error getting all prompt groups', error); return { message: 'Error getting all prompt groups' }; @@ -237,7 +233,7 @@ const deletePromptGroup = async ({ _id, author, role }) => { throw new Error('Prompt group not found'); } - await Prompt.deleteMany(groupQuery); + await db.models.Prompt.deleteMany(groupQuery); await removeGroupFromAllProjects(_id); return { message: 'Prompt group deleted successfully' }; }; @@ -254,6 +250,7 @@ module.exports = { createPromptGroup: async (saveData) => { try { const { prompt, group, author, authorName } = saveData; + const { Prompt, PromptGroup } = db.models; let newPromptGroup = await PromptGroup.findOneAndUpdate( { ...group, author, authorName, productionId: null }, @@ -309,6 +306,7 @@ module.exports = { /** @type {TPrompt} */ let newPrompt; + const { Prompt } = db.models; try { newPrompt = await Prompt.create(newPromptData); } catch (error) { @@ -328,7 +326,7 @@ module.exports = { }, getPrompts: async (filter) => { try { - return await Prompt.find(filter).sort({ createdAt: -1 }).lean(); + return await db.models.Prompt.find(filter).sort({ createdAt: -1 }).lean(); } catch (error) { logger.error('Error getting prompts', error); return { message: 'Error getting prompts' }; @@ -339,7 +337,7 @@ module.exports = { if (filter.groupId) { filter.groupId = new ObjectId(filter.groupId); } - return await Prompt.findOne(filter).lean(); + return await db.models.Prompt.findOne(filter).lean(); } catch (error) { logger.error('Error getting prompt', error); return { message: 'Error getting prompt' }; @@ -352,7 +350,7 @@ module.exports = { */ getRandomPromptGroups: async (filter) => { try { - const result = await PromptGroup.aggregate([ + const result = await db.models.PromptGroup.aggregate([ { $match: { category: { $ne: '' }, @@ -385,7 +383,7 @@ module.exports = { }, getPromptGroupsWithPrompts: async (filter) => { try { - return await PromptGroup.findOne(filter) + return await db.models.PromptGroup.findOne(filter) .populate({ path: 'prompts', select: '-_id -__v -user', @@ -399,7 +397,7 @@ module.exports = { }, getPromptGroup: async (filter) => { try { - return await PromptGroup.findOne(filter).lean(); + return await db.models.PromptGroup.findOne(filter).lean(); } catch (error) { logger.error('Error getting prompt group', error); return { message: 'Error getting prompt group' }; @@ -420,6 +418,7 @@ module.exports = { */ deletePrompt: async ({ promptId, groupId, author, role }) => { const query = { _id: promptId, groupId, author }; + const { Prompt, PromptGroup } = db.models; if (role === SystemRoles.ADMIN) { delete query.author; } @@ -484,7 +483,7 @@ module.exports = { } const updateData = { ...data, ...updateOps }; - const updatedDoc = await PromptGroup.findOneAndUpdate(filter, updateData, { + const updatedDoc = await db.models.PromptGroup.findOneAndUpdate(filter, updateData, { new: true, upsert: false, }); @@ -506,6 +505,7 @@ module.exports = { */ makePromptProduction: async (promptId) => { try { + const { Prompt, PromptGroup } = db.models; const prompt = await Prompt.findById(promptId).lean(); if (!prompt) { @@ -530,7 +530,7 @@ module.exports = { }, updatePromptLabels: async (_id, labels) => { try { - const response = await Prompt.updateOne({ _id }, { $set: { labels } }); + const response = await db.models.Prompt.updateOne({ _id }, { $set: { labels } }); if (response.matchedCount === 0) { return { message: 'Prompt not found' }; } diff --git a/api/models/Role.js b/api/models/Role.js index 07bf5a2ccb..b337968add 100644 --- a/api/models/Role.js +++ b/api/models/Role.js @@ -1,4 +1,3 @@ -const mongoose = require('mongoose'); const { CacheKeys, SystemRoles, @@ -8,10 +7,8 @@ const { removeNullishValues, } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); -const { roleSchema } = require('@librechat/data-schemas'); const { logger } = require('~/config'); - -const Role = mongoose.model('Role', roleSchema); +const db = require('~/lib/db/connectDb'); /** * Retrieve a role by name and convert the found role document to a plain object. @@ -24,6 +21,7 @@ const Role = mongoose.model('Role', roleSchema); */ const getRoleByName = async function (roleName, fieldsToSelect = null) { const cache = getLogStores(CacheKeys.ROLES); + const { Role } = db.models; try { const cachedRole = await cache.get(roleName); if (cachedRole) { @@ -57,7 +55,7 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) { const updateRoleByName = async function (roleName, updates) { const cache = getLogStores(CacheKeys.ROLES); try { - const role = await Role.findOneAndUpdate( + const role = await db.models.Role.findOneAndUpdate( { name: roleName }, { $set: updates }, { new: true, lean: true }, @@ -78,6 +76,7 @@ const updateRoleByName = async function (roleName, updates) { * @param {Object.>} permissionsUpdate - Permissions to update and their values. */ async function updateAccessPermissions(roleName, permissionsUpdate) { + const { Role } = db.models; // Filter and clean the permission updates based on our schema definition. const updates = {}; for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) { @@ -181,6 +180,7 @@ async function updateAccessPermissions(roleName, permissionsUpdate) { * @returns {Promise} */ const initializeRoles = async function () { + const { Role } = db.models; for (const roleName of [SystemRoles.ADMIN, SystemRoles.USER]) { let role = await Role.findOne({ name: roleName }); const defaultPerms = roleDefaults[roleName].permissions; @@ -210,6 +210,7 @@ const initializeRoles = async function () { * @returns {Promise} Number of roles migrated. */ const migrateRoleSchema = async function (roleName) { + const { Role } = db.models; try { // Get roles to migrate let roles; @@ -282,7 +283,6 @@ const migrateRoleSchema = async function (roleName) { }; module.exports = { - Role, getRoleByName, initializeRoles, updateRoleByName, diff --git a/api/models/Role.spec.js b/api/models/Role.spec.js index a8b60801ca..0b5431dfa4 100644 --- a/api/models/Role.spec.js +++ b/api/models/Role.spec.js @@ -6,9 +6,11 @@ const { roleDefaults, PermissionTypes, } = require('librechat-data-provider'); -const { Role, getRoleByName, updateAccessPermissions, initializeRoles } = require('~/models/Role'); +const { getRoleByName, updateAccessPermissions, initializeRoles } = require('~/models/Role'); const getLogStores = require('~/cache/getLogStores'); +const db = require('~/lib/db/connectDb'); + // Mock the cache jest.mock('~/cache/getLogStores', () => jest.fn().mockReturnValue({ @@ -19,11 +21,14 @@ jest.mock('~/cache/getLogStores', () => ); let mongoServer; +let Role; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); - await mongoose.connect(mongoUri); + await db.connectDb(mongoUri); + + Role = db.models.Role; }); afterAll(async () => { diff --git a/api/models/Session.js b/api/models/Session.js deleted file mode 100644 index 38821b77dd..0000000000 --- a/api/models/Session.js +++ /dev/null @@ -1,275 +0,0 @@ -const mongoose = require('mongoose'); -const signPayload = require('~/server/services/signPayload'); -const { hashToken } = require('~/server/utils/crypto'); -const { sessionSchema } = require('@librechat/data-schemas'); -const { logger } = require('~/config'); - -const Session = mongoose.model('Session', sessionSchema); - -const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; -const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; // 7 days default - -/** - * Error class for Session-related errors - */ -class SessionError extends Error { - constructor(message, code = 'SESSION_ERROR') { - super(message); - this.name = 'SessionError'; - this.code = code; - } -} - -/** - * Creates a new session for a user - * @param {string} userId - The ID of the user - * @param {Object} options - Additional options for session creation - * @param {Date} options.expiration - Custom expiration date - * @returns {Promise<{session: Session, refreshToken: string}>} - * @throws {SessionError} - */ -const createSession = async (userId, options = {}) => { - if (!userId) { - throw new SessionError('User ID is required', 'INVALID_USER_ID'); - } - - try { - const session = new Session({ - user: userId, - expiration: options.expiration || new Date(Date.now() + expires), - }); - const refreshToken = await generateRefreshToken(session); - return { session, refreshToken }; - } catch (error) { - logger.error('[createSession] Error creating session:', error); - throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED'); - } -}; - -/** - * Finds a session by various parameters - * @param {Object} params - Search parameters - * @param {string} [params.refreshToken] - The refresh token to search by - * @param {string} [params.userId] - The user ID to search by - * @param {string} [params.sessionId] - The session ID to search by - * @param {Object} [options] - Additional options - * @param {boolean} [options.lean=true] - Whether to return plain objects instead of documents - * @returns {Promise} - * @throws {SessionError} - */ -const findSession = async (params, options = { lean: true }) => { - try { - const query = {}; - - if (!params.refreshToken && !params.userId && !params.sessionId) { - throw new SessionError('At least one search parameter is required', 'INVALID_SEARCH_PARAMS'); - } - - if (params.refreshToken) { - const tokenHash = await hashToken(params.refreshToken); - query.refreshTokenHash = tokenHash; - } - - if (params.userId) { - query.user = params.userId; - } - - if (params.sessionId) { - const sessionId = params.sessionId.sessionId || params.sessionId; - if (!mongoose.Types.ObjectId.isValid(sessionId)) { - throw new SessionError('Invalid session ID format', 'INVALID_SESSION_ID'); - } - query._id = sessionId; - } - - // Add expiration check to only return valid sessions - query.expiration = { $gt: new Date() }; - - const sessionQuery = Session.findOne(query); - - if (options.lean) { - return await sessionQuery.lean(); - } - - return await sessionQuery.exec(); - } catch (error) { - logger.error('[findSession] Error finding session:', error); - throw new SessionError('Failed to find session', 'FIND_SESSION_FAILED'); - } -}; - -/** - * Updates session expiration - * @param {Session|string} session - The session or session ID to update - * @param {Date} [newExpiration] - Optional new expiration date - * @returns {Promise} - * @throws {SessionError} - */ -const updateExpiration = async (session, newExpiration) => { - try { - const sessionDoc = typeof session === 'string' ? await Session.findById(session) : session; - - if (!sessionDoc) { - throw new SessionError('Session not found', 'SESSION_NOT_FOUND'); - } - - sessionDoc.expiration = newExpiration || new Date(Date.now() + expires); - return await sessionDoc.save(); - } catch (error) { - logger.error('[updateExpiration] Error updating session:', error); - throw new SessionError('Failed to update session expiration', 'UPDATE_EXPIRATION_FAILED'); - } -}; - -/** - * Deletes a session by refresh token or session ID - * @param {Object} params - Delete parameters - * @param {string} [params.refreshToken] - The refresh token of the session to delete - * @param {string} [params.sessionId] - The ID of the session to delete - * @returns {Promise} - * @throws {SessionError} - */ -const deleteSession = async (params) => { - try { - if (!params.refreshToken && !params.sessionId) { - throw new SessionError( - 'Either refreshToken or sessionId is required', - 'INVALID_DELETE_PARAMS', - ); - } - - const query = {}; - - if (params.refreshToken) { - query.refreshTokenHash = await hashToken(params.refreshToken); - } - - if (params.sessionId) { - query._id = params.sessionId; - } - - const result = await Session.deleteOne(query); - - if (result.deletedCount === 0) { - logger.warn('[deleteSession] No session found to delete'); - } - - return result; - } catch (error) { - logger.error('[deleteSession] Error deleting session:', error); - throw new SessionError('Failed to delete session', 'DELETE_SESSION_FAILED'); - } -}; - -/** - * Deletes all sessions for a user - * @param {string} userId - The ID of the user - * @param {Object} [options] - Additional options - * @param {boolean} [options.excludeCurrentSession] - Whether to exclude the current session - * @param {string} [options.currentSessionId] - The ID of the current session to exclude - * @returns {Promise} - * @throws {SessionError} - */ -const deleteAllUserSessions = async (userId, options = {}) => { - try { - if (!userId) { - throw new SessionError('User ID is required', 'INVALID_USER_ID'); - } - - // Extract userId if it's passed as an object - const userIdString = userId.userId || userId; - - if (!mongoose.Types.ObjectId.isValid(userIdString)) { - throw new SessionError('Invalid user ID format', 'INVALID_USER_ID_FORMAT'); - } - - const query = { user: userIdString }; - - if (options.excludeCurrentSession && options.currentSessionId) { - query._id = { $ne: options.currentSessionId }; - } - - const result = await Session.deleteMany(query); - - if (result.deletedCount > 0) { - logger.debug( - `[deleteAllUserSessions] Deleted ${result.deletedCount} sessions for user ${userIdString}.`, - ); - } - - return result; - } catch (error) { - logger.error('[deleteAllUserSessions] Error deleting user sessions:', error); - throw new SessionError('Failed to delete user sessions', 'DELETE_ALL_SESSIONS_FAILED'); - } -}; - -/** - * Generates a refresh token for a session - * @param {Session} session - The session to generate a token for - * @returns {Promise} - * @throws {SessionError} - */ -const generateRefreshToken = async (session) => { - if (!session || !session.user) { - throw new SessionError('Invalid session object', 'INVALID_SESSION'); - } - - try { - const expiresIn = session.expiration ? session.expiration.getTime() : Date.now() + expires; - - if (!session.expiration) { - session.expiration = new Date(expiresIn); - } - - const refreshToken = await signPayload({ - payload: { - id: session.user, - sessionId: session._id, - }, - secret: process.env.JWT_REFRESH_SECRET, - expirationTime: Math.floor((expiresIn - Date.now()) / 1000), - }); - - session.refreshTokenHash = await hashToken(refreshToken); - await session.save(); - - return refreshToken; - } catch (error) { - logger.error('[generateRefreshToken] Error generating refresh token:', error); - throw new SessionError('Failed to generate refresh token', 'GENERATE_TOKEN_FAILED'); - } -}; - -/** - * Counts active sessions for a user - * @param {string} userId - The ID of the user - * @returns {Promise} - * @throws {SessionError} - */ -const countActiveSessions = async (userId) => { - try { - if (!userId) { - throw new SessionError('User ID is required', 'INVALID_USER_ID'); - } - - return await Session.countDocuments({ - user: userId, - expiration: { $gt: new Date() }, - }); - } catch (error) { - logger.error('[countActiveSessions] Error counting active sessions:', error); - throw new SessionError('Failed to count active sessions', 'COUNT_SESSIONS_FAILED'); - } -}; - -module.exports = { - createSession, - findSession, - updateExpiration, - deleteSession, - deleteAllUserSessions, - generateRefreshToken, - countActiveSessions, - SessionError, -}; diff --git a/api/models/Share.js b/api/models/Share.js index 8611d01bc0..441dfa25d6 100644 --- a/api/models/Share.js +++ b/api/models/Share.js @@ -1,9 +1,6 @@ -const mongoose = require('mongoose'); const { nanoid } = require('nanoid'); const { Constants } = require('librechat-data-provider'); -const { Conversation } = require('~/models/Conversation'); -const { shareSchema } = require('@librechat/data-schemas'); -const SharedLink = mongoose.model('SharedLink', shareSchema); +const db = require('~/lib/db/connectDb'); const { getMessages } = require('./Message'); const logger = require('~/config/winston'); @@ -76,7 +73,7 @@ function anonymizeMessages(messages, newConvoId) { async function getSharedMessages(shareId) { try { - const share = await SharedLink.findOne({ shareId, isPublic: true }) + const share = await db.models.SharedLink.findOne({ shareId, isPublic: true }) .populate({ path: 'messages', select: '-_id -__v -user', @@ -151,7 +148,7 @@ async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortD query.conversationId = { $in: query.conversationId }; } - const sharedLinks = await SharedLink.find(query) + const sharedLinks = await db.models.SharedLink.find(query) .sort(sort) .limit(pageSize + 1) .select('-__v -user') @@ -184,7 +181,7 @@ async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortD async function deleteAllSharedLinks(user) { try { - const result = await SharedLink.deleteMany({ user }); + const result = await db.models.SharedLink.deleteMany({ user }); return { message: 'All shared links deleted successfully', deletedCount: result.deletedCount, @@ -202,7 +199,7 @@ async function createSharedLink(user, conversationId) { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); } - + const { SharedLink, Conversation } = db.models; try { const [existingShare, conversationMessages] = await Promise.all([ SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(), @@ -244,7 +241,7 @@ async function getSharedLink(user, conversationId) { } try { - const share = await SharedLink.findOne({ conversationId, user, isPublic: true }) + const share = await db.models.SharedLink.findOne({ conversationId, user, isPublic: true }) .select('shareId -_id') .lean(); @@ -268,6 +265,7 @@ async function updateSharedLink(user, shareId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); } + const { SharedLink } = db.models; try { const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean(); @@ -318,7 +316,7 @@ async function deleteSharedLink(user, shareId) { } try { - const result = await SharedLink.findOneAndDelete({ shareId, user }).lean(); + const result = await db.models.SharedLink.findOneAndDelete({ shareId, user }).lean(); if (!result) { return null; @@ -340,7 +338,6 @@ async function deleteSharedLink(user, shareId) { } module.exports = { - SharedLink, getSharedLink, getSharedLinks, createSharedLink, diff --git a/api/models/Token.js b/api/models/Token.js index c89abb8c84..3fad16d668 100644 --- a/api/models/Token.js +++ b/api/models/Token.js @@ -1,13 +1,6 @@ -const mongoose = require('mongoose'); const { encryptV2 } = require('~/server/utils/crypto'); -const { tokenSchema } = require('@librechat/data-schemas'); const { logger } = require('~/config'); - -/** - * Token model. - * @type {mongoose.Model} - */ -const Token = mongoose.model('Token', tokenSchema); +const db = require('~/lib/db/connectDb'); /** * Fixes the indexes for the Token collection from legacy TTL indexes to the new expiresAt index. */ @@ -20,7 +13,7 @@ async function fixIndexes() { ) { return; } - const indexes = await Token.collection.indexes(); + const indexes = await db.models.Token.collection.indexes(); logger.debug('Existing Token Indexes:', JSON.stringify(indexes, null, 2)); const unwantedTTLIndexes = indexes.filter( (index) => index.key.createdAt === 1 && index.expireAfterSeconds !== undefined, @@ -31,7 +24,7 @@ async function fixIndexes() { } for (const index of unwantedTTLIndexes) { logger.debug(`Dropping unwanted Token index: ${index.name}`); - await Token.collection.dropIndex(index.name); + await db.models.Token.collection.dropIndex(index.name); logger.debug(`Dropped Token index: ${index.name}`); } logger.debug('Token index cleanup completed successfully.'); @@ -42,118 +35,6 @@ async function fixIndexes() { fixIndexes(); -/** - * Creates a new Token instance. - * @param {Object} tokenData - The data for the new Token. - * @param {mongoose.Types.ObjectId} tokenData.userId - The user's ID. It is required. - * @param {String} tokenData.email - The user's email. - * @param {String} tokenData.token - The token. It is required. - * @param {Number} tokenData.expiresIn - The number of seconds until the token expires. - * @returns {Promise} The new Token instance. - * @throws Will throw an error if token creation fails. - */ -async function createToken(tokenData) { - try { - const currentTime = new Date(); - const expiresAt = new Date(currentTime.getTime() + tokenData.expiresIn * 1000); - - const newTokenData = { - ...tokenData, - createdAt: currentTime, - expiresAt, - }; - - return await Token.create(newTokenData); - } catch (error) { - logger.debug('An error occurred while creating token:', error); - throw error; - } -} - -/** - * Finds a Token document that matches the provided query. - * @param {Object} query - The query to match against. - * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. - * @param {String} query.token - The token value. - * @param {String} [query.email] - The email of the user. - * @param {String} [query.identifier] - Unique, alternative identifier for the token. - * @returns {Promise} The matched Token document, or null if not found. - * @throws Will throw an error if the find operation fails. - */ -async function findToken(query) { - try { - const conditions = []; - - if (query.userId) { - conditions.push({ userId: query.userId }); - } - if (query.token) { - conditions.push({ token: query.token }); - } - if (query.email) { - conditions.push({ email: query.email }); - } - if (query.identifier) { - conditions.push({ identifier: query.identifier }); - } - - const token = await Token.findOne({ - $and: conditions, - }).lean(); - - return token; - } catch (error) { - logger.debug('An error occurred while finding token:', error); - throw error; - } -} - -/** - * Updates a Token document that matches the provided query. - * @param {Object} query - The query to match against. - * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. - * @param {String} query.token - The token value. - * @param {String} [query.email] - The email of the user. - * @param {String} [query.identifier] - Unique, alternative identifier for the token. - * @param {Object} updateData - The data to update the Token with. - * @returns {Promise} The updated Token document, or null if not found. - * @throws Will throw an error if the update operation fails. - */ -async function updateToken(query, updateData) { - try { - return await Token.findOneAndUpdate(query, updateData, { new: true }); - } catch (error) { - logger.debug('An error occurred while updating token:', error); - throw error; - } -} - -/** - * Deletes all Token documents that match the provided token, user ID, or email. - * @param {Object} query - The query to match against. - * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. - * @param {String} query.token - The token value. - * @param {String} [query.email] - The email of the user. - * @param {String} [query.identifier] - Unique, alternative identifier for the token. - * @returns {Promise} The result of the delete operation. - * @throws Will throw an error if the delete operation fails. - */ -async function deleteTokens(query) { - try { - return await Token.deleteMany({ - $or: [ - { userId: query.userId }, - { token: query.token }, - { email: query.email }, - { identifier: query.identifier }, - ], - }); - } catch (error) { - logger.debug('An error occurred while deleting tokens:', error); - throw error; - } -} - /** * Handles the OAuth token by creating or updating the token. * @param {object} fields @@ -182,18 +63,15 @@ async function handleOAuthToken({ expiresIn: parseInt(expiresIn, 10) || 3600, }; - const existingToken = await findToken({ userId, identifier }); + const {Token} = db.models; + const existingToken = await Token.findToken({ userId, identifier }); if (existingToken) { - return await updateToken({ identifier }, tokenData); + return await Token.updateToken({ identifier }, tokenData); } else { - return await createToken(tokenData); + return await Token.createToken(tokenData); } } module.exports = { - findToken, - createToken, - updateToken, - deleteTokens, handleOAuthToken, }; diff --git a/api/models/ToolCall.js b/api/models/ToolCall.js index 7bc0f157dc..9df2f2f846 100644 --- a/api/models/ToolCall.js +++ b/api/models/ToolCall.js @@ -1,7 +1,4 @@ -const mongoose = require('mongoose'); -const { toolCallSchema } = require('@librechat/data-schemas'); -const ToolCall = mongoose.model('ToolCall', toolCallSchema); - +const db = require('~/lib/db/connectDb'); /** * Create a new tool call * @param {IToolCallData} toolCallData - The tool call data @@ -9,7 +6,7 @@ const ToolCall = mongoose.model('ToolCall', toolCallSchema); */ async function createToolCall(toolCallData) { try { - return await ToolCall.create(toolCallData); + return await db.models.ToolCall.create(toolCallData); } catch (error) { throw new Error(`Error creating tool call: ${error.message}`); } @@ -22,7 +19,7 @@ async function createToolCall(toolCallData) { */ async function getToolCallById(id) { try { - return await ToolCall.findById(id).lean(); + return await db.models.ToolCall.findById(id).lean(); } catch (error) { throw new Error(`Error fetching tool call: ${error.message}`); } @@ -36,7 +33,7 @@ async function getToolCallById(id) { */ async function getToolCallsByMessage(messageId, userId) { try { - return await ToolCall.find({ messageId, user: userId }).lean(); + return await db.models.ToolCall.find({ messageId, user: userId }).lean(); } catch (error) { throw new Error(`Error fetching tool calls: ${error.message}`); } @@ -50,7 +47,7 @@ async function getToolCallsByMessage(messageId, userId) { */ async function getToolCallsByConvo(conversationId, userId) { try { - return await ToolCall.find({ conversationId, user: userId }).lean(); + return await db.models.ToolCall.find({ conversationId, user: userId }).lean(); } catch (error) { throw new Error(`Error fetching tool calls: ${error.message}`); } @@ -64,7 +61,7 @@ async function getToolCallsByConvo(conversationId, userId) { */ async function updateToolCall(id, updateData) { try { - return await ToolCall.findByIdAndUpdate(id, updateData, { new: true }).lean(); + return await db.models.ToolCall.findByIdAndUpdate(id, updateData, { new: true }).lean(); } catch (error) { throw new Error(`Error updating tool call: ${error.message}`); } @@ -82,7 +79,7 @@ async function deleteToolCalls(userId, conversationId) { if (conversationId) { query.conversationId = conversationId; } - return await ToolCall.deleteMany(query); + return await db.models.ToolCall.deleteMany(query); } catch (error) { throw new Error(`Error deleting tool call: ${error.message}`); } diff --git a/api/models/Transaction.js b/api/models/Transaction.js index e171241b61..a28b2a475f 100644 --- a/api/models/Transaction.js +++ b/api/models/Transaction.js @@ -3,7 +3,7 @@ const { transactionSchema } = require('@librechat/data-schemas'); const { getBalanceConfig } = require('~/server/services/Config'); const { getMultiplier, getCacheMultiplier } = require('./tx'); const { logger } = require('~/config'); -const Balance = require('./Balance'); +const db = require('~/lib/db/connectDb'); const cancelRate = 1.15; @@ -23,6 +23,7 @@ const updateBalance = async ({ user, incrementValue, setValues }) => { let maxRetries = 10; // Number of times to retry on conflict let delay = 50; // Initial retry delay in ms let lastError = null; + const { Balance } = db.models; for (let attempt = 1; attempt <= maxRetries; attempt++) { let currentBalanceDoc; @@ -140,19 +141,19 @@ const updateBalance = async ({ user, incrementValue, setValues }) => { }; /** Method to calculate and set the tokenValue for a transaction */ -transactionSchema.methods.calculateTokenValue = function () { - if (!this.valueKey || !this.tokenType) { - this.tokenValue = this.rawAmount; +function calculateTokenValue(txn) { + if (!txn.valueKey || !txn.tokenType) { + txn.tokenValue = txn.rawAmount; } - const { valueKey, tokenType, model, endpointTokenConfig } = this; + const { valueKey, tokenType, model, endpointTokenConfig } = txn; const multiplier = Math.abs(getMultiplier({ valueKey, tokenType, model, endpointTokenConfig })); - this.rate = multiplier; - this.tokenValue = this.rawAmount * multiplier; - if (this.context && this.tokenType === 'completion' && this.context === 'incomplete') { - this.tokenValue = Math.ceil(this.tokenValue * cancelRate); - this.rate *= cancelRate; + txn.rate = multiplier; + txn.tokenValue = txn.rawAmount * multiplier; + if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') { + txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate); + txn.rate *= cancelRate; } -}; +} /** * New static method to create an auto-refill transaction that does NOT trigger a balance update. @@ -163,13 +164,14 @@ transactionSchema.methods.calculateTokenValue = function () { * @param {number} txData.rawAmount - The raw amount of tokens. * @returns {Promise} - The created transaction. */ -transactionSchema.statics.createAutoRefillTransaction = async function (txData) { +async function createAutoRefillTransaction(txData) { + const Transaction = db.models.Transaction; if (txData.rawAmount != null && isNaN(txData.rawAmount)) { return; } - const transaction = new this(txData); + const transaction = new Transaction(txData); transaction.endpointTokenConfig = txData.endpointTokenConfig; - transaction.calculateTokenValue(); + calculateTokenValue(transaction); await transaction.save(); const balanceResponse = await updateBalance({ @@ -185,21 +187,20 @@ transactionSchema.statics.createAutoRefillTransaction = async function (txData) logger.debug('[Balance.check] Auto-refill performed', result); result.transaction = transaction; return result; -}; +} /** * Static method to create a transaction and update the balance * @param {txData} txData - Transaction data. */ -transactionSchema.statics.create = async function (txData) { - const Transaction = this; +async function createTransaction(txData) { if (txData.rawAmount != null && isNaN(txData.rawAmount)) { return; } - const transaction = new Transaction(txData); + const transaction = new db.models.Transaction(txData); transaction.endpointTokenConfig = txData.endpointTokenConfig; - transaction.calculateTokenValue(); + calculateTokenValue(transaction); await transaction.save(); @@ -209,7 +210,6 @@ transactionSchema.statics.create = async function (txData) { } let incrementValue = transaction.tokenValue; - const balanceResponse = await updateBalance({ user: transaction.user, incrementValue, @@ -221,21 +221,19 @@ transactionSchema.statics.create = async function (txData) { balance: balanceResponse.tokenCredits, [transaction.tokenType]: incrementValue, }; -}; +} /** * Static method to create a structured transaction and update the balance * @param {txData} txData - Transaction data. */ -transactionSchema.statics.createStructured = async function (txData) { - const Transaction = this; - - const transaction = new Transaction({ +async function createStructuredTransaction(txData) { + const transaction = new db.models.Transaction({ ...txData, endpointTokenConfig: txData.endpointTokenConfig, }); - transaction.calculateStructuredTokenValue(); + calculateStructuredTokenValue(transaction); await transaction.save(); @@ -257,71 +255,69 @@ transactionSchema.statics.createStructured = async function (txData) { balance: balanceResponse.tokenCredits, [transaction.tokenType]: incrementValue, }; -}; +} /** Method to calculate token value for structured tokens */ -transactionSchema.methods.calculateStructuredTokenValue = function () { - if (!this.tokenType) { - this.tokenValue = this.rawAmount; +function calculateStructuredTokenValue(txn) { + if (!txn.tokenType) { + txn.tokenValue = txn.rawAmount; return; } - const { model, endpointTokenConfig } = this; + const { model, endpointTokenConfig } = txn; - if (this.tokenType === 'prompt') { + if (txn.tokenType === 'prompt') { const inputMultiplier = getMultiplier({ tokenType: 'prompt', model, endpointTokenConfig }); const writeMultiplier = getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier; const readMultiplier = getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? inputMultiplier; - this.rateDetail = { + txn.rateDetail = { input: inputMultiplier, write: writeMultiplier, read: readMultiplier, }; const totalPromptTokens = - Math.abs(this.inputTokens || 0) + - Math.abs(this.writeTokens || 0) + - Math.abs(this.readTokens || 0); + Math.abs(txn.inputTokens || 0) + + Math.abs(txn.writeTokens || 0) + + Math.abs(txn.readTokens || 0); if (totalPromptTokens > 0) { - this.rate = - (Math.abs(inputMultiplier * (this.inputTokens || 0)) + - Math.abs(writeMultiplier * (this.writeTokens || 0)) + - Math.abs(readMultiplier * (this.readTokens || 0))) / + txn.rate = + (Math.abs(inputMultiplier * (txn.inputTokens || 0)) + + Math.abs(writeMultiplier * (txn.writeTokens || 0)) + + Math.abs(readMultiplier * (txn.readTokens || 0))) / totalPromptTokens; } else { - this.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens + txn.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens } - this.tokenValue = -( - Math.abs(this.inputTokens || 0) * inputMultiplier + - Math.abs(this.writeTokens || 0) * writeMultiplier + - Math.abs(this.readTokens || 0) * readMultiplier + txn.tokenValue = -( + Math.abs(txn.inputTokens || 0) * inputMultiplier + + Math.abs(txn.writeTokens || 0) * writeMultiplier + + Math.abs(txn.readTokens || 0) * readMultiplier ); - this.rawAmount = -totalPromptTokens; - } else if (this.tokenType === 'completion') { - const multiplier = getMultiplier({ tokenType: this.tokenType, model, endpointTokenConfig }); - this.rate = Math.abs(multiplier); - this.tokenValue = -Math.abs(this.rawAmount) * multiplier; - this.rawAmount = -Math.abs(this.rawAmount); + txn.rawAmount = -totalPromptTokens; + } else if (txn.tokenType === 'completion') { + const multiplier = getMultiplier({ tokenType: txn.tokenType, model, endpointTokenConfig }); + txn.rate = Math.abs(multiplier); + txn.tokenValue = -Math.abs(txn.rawAmount) * multiplier; + txn.rawAmount = -Math.abs(txn.rawAmount); } - if (this.context && this.tokenType === 'completion' && this.context === 'incomplete') { - this.tokenValue = Math.ceil(this.tokenValue * cancelRate); - this.rate *= cancelRate; - if (this.rateDetail) { - this.rateDetail = Object.fromEntries( - Object.entries(this.rateDetail).map(([k, v]) => [k, v * cancelRate]), + if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') { + txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate); + txn.rate *= cancelRate; + if (txn.rateDetail) { + txn.rateDetail = Object.fromEntries( + Object.entries(txn.rateDetail).map(([k, v]) => [k, v * cancelRate]), ); } } -}; - -const Transaction = mongoose.model('Transaction', transactionSchema); +} /** * Queries and retrieves transactions based on a given filter. @@ -333,11 +329,16 @@ const Transaction = mongoose.model('Transaction', transactionSchema); */ async function getTransactions(filter) { try { - return await Transaction.find(filter).lean(); + return await db.models.Transaction.find(filter).lean(); } catch (error) { logger.error('Error querying transactions:', error); throw error; } } -module.exports = { Transaction, getTransactions }; +module.exports = { + getTransactions, + createTransaction, + createAutoRefillTransaction, + createStructuredTransaction, +}; diff --git a/api/models/Transaction.spec.js b/api/models/Transaction.spec.js index 43f3c004b2..008566e2fa 100644 --- a/api/models/Transaction.spec.js +++ b/api/models/Transaction.spec.js @@ -3,18 +3,22 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); const { spendTokens, spendStructuredTokens } = require('./spendTokens'); const { getBalanceConfig } = require('~/server/services/Config'); const { getMultiplier, getCacheMultiplier } = require('./tx'); -const { Transaction } = require('./Transaction'); -const Balance = require('./Balance'); +const db = require('~/lib/db/connectDb'); +const { createTransaction } = require('./Transaction'); // Mock the custom config module so we can control the balance flag. jest.mock('~/server/services/Config'); let mongoServer; - +let Balance; +let Transaction; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); - await mongoose.connect(mongoUri); + await db.connectDb(mongoUri); + + Balance = db.models.Balance; + Transaction = db.models.Transaction; }); afterAll(async () => { @@ -368,7 +372,7 @@ describe('NaN Handling Tests', () => { }; // Act - const result = await Transaction.create(txData); + const result = await createTransaction(txData); // Assert: No transaction should be created and balance remains unchanged. expect(result).toBeUndefined(); diff --git a/api/models/User.js b/api/models/User.js deleted file mode 100644 index f4e8b0ec5b..0000000000 --- a/api/models/User.js +++ /dev/null @@ -1,6 +0,0 @@ -const mongoose = require('mongoose'); -const { userSchema } = require('@librechat/data-schemas'); - -const User = mongoose.model('User', userSchema); - -module.exports = User; diff --git a/api/models/balanceMethods.js b/api/models/balanceMethods.js index 4b788160aa..e262cfcfe2 100644 --- a/api/models/balanceMethods.js +++ b/api/models/balanceMethods.js @@ -1,9 +1,9 @@ const { ViolationTypes } = require('librechat-data-provider'); -const { Transaction } = require('./Transaction'); +const { createAutoRefillTransaction } = require('./Transaction'); const { logViolation } = require('~/cache'); const { getMultiplier } = require('./tx'); const { logger } = require('~/config'); -const Balance = require('./Balance'); +const db = require('~/lib/db/connectDb'); function isInvalidDate(date) { return isNaN(date); @@ -26,7 +26,7 @@ const checkBalanceRecord = async function ({ const tokenCost = amount * multiplier; // Retrieve the balance record - let record = await Balance.findOne({ user }).lean(); + let record = await db.models.Balance.findOne({ user }).lean(); if (!record) { logger.debug('[Balance.check] No balance record found for user', { user }); return { @@ -60,7 +60,7 @@ const checkBalanceRecord = async function ({ ) { try { /** @type {{ rate: number, user: string, balance: number, transaction: import('@librechat/data-schemas').ITransaction}} */ - const result = await Transaction.createAutoRefillTransaction({ + const result = await createAutoRefillTransaction({ user: user, tokenType: 'credits', context: 'autoRefill', diff --git a/api/models/convoStructure.spec.js b/api/models/convoStructure.spec.js index e672e0fa1c..0720bf3495 100644 --- a/api/models/convoStructure.spec.js +++ b/api/models/convoStructure.spec.js @@ -1,6 +1,7 @@ const mongoose = require('mongoose'); const { MongoMemoryServer } = require('mongodb-memory-server'); -const { Message, getMessages, bulkSaveMessages } = require('./Message'); +const { getMessages, bulkSaveMessages } = require('./Message'); +const db = require('~/lib/db/connectDb'); // Original version of buildTree function function buildTree({ messages, fileMap }) { @@ -42,11 +43,13 @@ function buildTree({ messages, fileMap }) { } let mongod; - +let Message; beforeAll(async () => { mongod = await MongoMemoryServer.create(); const uri = mongod.getUri(); - await mongoose.connect(uri); + await db.connectDb(uri); + + Message = db.models.Message; }); afterAll(async () => { diff --git a/api/models/index.js b/api/models/index.js index 73cfa1c96c..5896b1634f 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -1,13 +1,4 @@ -const { - comparePassword, - deleteUserById, - generateToken, - getUserById, - updateUser, - createUser, - countUsers, - findUser, -} = require('./userMethods'); +const { comparePassword } = require('./userMethods'); const { findFileById, createFile, @@ -26,32 +17,11 @@ const { deleteMessagesSince, deleteMessages, } = require('./Message'); -const { - createSession, - findSession, - updateExpiration, - deleteSession, - deleteAllUserSessions, - generateRefreshToken, - countActiveSessions, -} = require('./Session'); const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation'); const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset'); -const { createToken, findToken, updateToken, deleteTokens } = require('./Token'); -const Balance = require('./Balance'); -const User = require('./User'); -const Key = require('./Key'); module.exports = { comparePassword, - deleteUserById, - generateToken, - getUserById, - updateUser, - createUser, - countUsers, - findUser, - findFileById, createFile, updateFile, @@ -77,21 +47,4 @@ module.exports = { getPresets, savePreset, deletePresets, - - createToken, - findToken, - updateToken, - deleteTokens, - - createSession, - findSession, - updateExpiration, - deleteSession, - deleteAllUserSessions, - generateRefreshToken, - countActiveSessions, - - User, - Key, - Balance, }; diff --git a/api/models/inviteUser.js b/api/models/inviteUser.js index 6cd699fd66..a7519aa76e 100644 --- a/api/models/inviteUser.js +++ b/api/models/inviteUser.js @@ -1,7 +1,7 @@ const mongoose = require('mongoose'); const { getRandomValues, hashToken } = require('~/server/utils/crypto'); -const { createToken, findToken } = require('./Token'); const logger = require('~/config/winston'); +const db = require('~/lib/db/connectDb'); /** * @module inviteUser @@ -23,7 +23,7 @@ const createInvite = async (email) => { const fakeUserId = new mongoose.Types.ObjectId(); - await createToken({ + await db.models.Token.createToken({ userId: fakeUserId, email, token: hash, @@ -50,7 +50,7 @@ const getInvite = async (encodedToken, email) => { try { const token = decodeURIComponent(encodedToken); const hash = await hashToken(token); - const invite = await findToken({ token: hash, email }); + const invite = await db.models.Token.findToken({ token: hash, email }); if (!invite) { throw new Error('Invite not found or email does not match'); diff --git a/api/models/schema/convoSchema.js b/api/models/schema/convoSchema.js deleted file mode 100644 index 89cb9c80b5..0000000000 --- a/api/models/schema/convoSchema.js +++ /dev/null @@ -1,18 +0,0 @@ -const mongoose = require('mongoose'); -const mongoMeili = require('../plugins/mongoMeili'); - -const { convoSchema } = require('@librechat/data-schemas'); - -if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { - convoSchema.plugin(mongoMeili, { - host: process.env.MEILI_HOST, - apiKey: process.env.MEILI_MASTER_KEY, - /** Note: Will get created automatically if it doesn't exist already */ - indexName: 'convos', - primaryKey: 'conversationId', - }); -} - -const Conversation = mongoose.models.Conversation || mongoose.model('Conversation', convoSchema); - -module.exports = Conversation; diff --git a/api/models/schema/messageSchema.js b/api/models/schema/messageSchema.js index cf97b84eea..ebdd911fef 100644 --- a/api/models/schema/messageSchema.js +++ b/api/models/schema/messageSchema.js @@ -1,4 +1,3 @@ -const mongoose = require('mongoose'); const mongoMeili = require('~/models/plugins/mongoMeili'); const { messageSchema } = require('@librechat/data-schemas'); @@ -11,6 +10,6 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { }); } -const Message = mongoose.models.Message || mongoose.model('Message', messageSchema); +// const Message = mongoose.models.Message || mongoose.model('Message', messageSchema); module.exports = Message; diff --git a/api/models/schema/presetSchema.js b/api/models/schema/presetSchema.js deleted file mode 100644 index 6d03803ace..0000000000 --- a/api/models/schema/presetSchema.js +++ /dev/null @@ -1,6 +0,0 @@ -const mongoose = require('mongoose'); -const { presetSchema } = require('@librechat/data-schemas'); - -const Preset = mongoose.models.Preset || mongoose.model('Preset', presetSchema); - -module.exports = Preset; diff --git a/api/models/spendTokens.js b/api/models/spendTokens.js index 36b71ca9fc..48b2309524 100644 --- a/api/models/spendTokens.js +++ b/api/models/spendTokens.js @@ -1,6 +1,6 @@ -const { Transaction } = require('./Transaction'); const { logger } = require('~/config'); - +const db = require('~/lib/db/connectDb'); +const { createTransaction, createStructuredTransaction } = require('./Transaction'); /** * Creates up to two transactions to record the spending of tokens. * @@ -33,7 +33,7 @@ const spendTokens = async (txData, tokenUsage) => { let prompt, completion; try { if (promptTokens !== undefined) { - prompt = await Transaction.create({ + prompt = await createTransaction({ ...txData, tokenType: 'prompt', rawAmount: promptTokens === 0 ? 0 : -Math.max(promptTokens, 0), @@ -41,7 +41,7 @@ const spendTokens = async (txData, tokenUsage) => { } if (completionTokens !== undefined) { - completion = await Transaction.create({ + completion = await createTransaction({ ...txData, tokenType: 'completion', rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0), @@ -101,7 +101,7 @@ const spendStructuredTokens = async (txData, tokenUsage) => { try { if (promptTokens) { const { input = 0, write = 0, read = 0 } = promptTokens; - prompt = await Transaction.createStructured({ + prompt = await createStructuredTransaction({ ...txData, tokenType: 'prompt', inputTokens: -input, @@ -111,7 +111,7 @@ const spendStructuredTokens = async (txData, tokenUsage) => { } if (completionTokens) { - completion = await Transaction.create({ + completion = await createTransaction({ ...txData, tokenType: 'completion', rawAmount: -completionTokens, diff --git a/api/models/spendTokens.spec.js b/api/models/spendTokens.spec.js index eacf420330..a17f752944 100644 --- a/api/models/spendTokens.spec.js +++ b/api/models/spendTokens.spec.js @@ -1,8 +1,8 @@ const mongoose = require('mongoose'); const { MongoMemoryServer } = require('mongodb-memory-server'); -const { Transaction } = require('./Transaction'); -const Balance = require('./Balance'); const { spendTokens, spendStructuredTokens } = require('./spendTokens'); +const db = require('~/lib/db/connectDb'); +const { createTransaction, createAutoRefillTransaction } = require('./Transaction'); // Mock the logger to prevent console output during tests jest.mock('~/config', () => ({ @@ -20,10 +20,15 @@ describe('spendTokens', () => { let mongoServer; let userId; + let Transaction; + let Balance; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); - await mongoose.connect(mongoUri); + await db.connectDb(mongoUri); + + Balance = db.models.Balance; + Transaction = db.models.Transaction; }); afterAll(async () => { @@ -197,7 +202,7 @@ describe('spendTokens', () => { // Check that the transaction records show the adjusted values const transactionResults = await Promise.all( transactions.map((t) => - Transaction.create({ + createTransaction({ ...txData, tokenType: t.tokenType, rawAmount: t.rawAmount, @@ -280,7 +285,7 @@ describe('spendTokens', () => { // Check the return values from Transaction.create directly // This is to verify that the incrementValue is not becoming positive - const directResult = await Transaction.create({ + const directResult = await createTransaction({ user: userId, conversationId: 'test-convo-3', model: 'gpt-4', @@ -607,7 +612,7 @@ describe('spendTokens', () => { const promises = []; for (let i = 0; i < numberOfRefills; i++) { promises.push( - Transaction.createAutoRefillTransaction({ + createAutoRefillTransaction({ user: userId, tokenType: 'credits', context: 'concurrent-refill-test', diff --git a/api/models/userMethods.js b/api/models/userMethods.js index fbcd33aba8..e8bf5e4784 100644 --- a/api/models/userMethods.js +++ b/api/models/userMethods.js @@ -1,159 +1,4 @@ const bcrypt = require('bcryptjs'); -const { getBalanceConfig } = require('~/server/services/Config'); -const signPayload = require('~/server/services/signPayload'); -const Balance = require('./Balance'); -const User = require('./User'); - -/** - * Retrieve a user by ID and convert the found user document to a plain object. - * - * @param {string} userId - The ID of the user to find and return as a plain object. - * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. - * @returns {Promise} A plain object representing the user document, or `null` if no user is found. - */ -const getUserById = async function (userId, fieldsToSelect = null) { - const query = User.findById(userId); - if (fieldsToSelect) { - query.select(fieldsToSelect); - } - return await query.lean(); -}; - -/** - * Search for a single user based on partial data and return matching user document as plain object. - * @param {Partial} searchCriteria - The partial data to use for searching the user. - * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. - * @returns {Promise} A plain object representing the user document, or `null` if no user is found. - */ -const findUser = async function (searchCriteria, fieldsToSelect = null) { - const query = User.findOne(searchCriteria); - if (fieldsToSelect) { - query.select(fieldsToSelect); - } - return await query.lean(); -}; - -/** - * Update a user with new data without overwriting existing properties. - * - * @param {string} userId - The ID of the user to update. - * @param {Object} updateData - An object containing the properties to update. - * @returns {Promise} The updated user document as a plain object, or `null` if no user is found. - */ -const updateUser = async function (userId, updateData) { - const updateOperation = { - $set: updateData, - $unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL - }; - return await User.findByIdAndUpdate(userId, updateOperation, { - new: true, - runValidators: true, - }).lean(); -}; - -/** - * Creates a new user, optionally with a TTL of 1 week. - * @param {MongoUser} data - The user data to be created, must contain user_id. - * @param {boolean} [disableTTL=true] - Whether to disable the TTL. Defaults to `true`. - * @param {boolean} [returnUser=false] - Whether to return the created user object. - * @returns {Promise} A promise that resolves to the created user document ID or user object. - * @throws {Error} If a user with the same user_id already exists. - */ -const createUser = async (data, disableTTL = true, returnUser = false) => { - const balance = await getBalanceConfig(); - const userData = { - ...data, - expiresAt: disableTTL ? null : new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds - }; - - if (disableTTL) { - delete userData.expiresAt; - } - - const user = await User.create(userData); - - // If balance is enabled, create or update a balance record for the user using global.interfaceConfig.balance - if (balance?.enabled && balance?.startBalance) { - const update = { - $inc: { tokenCredits: balance.startBalance }, - }; - - if ( - balance.autoRefillEnabled && - balance.refillIntervalValue != null && - balance.refillIntervalUnit != null && - balance.refillAmount != null - ) { - update.$set = { - autoRefillEnabled: true, - refillIntervalValue: balance.refillIntervalValue, - refillIntervalUnit: balance.refillIntervalUnit, - refillAmount: balance.refillAmount, - }; - } - - await Balance.findOneAndUpdate({ user: user._id }, update, { upsert: true, new: true }).lean(); - } - - if (returnUser) { - return user.toObject(); - } - return user._id; -}; - -/** - * Count the number of user documents in the collection based on the provided filter. - * - * @param {Object} [filter={}] - The filter to apply when counting the documents. - * @returns {Promise} The count of documents that match the filter. - */ -const countUsers = async function (filter = {}) { - return await User.countDocuments(filter); -}; - -/** - * Delete a user by their unique ID. - * - * @param {string} userId - The ID of the user to delete. - * @returns {Promise<{ deletedCount: number }>} An object indicating the number of deleted documents. - */ -const deleteUserById = async function (userId) { - try { - const result = await User.deleteOne({ _id: userId }); - if (result.deletedCount === 0) { - return { deletedCount: 0, message: 'No user found with that ID.' }; - } - return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' }; - } catch (error) { - throw new Error('Error deleting user: ' + error.message); - } -}; - -const { SESSION_EXPIRY } = process.env ?? {}; -const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15; - -/** - * Generates a JWT token for a given user. - * - * @param {MongoUser} user - The user for whom the token is being generated. - * @returns {Promise} A promise that resolves to a JWT token. - */ -const generateToken = async (user) => { - if (!user) { - throw new Error('No user provided'); - } - - return await signPayload({ - payload: { - id: user._id, - username: user.username, - provider: user.provider, - email: user.email, - }, - secret: process.env.JWT_SECRET, - expirationTime: expires / 1000, - }); -}; /** * Compares the provided password with the user's password. @@ -179,11 +24,4 @@ const comparePassword = async (user, candidatePassword) => { module.exports = { comparePassword, - deleteUserById, - generateToken, - getUserById, - countUsers, - createUser, - updateUser, - findUser, }; diff --git a/api/package.json b/api/package.json index 3d3766bde8..5188ef5e04 100644 --- a/api/package.json +++ b/api/package.json @@ -83,7 +83,7 @@ "librechat-data-provider": "*", "librechat-mcp": "*", "lodash": "^4.17.21", - "meilisearch": "^0.38.0", + "meilisearch": "^0.50.0", "memorystore": "^1.6.7", "mime": "^3.0.0", "module-alias": "^2.2.3", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index a71ce7d59a..15633091df 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -8,10 +8,10 @@ const { requestPasswordReset, setOpenIDAuthTokens, } = require('~/server/services/AuthService'); -const { findSession, getUserById, deleteAllUserSessions, findUser } = require('~/models'); const { getOpenIdConfig } = require('~/strategies'); const { logger } = require('~/config'); const { isEnabled } = require('~/server/utils'); +const db = require('~/lib/db/connectDb'); const registrationController = async (req, res) => { try { @@ -48,7 +48,7 @@ const resetPasswordController = async (req, res) => { if (resetPasswordService instanceof Error) { return res.status(400).json(resetPasswordService); } else { - await deleteAllUserSessions({ userId: req.body.userId }); + await db.models.Session.deleteAllUserSessions({ userId: req.body.userId }); return res.status(200).json(resetPasswordService); } } catch (e) { @@ -83,7 +83,7 @@ const refreshController = async (req, res) => { } try { const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); - const user = await getUserById(payload.id, '-password -__v -totpSecret'); + const user = await db.models.User.getUserById(payload.id, '-password -__v -totpSecret'); if (!user) { return res.status(401).redirect('/login'); } @@ -96,7 +96,10 @@ const refreshController = async (req, res) => { } // Find the session with the hashed refresh token - const session = await findSession({ userId: userId, refreshToken: refreshToken }); + const session = await db.models.Session.findSession({ + userId: userId, + refreshToken: refreshToken, + }); if (session && session.expiration > new Date()) { const token = await setAuthTokens(userId, res, session._id); diff --git a/api/server/controllers/Balance.js b/api/server/controllers/Balance.js index 0361045c72..62e721b597 100644 --- a/api/server/controllers/Balance.js +++ b/api/server/controllers/Balance.js @@ -1,7 +1,6 @@ -const Balance = require('~/models/Balance'); - +const db = require('~/lib/db/connectDb'); async function balanceController(req, res) { - const balanceData = await Balance.findOne( + const balanceData = await db.models.Balance.findOne( { user: req.user.id }, '-_id tokenCredits autoRefillEnabled refillIntervalValue refillIntervalUnit lastRefill refillAmount', ).lean(); diff --git a/api/server/controllers/TwoFactorController.js b/api/server/controllers/TwoFactorController.js index f5783f45ad..2f481a32d3 100644 --- a/api/server/controllers/TwoFactorController.js +++ b/api/server/controllers/TwoFactorController.js @@ -5,10 +5,9 @@ const { verifyBackupCode, getTOTPSecret, } = require('~/server/services/twoFactorService'); -const { updateUser, getUserById } = require('~/models'); const { logger } = require('~/config'); const { encryptV3 } = require('~/server/utils/crypto'); - +const db = require('~/lib/db/connectDb'); const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, ''); /** @@ -25,7 +24,7 @@ const enable2FA = async (req, res) => { const encryptedSecret = encryptV3(secret); // Update the user record: store the secret & backup codes and set twoFactorEnabled to false. - const user = await updateUser(userId, { + const user = await db.models.User.updateUser(userId, { totpSecret: encryptedSecret, backupCodes: codeObjects, twoFactorEnabled: false, @@ -47,7 +46,7 @@ const verify2FA = async (req, res) => { try { const userId = req.user.id; const { token, backupCode } = req.body; - const user = await getUserById(userId); + const user = await db.models.User.getUserById(userId); if (!user || !user.totpSecret) { return res.status(400).json({ message: '2FA not initiated' }); @@ -79,7 +78,8 @@ const confirm2FA = async (req, res) => { try { const userId = req.user.id; const { token } = req.body; - const user = await getUserById(userId); + const { User } = db.models; + const user = await User.getUserById(userId); if (!user || !user.totpSecret) { return res.status(400).json({ message: '2FA not initiated' }); @@ -87,7 +87,7 @@ const confirm2FA = async (req, res) => { const secret = await getTOTPSecret(user.totpSecret); if (await verifyTOTP(secret, token)) { - await updateUser(userId, { twoFactorEnabled: true }); + await User.updateUser(userId, { twoFactorEnabled: true }); return res.status(200).json(); } return res.status(400).json({ message: 'Invalid token.' }); @@ -103,7 +103,7 @@ const confirm2FA = async (req, res) => { const disable2FA = async (req, res) => { try { const userId = req.user.id; - await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false }); + await db.models.User.updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false }); return res.status(200).json(); } catch (err) { logger.error('[disable2FA]', err); @@ -118,7 +118,7 @@ const regenerateBackupCodes = async (req, res) => { try { const userId = req.user.id; const { plainCodes, codeObjects } = await generateBackupCodes(); - await updateUser(userId, { backupCodes: codeObjects }); + await db.models.User.updateUser(userId, { backupCodes: codeObjects }); return res.status(200).json({ backupCodes: plainCodes, backupCodesHash: codeObjects, diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index 571c454552..80ede8e7f4 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -8,15 +8,11 @@ const { const { Balance, getFiles, - updateUser, deleteFiles, deleteConvos, deletePresets, deleteMessages, - deleteUserById, - deleteAllUserSessions, } = require('~/models'); -const User = require('~/models/User'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); @@ -26,6 +22,7 @@ const { deleteAllSharedLinks } = require('~/models/Share'); const { deleteToolCalls } = require('~/models/ToolCall'); const { Transaction } = require('~/models/Transaction'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); const getUserController = async (req, res) => { /** @type {MongoUser} */ @@ -39,7 +36,7 @@ const getUserController = async (req, res) => { const originalAvatar = userData.avatar; try { userData.avatar = await getNewS3URL(userData.avatar); - await updateUser(userData.id, { avatar: userData.avatar }); + await db.models.User.updateUser(userData.id, { avatar: userData.avatar }); } catch (error) { userData.avatar = originalAvatar; logger.error('Error getting new S3 URL for avatar:', error); @@ -50,7 +47,7 @@ const getUserController = async (req, res) => { const getTermsStatusController = async (req, res) => { try { - const user = await User.findById(req.user.id); + const user = await db.models.User.findById(req.user.id); if (!user) { return res.status(404).json({ message: 'User not found' }); } @@ -63,7 +60,7 @@ const getTermsStatusController = async (req, res) => { const acceptTermsController = async (req, res) => { try { - const user = await User.findByIdAndUpdate(req.user.id, { termsAccepted: true }, { new: true }); + const user = await db.models.User.findByIdAndUpdate(req.user.id, { termsAccepted: true }, { new: true }); if (!user) { return res.status(404).json({ message: 'User not found' }); } @@ -160,7 +157,7 @@ const deleteUserController = async (req, res) => { try { await deleteMessages({ user: user.id }); // delete user messages - await deleteAllUserSessions({ userId: user.id }); // delete user sessions + await db.models.Session.deleteAllUserSessions({ userId: user.id }); // delete user sessions await Transaction.deleteMany({ user: user.id }); // delete user transactions await deleteUserKey({ userId: user.id, all: true }); // delete user keys await Balance.deleteMany({ user: user._id }); // delete user balances @@ -168,7 +165,7 @@ const deleteUserController = async (req, res) => { /* TODO: Delete Assistant Threads */ await deleteConvos(user.id); // delete user convos await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth - await deleteUserById(user.id); // delete user + await db.models.User.deleteUserById(user.id); // delete user await deleteAllSharedLinks(user.id); // delete user shared links await deleteUserFiles(req); // delete user files await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps diff --git a/api/server/controllers/auth/TwoFactorAuthController.js b/api/server/controllers/auth/TwoFactorAuthController.js index 15cde8122a..150124b0cc 100644 --- a/api/server/controllers/auth/TwoFactorAuthController.js +++ b/api/server/controllers/auth/TwoFactorAuthController.js @@ -5,8 +5,8 @@ const { getTOTPSecret, } = require('~/server/services/twoFactorService'); const { setAuthTokens } = require('~/server/services/AuthService'); -const { getUserById } = require('~/models/userMethods'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); /** * Verifies the 2FA code during login using a temporary token. @@ -25,7 +25,7 @@ const verify2FAWithTempToken = async (req, res) => { return res.status(401).json({ message: 'Invalid or expired temporary token' }); } - const user = await getUserById(payload.userId); + const user = await db.models.User.getUserById(payload.userId); if (!user || !user.twoFactorEnabled) { return res.status(400).json({ message: '2FA is not enabled for this user' }); } diff --git a/api/server/index.js b/api/server/index.js index c7525f9b91..f04a77c48d 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -10,7 +10,7 @@ const mongoSanitize = require('express-mongo-sanitize'); const fs = require('fs'); const cookieParser = require('cookie-parser'); const { jwtLogin, passportLogin } = require('~/strategies'); -const { connectDb, indexSync } = require('~/lib/db'); +const { connectDb, indexSync, getModels } = require('~/lib/db'); const { isEnabled } = require('~/server/utils'); const { ldapLogin } = require('~/strategies'); const { logger } = require('~/config'); @@ -36,6 +36,7 @@ const startServer = async () => { axios.defaults.headers.common['Accept-Encoding'] = 'gzip'; } await connectDb(); + logger.info('Connected to MongoDB'); await indexSync(); diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index 4e0593192a..cafd0c5f74 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -5,8 +5,9 @@ const { isEnabled, removePorts } = require('~/server/utils'); const keyvMongo = require('~/cache/keyvMongo'); const denyRequest = require('./denyRequest'); const { getLogStores } = require('~/cache'); -const { findUser } = require('~/models'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); + const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 }); const message = 'Your account has been temporarily banned due to violations of our service.'; @@ -57,7 +58,7 @@ const checkBan = async (req, res, next = () => {}) => { let userId = req.user?.id ?? req.user?._id ?? null; if (!userId && req?.body?.email) { - const user = await findUser({ email: req.body.email }, '_id'); + const user = await db.models.User.findUser({ email: req.body.email }, '_id'); userId = user?._id ? user._id.toString() : userId; } diff --git a/api/server/middleware/checkInviteUser.js b/api/server/middleware/checkInviteUser.js index e1ad271b55..17586d534d 100644 --- a/api/server/middleware/checkInviteUser.js +++ b/api/server/middleware/checkInviteUser.js @@ -1,5 +1,5 @@ const { getInvite } = require('~/models/inviteUser'); -const { deleteTokens } = require('~/models/Token'); +const db = require('~/lib/db/connectDb'); async function checkInviteUser(req, res, next) { const token = req.body.token; @@ -16,7 +16,7 @@ async function checkInviteUser(req, res, next) { return res.status(400).json({ message: 'Invalid invite token' }); } - await deleteTokens({ token: invite.token }); + await db.models.Token.deleteTokens({ token: invite.token }); req.invite = invite; next(); } catch (error) { diff --git a/api/server/middleware/setBalanceConfig.js b/api/server/middleware/setBalanceConfig.js index 98d3cf1145..b2874c4bef 100644 --- a/api/server/middleware/setBalanceConfig.js +++ b/api/server/middleware/setBalanceConfig.js @@ -1,6 +1,6 @@ const { getBalanceConfig } = require('~/server/services/Config'); -const Balance = require('~/models/Balance'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); /** * Middleware to synchronize user balance settings with current balance configuration. @@ -20,14 +20,14 @@ const setBalanceConfig = async (req, res, next) => { } const userId = req.user._id; - const userBalanceRecord = await Balance.findOne({ user: userId }).lean(); + const userBalanceRecord = await db.models.Balance.findOne({ user: userId }).lean(); const updateFields = buildUpdateFields(balanceConfig, userBalanceRecord); if (Object.keys(updateFields).length === 0) { return next(); } - await Balance.findOneAndUpdate( + await db.models.Balance.findOneAndUpdate( { user: userId }, { $set: updateFields }, { upsert: true, new: true }, diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index d5980ae55b..232550865d 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -13,8 +13,8 @@ const { requireJwtAuth, validateMessageReq } = require('~/server/middleware'); const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc'); const { getConvosQueried } = require('~/models/Conversation'); const { countTokens } = require('~/server/utils'); -const { Message } = require('~/models/Message'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); const router = express.Router(); router.use(requireJwtAuth); @@ -40,21 +40,25 @@ router.get('/', async (req, res) => { const sortOrder = sortDirection === 'asc' ? 1 : -1; if (conversationId && messageId) { - const message = await Message.findOne({ conversationId, messageId, user: user }).lean(); + const message = await db.models.Message.findOne({ + conversationId, + messageId, + user: user, + }).lean(); response = { messages: message ? [message] : [], nextCursor: null }; } else if (conversationId) { const filter = { conversationId, user: user }; if (cursor) { filter[sortField] = sortOrder === 1 ? { $gt: cursor } : { $lt: cursor }; } - const messages = await Message.find(filter) + const messages = await db.models.Message.find(filter) .sort({ [sortField]: sortOrder }) .limit(pageSize + 1) .lean(); const nextCursor = messages.length > pageSize ? messages.pop()[sortField] : null; response = { messages, nextCursor }; } else if (search) { - const searchResults = await Message.meiliSearch(search, undefined, true); + const searchResults = await db.models.Message.meiliSearch(search, undefined, true); const messages = searchResults.hits || []; diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index 740a77092a..9dfc9d8712 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -17,9 +17,9 @@ const { logger, getFlowStateManager, sendEvent } = require('~/config'); const { encryptV2, decryptV2 } = require('~/server/utils/crypto'); const { getActions, deleteActions } = require('~/models/Action'); const { deleteAssistant } = require('~/models/Assistant'); -const { findToken } = require('~/models/Token'); const { logAxiosError } = require('~/utils'); const { getLogStores } = require('~/cache'); +const db = require('~/lib/db/connectDb'); const JWT_SECRET = process.env.JWT_SECRET; const toolNameRegex = /^[a-zA-Z0-9_-]+$/; @@ -231,9 +231,10 @@ async function createActionTool({ }; const tokenPromises = []; - tokenPromises.push(findToken({ userId, type: 'oauth', identifier })); + const { Token } = db.models; + tokenPromises.push(Token.findToken({ userId, type: 'oauth', identifier })); tokenPromises.push( - findToken({ + Token.findToken({ userId, type: 'oauth_refresh', identifier: `${identifier}:refresh`, diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index ac13172128..6f30a8a095 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,28 +1,12 @@ const bcrypt = require('bcryptjs'); const { webcrypto } = require('node:crypto'); const { SystemRoles, errorsToString } = require('librechat-data-provider'); -const { - findUser, - countUsers, - createUser, - updateUser, - getUserById, - generateToken, - deleteUserById, -} = require('~/models/userMethods'); -const { - createToken, - findToken, - deleteTokens, - findSession, - deleteSession, - createSession, - generateRefreshToken, -} = require('~/models'); const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils'); const { isEmailDomainAllowed } = require('~/server/services/domains'); const { registerSchema } = require('~/strategies/validators'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); +const { getBalanceConfig } = require('~/server/services/Config'); const domains = { client: process.env.DOMAIN_CLIENT, @@ -40,13 +24,14 @@ const genericVerificationMessage = 'Please check your email to verify your email * @returns */ const logoutUser = async (req, refreshToken) => { + const { Session } = db.models; try { const userId = req.user._id; - const session = await findSession({ userId: userId, refreshToken }); + const session = await Session.findSession({ userId: userId, refreshToken }); if (session) { try { - await deleteSession({ sessionId: session._id }); + await Session.deleteSession({ sessionId: session._id }); } catch (deleteErr) { logger.error('[logoutUser] Failed to delete session.', deleteErr); return { status: 500, message: 'Failed to delete session.' }; @@ -98,7 +83,7 @@ const sendVerificationEmail = async (user) => { template: 'verifyEmail.handlebars', }); - await createToken({ + await db.models.Token.createToken({ userId: user._id, email: user.email, token: hash, @@ -117,7 +102,8 @@ const verifyEmail = async (req) => { const { email, token } = req.body; const decodedEmail = decodeURIComponent(email); - const user = await findUser({ email: decodedEmail }, 'email _id emailVerified'); + const { User, Token } = db.models; + const user = await User.findUser({ email: decodedEmail }, 'email _id emailVerified'); if (!user) { logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`); @@ -129,7 +115,7 @@ const verifyEmail = async (req) => { return { message: 'Email already verified', status: 'success' }; } - let emailVerificationData = await findToken({ email: decodedEmail }); + let emailVerificationData = await Token.findToken({ email: decodedEmail }); if (!emailVerificationData) { logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`); @@ -145,13 +131,13 @@ const verifyEmail = async (req) => { return new Error('Invalid or expired email verification token'); } - const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true }); + const updatedUser = await User.updateUser(emailVerificationData.userId, { emailVerified: true }); if (!updatedUser) { logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`); return new Error('Failed to update user verification status'); } - await deleteTokens({ token: emailVerificationData.token }); + await Token.deleteTokens({ token: emailVerificationData.token }); logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`); return { message: 'Email verification was successful', status: 'success' }; }; @@ -162,6 +148,7 @@ const verifyEmail = async (req) => { * @returns {Promise<{status: number, message: string, user?: MongoUser}>} */ const registerUser = async (user, additionalData = {}) => { + const { User } = db.models; const { error } = registerSchema.safeParse(user); if (error) { const errorMessage = errorsToString(error.errors); @@ -178,7 +165,7 @@ const registerUser = async (user, additionalData = {}) => { let newUserId; try { - const existingUser = await findUser({ email }, 'email _id'); + const existingUser = await User.findUser({ email }, 'email _id'); if (existingUser) { logger.info( @@ -200,7 +187,7 @@ const registerUser = async (user, additionalData = {}) => { } //determine if this is the first registered user (not counting anonymous_user) - const isFirstRegisteredUser = (await countUsers()) === 0; + const isFirstRegisteredUser = (await User.countUsers()) === 0; const salt = bcrypt.genSaltSync(10); const newUserData = { @@ -216,7 +203,9 @@ const registerUser = async (user, additionalData = {}) => { const emailEnabled = checkEmailConfig(); const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); - const newUser = await createUser(newUserData, disableTTL, true); + const balanceConfig = await getBalanceConfig(); + + const newUser = await User.createUser(newUserData, balanceConfig, disableTTL, true); newUserId = newUser._id; if (emailEnabled && !newUser.emailVerified) { await sendVerificationEmail({ @@ -225,14 +214,14 @@ const registerUser = async (user, additionalData = {}) => { name, }); } else { - await updateUser(newUserId, { emailVerified: true }); + await User.updateUser(newUserId, { emailVerified: true }); } return { status: 200, message: genericVerificationMessage }; } catch (err) { logger.error('[registerUser] Error in registering user:', err); if (newUserId) { - const result = await deleteUserById(newUserId); + const result = await User.deleteUserById(newUserId); logger.warn( `[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`, ); @@ -247,7 +236,8 @@ const registerUser = async (user, additionalData = {}) => { */ const requestPasswordReset = async (req) => { const { email } = req.body; - const user = await findUser({ email }, 'email _id'); + const { User, Token } = db.models; + const user = await User.findUser({ email }, 'email _id'); const emailEnabled = checkEmailConfig(); logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`); @@ -259,11 +249,11 @@ const requestPasswordReset = async (req) => { }; } - await deleteTokens({ userId: user._id }); + await Token.deleteTokens({ userId: user._id }); const [resetToken, hash] = createTokenHash(); - await createToken({ + await Token.createToken({ userId: user._id, token: hash, createdAt: Date.now(), @@ -308,7 +298,8 @@ const requestPasswordReset = async (req) => { * @returns */ const resetPassword = async (userId, token, password) => { - let passwordResetToken = await findToken({ + const { User, Token } = db.models; + let passwordResetToken = await Token.findToken({ userId, }); @@ -323,7 +314,7 @@ const resetPassword = async (userId, token, password) => { } const hash = bcrypt.hashSync(password, 10); - const user = await updateUser(userId, { password: hash }); + const user = await User.updateUser(userId, { password: hash }); if (checkEmailConfig()) { await sendEmail({ @@ -338,7 +329,7 @@ const resetPassword = async (userId, token, password) => { }); } - await deleteTokens({ token: passwordResetToken.token }); + await Token.deleteTokens({ token: passwordResetToken.token }); logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`); return { message: 'Password reset was successful' }; }; @@ -353,19 +344,20 @@ const resetPassword = async (userId, token, password) => { */ const setAuthTokens = async (userId, res, sessionId = null) => { try { - const user = await getUserById(userId); - const token = await generateToken(user); + const { User, Session } = db.models; + const user = await User.getUserById(userId); + const token = await User.generateToken(user); let session; let refreshToken; let refreshTokenExpires; if (sessionId) { - session = await findSession({ sessionId: sessionId }, { lean: false }); + session = await Session.findSession({ sessionId: sessionId }, { lean: false }); refreshTokenExpires = session.expiration.getTime(); - refreshToken = await generateRefreshToken(session); + refreshToken = await Session.generateRefreshToken(session); } else { - const result = await createSession(userId); + const result = await Session.createSession(userId); session = result.session; refreshToken = result.refreshToken; refreshTokenExpires = session.expiration.getTime(); @@ -444,8 +436,9 @@ const setOpenIDAuthTokens = (tokenset, res) => { const resendVerificationEmail = async (req) => { try { const { email } = req.body; - await deleteTokens(email); - const user = await findUser({ email }, 'email _id name'); + const { User, Token } = db.models; + await Token.deleteTokens(email); + const user = await User.findUser({ email }, 'email _id name'); if (!user) { logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`); @@ -470,7 +463,7 @@ const resendVerificationEmail = async (req) => { template: 'verifyEmail.handlebars', }); - await createToken({ + await Token.createToken({ userId: user._id, email: user.email, token: hash, diff --git a/api/server/services/Files/Azure/images.js b/api/server/services/Files/Azure/images.js index a83b700af3..a4d2d0932e 100644 --- a/api/server/services/Files/Azure/images.js +++ b/api/server/services/Files/Azure/images.js @@ -2,11 +2,10 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const { resizeImageBuffer } = require('../images/resize'); -const { updateUser } = require('~/models/userMethods'); const { updateFile } = require('~/models/File'); const { logger } = require('~/config'); const { saveBufferToAzure } = require('./crud'); - +const db = require('~/lib/db/connectDb'); /** * Uploads an image file to Azure Blob Storage. * It resizes and converts the image similar to your Firebase implementation. @@ -108,7 +107,7 @@ async function processAzureAvatar({ buffer, userId, manual, basePath = 'images', const isManual = manual === 'true'; const url = `${downloadURL}?manual=${isManual}`; if (isManual) { - await updateUser(userId, { avatar: url }); + await db.models?.User.updateUser(userId, { avatar: url }); } return url; } catch (error) { diff --git a/api/server/services/Files/Firebase/images.js b/api/server/services/Files/Firebase/images.js index 7345f30df1..57c5026683 100644 --- a/api/server/services/Files/Firebase/images.js +++ b/api/server/services/Files/Firebase/images.js @@ -2,10 +2,10 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const { resizeImageBuffer } = require('../images/resize'); -const { updateUser } = require('~/models/userMethods'); const { saveBufferToFirebase } = require('./crud'); const { updateFile } = require('~/models/File'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); /** * Converts an image file to the target format. The function first resizes the image based on the specified @@ -99,7 +99,7 @@ async function processFirebaseAvatar({ buffer, userId, manual }) { const url = `${downloadURL}?manual=${isManual}`; if (isManual) { - await updateUser(userId, { avatar: url }); + await db.models.User.updateUser(userId, { avatar: url }); } return url; diff --git a/api/server/services/Files/Local/images.js b/api/server/services/Files/Local/images.js index 1305505381..520075efe5 100644 --- a/api/server/services/Files/Local/images.js +++ b/api/server/services/Files/Local/images.js @@ -2,8 +2,8 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const { resizeImageBuffer } = require('../images/resize'); -const { updateUser } = require('~/models/userMethods'); const { updateFile } = require('~/models/File'); +const db = require('~/lib/db/connectDb'); /** * Converts an image file to the target format. The function first resizes the image based on the specified @@ -141,7 +141,7 @@ async function processLocalAvatar({ buffer, userId, manual }) { let url = `${urlRoute}?manual=${isManual}`; if (isManual) { - await updateUser(userId, { avatar: url }); + await db.models?.User.updateUser(userId, { avatar: url }); } return url; diff --git a/api/server/services/Files/S3/images.js b/api/server/services/Files/S3/images.js index 378212cb5e..aeaf984c44 100644 --- a/api/server/services/Files/S3/images.js +++ b/api/server/services/Files/S3/images.js @@ -2,10 +2,10 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const { resizeImageBuffer } = require('../images/resize'); -const { updateUser } = require('~/models/userMethods'); const { saveBufferToS3 } = require('./crud'); const { updateFile } = require('~/models/File'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); const defaultBasePath = 'images'; @@ -102,7 +102,7 @@ async function processS3Avatar({ buffer, userId, manual, basePath = defaultBaseP try { const downloadURL = await saveBufferToS3({ userId, buffer, fileName: 'avatar.png', basePath }); if (manual === 'true') { - await updateUser(userId, { avatar: downloadURL }); + await db.models?.User.updateUser(userId, { avatar: downloadURL }); } return downloadURL; } catch (error) { diff --git a/api/server/services/UserService.js b/api/server/services/UserService.js index 91d772477b..d8525ac368 100644 --- a/api/server/services/UserService.js +++ b/api/server/services/UserService.js @@ -1,8 +1,7 @@ const { ErrorTypes } = require('librechat-data-provider'); const { encrypt, decrypt } = require('~/server/utils'); -const { updateUser, Key } = require('~/models'); const { logger } = require('~/config'); - +const db = require('~/lib/db/connectDb'); /** * Updates the plugins for a user based on the action specified (install/uninstall). * @async @@ -17,10 +16,11 @@ const { logger } = require('~/config'); const updateUserPluginsService = async (user, pluginKey, action) => { try { const userPlugins = user.plugins || []; + const { User } = db.models; if (action === 'install') { - return await updateUser(user._id, { plugins: [...userPlugins, pluginKey] }); + return await User.updateUser(user._id, { plugins: [...userPlugins, pluginKey] }); } else if (action === 'uninstall') { - return await updateUser(user._id, { + return await User.updateUser(user._id, { plugins: userPlugins.filter((plugin) => plugin !== pluginKey), }); } @@ -42,7 +42,7 @@ const updateUserPluginsService = async (user, pluginKey, action) => { * an error indicating that there is no user key available. */ const getUserKey = async ({ userId, name }) => { - const keyValue = await Key.findOne({ userId, name }).lean(); + const keyValue = await db.models.Key.findOne({ userId, name }).lean(); if (!keyValue) { throw new Error( JSON.stringify({ @@ -89,7 +89,7 @@ const getUserKeyValues = async ({ userId, name }) => { * returns its expiry date. If the key is not found, it returns null for the expiry date. */ const getUserKeyExpiry = async ({ userId, name }) => { - const keyValue = await Key.findOne({ userId, name }).lean(); + const keyValue = await db.models.Key.findOne({ userId, name }).lean(); if (!keyValue) { return { expiresAt: null }; } @@ -123,7 +123,7 @@ const updateUserKey = async ({ userId, name, value, expiresAt = null }) => { // make sure to remove if already present updateQuery.$unset = { expiresAt }; } - return await Key.findOneAndUpdate({ userId, name }, updateQuery, { + return await db.models.Key.findOneAndUpdate({ userId, name }, updateQuery, { upsert: true, new: true, }).lean(); @@ -143,10 +143,10 @@ const updateUserKey = async ({ userId, name, value, expiresAt = null }) => { */ const deleteUserKey = async ({ userId, name, all = false }) => { if (all) { - return await Key.deleteMany({ userId }); + return await db.models.Key.deleteMany({ userId }); } - await Key.findOneAndDelete({ userId, name }).lean(); + await db.models.Key.findOneAndDelete({ userId, name }).lean(); }; /** diff --git a/api/server/services/twoFactorService.js b/api/server/services/twoFactorService.js index d000c8fcfc..68c335b390 100644 --- a/api/server/services/twoFactorService.js +++ b/api/server/services/twoFactorService.js @@ -1,6 +1,7 @@ const { webcrypto } = require('node:crypto'); const { decryptV3, decryptV2 } = require('../utils/crypto'); const { hashBackupCode } = require('~/server/utils/crypto'); +const db = require('~/lib/db/connectDb'); // Base32 alphabet for TOTP secret encoding. const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; @@ -172,8 +173,7 @@ const verifyBackupCode = async ({ user, backupCode }) => { : codeObj, ); // Update the user record with the marked backup code. - const { updateUser } = require('~/models'); - await updateUser(user._id, { backupCodes: updatedBackupCodes }); + await db.models.User.updateUser(user._id, { backupCodes: updatedBackupCodes }); return true; } return false; diff --git a/api/server/utils/crypto.js b/api/server/utils/crypto.js index 333cd7573a..43662c3ef5 100644 --- a/api/server/utils/crypto.js +++ b/api/server/utils/crypto.js @@ -3,8 +3,8 @@ const crypto = require('node:crypto'); const { webcrypto } = crypto; // Use hex decoding for both key and IV for legacy methods. -const key = Buffer.from(process.env.CREDS_KEY, 'hex'); -const iv = Buffer.from(process.env.CREDS_IV, 'hex'); +const key = Buffer.from('f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', 'hex'); +const iv = Buffer.from('e2341419ec3dd3d19b13a1a87fafcbfb', 'hex'); const algorithm = 'AES-CBC'; // --- Legacy v1/v2 Setup: AES-CBC with fixed key and IV --- diff --git a/api/server/utils/handleText.spec.js b/api/server/utils/handleText.spec.js index 8b1b6eef8d..d5488ad76f 100644 --- a/api/server/utils/handleText.spec.js +++ b/api/server/utils/handleText.spec.js @@ -1,3 +1,9 @@ +jest.mock('~/models/Message', () => ({ + Message: jest.fn(), +})); +jest.mock('~/models/Conversation', () => ({ + Conversation: jest.fn(), +})); const { isEnabled, sanitizeFilename } = require('./handleText'); describe('isEnabled', () => { diff --git a/api/strategies/appleStrategy.js b/api/strategies/appleStrategy.js index a45f10fc62..4dbac2e364 100644 --- a/api/strategies/appleStrategy.js +++ b/api/strategies/appleStrategy.js @@ -18,17 +18,13 @@ const getProfileDetails = ({ idToken, profile }) => { const decoded = jwt.decode(idToken); - logger.debug( - `Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`, - ); + logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`); return { email: decoded.email, id: decoded.sub, avatarUrl: null, // Apple does not provide an avatar URL - username: decoded.email - ? decoded.email.split('@')[0].toLowerCase() - : `user_${decoded.sub}`, + username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`, name: decoded.name ? `${decoded.name.firstName} ${decoded.name.lastName}` : profile.displayName || null, diff --git a/api/strategies/appleStrategy.test.js b/api/strategies/appleStrategy.test.js index c457e15fdc..d1083224e7 100644 --- a/api/strategies/appleStrategy.test.js +++ b/api/strategies/appleStrategy.test.js @@ -3,11 +3,10 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); const jwt = require('jsonwebtoken'); const { Strategy: AppleStrategy } = require('passport-apple'); const socialLogin = require('./socialLogin'); -const User = require('~/models/User'); const { logger } = require('~/config'); const { createSocialUser, handleExistingUser } = require('./process'); const { isEnabled } = require('~/server/utils'); -const { findUser } = require('~/models'); +const db = require('~/lib/db/connectDb'); // Mocking external dependencies jest.mock('jsonwebtoken'); @@ -24,21 +23,20 @@ jest.mock('./process', () => ({ jest.mock('~/server/utils', () => ({ isEnabled: jest.fn(), })); -jest.mock('~/models', () => ({ - findUser: jest.fn(), -})); describe('Apple Login Strategy', () => { let mongoServer; let appleStrategyInstance; const OLD_ENV = process.env; let getProfileDetails; - + let User; // Start and stop in-memory MongoDB beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); - await mongoose.connect(mongoUri); + await db.connectDb(mongoUri); + + User = db.models.User; }); afterAll(async () => { @@ -64,7 +62,6 @@ describe('Apple Login Strategy', () => { // Define getProfileDetails within the test scope getProfileDetails = ({ idToken, profile }) => { - console.log('getProfileDetails called with idToken:', idToken); if (!idToken) { logger.error('idToken is missing'); throw new Error('idToken is missing'); @@ -84,9 +81,7 @@ describe('Apple Login Strategy', () => { email: decoded.email, id: decoded.sub, avatarUrl: null, // Apple does not provide an avatar URL - username: decoded.email - ? decoded.email.split('@')[0].toLowerCase() - : `user_${decoded.sub}`, + username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`, name: decoded.name ? `${decoded.name.firstName} ${decoded.name.lastName}` : profile.displayName || null, @@ -96,8 +91,12 @@ describe('Apple Login Strategy', () => { // Mock isEnabled based on environment variable isEnabled.mockImplementation((flag) => { - if (flag === 'true') { return true; } - if (flag === 'false') { return false; } + if (flag === 'true') { + return true; + } + if (flag === 'false') { + return false; + } return false; }); @@ -154,9 +153,7 @@ describe('Apple Login Strategy', () => { }); expect(jwt.decode).toHaveBeenCalledWith('fake_id_token'); - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('Decoded Apple JWT'), - ); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Decoded Apple JWT')); expect(profileDetails).toEqual({ email: 'john.doe@example.com', id: 'apple-sub-1234', @@ -209,12 +206,13 @@ describe('Apple Login Strategy', () => { beforeEach(() => { jwt.decode.mockReturnValue(decodedToken); - findUser.mockImplementation(({ email }) => User.findOne({ email })); + User.findUser = jest.fn(); + User.findUser.mockImplementation(({ email }) => User.findOne({ email })); }); it('should create a new user if one does not exist and registration is allowed', async () => { // Mock findUser to return null (user does not exist) - findUser.mockResolvedValue(null); + User.findUser.mockResolvedValue(null); // Mock createSocialUser to create a user createSocialUser.mockImplementation(async (userData) => { @@ -260,7 +258,7 @@ describe('Apple Login Strategy', () => { await existingUser.save(); // Mock findUser to return the existing user - findUser.mockResolvedValue(existingUser); + User.findUser.mockResolvedValue(existingUser); // Mock handleExistingUser to update avatarUrl handleExistingUser.mockImplementation(async (user, avatarUrl) => { @@ -297,7 +295,7 @@ describe('Apple Login Strategy', () => { appleStrategyInstance._verify( fakeAccessToken, fakeRefreshToken, - null, // idToken is missing + null, // idToken is missing mockProfile, (err, user) => { mockVerifyCallback(err, user); @@ -344,7 +342,7 @@ describe('Apple Login Strategy', () => { it('should handle errors during user creation', async () => { // Mock findUser to return null (user does not exist) - findUser.mockResolvedValue(null); + User.findUser.mockResolvedValue(null); // Mock createSocialUser to throw an error createSocialUser.mockImplementation(() => { diff --git a/api/strategies/jwtStrategy.js b/api/strategies/jwtStrategy.js index eb4b34fd85..fd4d556970 100644 --- a/api/strategies/jwtStrategy.js +++ b/api/strategies/jwtStrategy.js @@ -1,7 +1,7 @@ const { SystemRoles } = require('librechat-data-provider'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); -const { getUserById, updateUser } = require('~/models'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); // JWT strategy const jwtLogin = () => @@ -12,12 +12,13 @@ const jwtLogin = () => }, async (payload, done) => { try { - const user = await getUserById(payload?.id, '-password -__v -totpSecret'); + const {User} = db.models; + const user = await User.getUserById(payload?.id, '-password -__v -totpSecret'); if (user) { user.id = user._id.toString(); if (!user.role) { user.role = SystemRoles.USER; - await updateUser(user.id, { role: user.role }); + await User.updateUser(user.id, { role: user.role }); } done(null, user); } else { diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js index beb9b8c2fd..7f688723e4 100644 --- a/api/strategies/ldapStrategy.js +++ b/api/strategies/ldapStrategy.js @@ -1,10 +1,10 @@ const fs = require('fs'); const LdapStrategy = require('passport-ldapauth'); const { SystemRoles } = require('librechat-data-provider'); -const { findUser, createUser, updateUser } = require('~/models/userMethods'); -const { countUsers } = require('~/models/userMethods'); const { isEnabled } = require('~/server/utils'); const logger = require('~/utils/logger'); +const db = require('~/lib/db/connectDb'); +const { getBalanceConfig } = require('~/server/services/Config'); const { LDAP_URL, @@ -81,6 +81,7 @@ const ldapOptions = { }; const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { + const { User } = db.models; if (!userinfo) { return done(null, false, { message: 'Invalid credentials' }); } @@ -89,7 +90,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { const ldapId = (LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail; - let user = await findUser({ ldapId }); + let user = await User.findUser({ ldapId }); const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(','); const fullName = @@ -114,7 +115,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { } if (!user) { - const isFirstRegisteredUser = (await countUsers()) === 0; + const isFirstRegisteredUser = (await User.countUsers()) === 0; user = { provider: 'ldap', ldapId, @@ -124,7 +125,9 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { name: fullName, role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, }; - const userId = await createUser(user); + const balanceConfig = await getBalanceConfig(); + + const userId = await User.createUser(user, balanceConfig); user._id = userId; } else { // Users registered in LDAP are assumed to have their user information managed in LDAP, @@ -136,7 +139,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => { user.name = fullName; } - user = await updateUser(user._id, user); + user = await User.updateUser(user._id, user); done(null, user); } catch (err) { logger.error('[ldapStrategy]', err); diff --git a/api/strategies/localStrategy.js b/api/strategies/localStrategy.js index bffb4f845f..814c66bf89 100644 --- a/api/strategies/localStrategy.js +++ b/api/strategies/localStrategy.js @@ -1,9 +1,10 @@ const { errorsToString } = require('librechat-data-provider'); const { Strategy: PassportLocalStrategy } = require('passport-local'); -const { findUser, comparePassword, updateUser } = require('~/models'); +const { comparePassword } = require('~/models'); const { isEnabled, checkEmailConfig } = require('~/server/utils'); const { loginSchema } = require('./validators'); const logger = require('~/utils/logger'); +const db = require('~/lib/db/connectDb'); // Unix timestamp for 2024-06-07 15:20:18 Eastern Time const verificationEnabledTimestamp = 1717788018; @@ -14,6 +15,7 @@ async function validateLoginRequest(req) { } async function passportLogin(req, email, password, done) { + const {User} = db.models; try { const validationError = await validateLoginRequest(req); if (validationError) { @@ -22,7 +24,7 @@ async function passportLogin(req, email, password, done) { return done(null, false, { message: validationError }); } - const user = await findUser({ email: email.trim() }); + const user = await User.findUser({ email: email.trim() }); if (!user) { logError('Passport Local Strategy - User Not Found', { email }); logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`); @@ -44,13 +46,13 @@ async function passportLogin(req, email, password, done) { !user.emailVerified && userCreatedAtTimestamp < verificationEnabledTimestamp ) { - await updateUser(user._id, { emailVerified: true }); + await User.updateUser(user._id, { emailVerified: true }); user.emailVerified = true; } const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); if (user.expiresAt && unverifiedAllowed) { - await updateUser(user._id, {}); + await User.updateUser(user._id, {}); } if (!user.emailVerified && !unverifiedAllowed) { diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index ea109358d7..f3852e036b 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -6,8 +6,6 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const client = require('openid-client'); const { Strategy: OpenIDStrategy } = require('openid-client/passport'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); -const { findUser, createUser, updateUser } = require('~/models/userMethods'); -const { hashToken } = require('~/server/utils/crypto'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); const getLogStores = require('~/cache/getLogStores'); @@ -37,6 +35,24 @@ class CustomOpenIDStrategy extends OpenIDStrategy { } } +const db = require('~/lib/db/connectDb'); +const { getBalanceConfig } = require('~/server/services/Config'); + +let crypto; +let webcrypto; +try { + crypto = require('node:crypto'); + webcrypto = crypto; +} catch (err) { + logger.error('[openidStrategy] crypto support is disabled!', err); +} + +async function hashToken(str) { + const data = new TextEncoder().encode(str); + const hashBuffer = await webcrypto.subtle.digest('SHA-256', data); + return Buffer.from(hashBuffer).toString('hex'); +} + /** * Exchange the access token for a new access token using the on-behalf-of flow if required. * @param {Configuration} config @@ -196,6 +212,7 @@ function convertToUsername(input, defaultValue = '') { * @throws {Error} If an error occurs during the setup process. */ async function setupOpenId() { + const { User } = db.models; try { /** @type {ClientMetadata} */ const clientMetadata = { @@ -230,13 +247,13 @@ async function setupOpenId() { async (tokenset, done) => { try { const claims = tokenset.claims(); - let user = await findUser({ openidId: claims.sub }); + let user = await User.findUser({ openidId: claims.sub }); logger.info( `[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`, ); if (!user) { - user = await findUser({ email: claims.email }); + user = await User.findUser({ email: claims.email }); logger.info( `[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${ claims.email @@ -297,7 +314,10 @@ async function setupOpenId() { emailVerified: userinfo.email_verified || false, name: fullName, }; - user = await createUser(user, true, true); + + const balanceConfig = await getBalanceConfig(); + + user = await User.createUser(user, balanceConfig, true, true); } else { user.provider = 'openid'; user.openidId = userinfo.sub; @@ -333,7 +353,7 @@ async function setupOpenId() { } } - user = await updateUser(user._id, user); + user = await User.updateUser(user._id, user); logger.info( `[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index e70dfa5529..68e7aff49a 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -1,9 +1,48 @@ const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); -const { findUser, createUser, updateUser } = require('~/models/userMethods'); const { setupOpenId } = require('./openidStrategy'); +const { getBalanceConfig } = require('~/server/services/Config'); // --- Mocks --- +const mockCreateUser = jest.fn(); +const mockFindUser = jest.fn(); +const mockUpdateUser = jest.fn(); +let User; + +jest.mock('@librechat/data-schemas', () => { + return { + registerModels: jest.fn().mockReturnValue({ + User: { + createUser: mockCreateUser, + findUser: mockFindUser, + updateUser: mockUpdateUser, + }, + }), + }; +}); + +const mockModels = { + User: { + createUser: mockCreateUser, + findUser: mockFindUser, + updateUser: mockUpdateUser, + }, +}; + +jest.mock('~/lib/db/connectDb', () => { + return { + getModels: jest.fn(() => mockModels), + connectDb: jest.fn(), + get models() { + return mockModels; + }, + }; +}); + +jest.mock('~/server/services/Config', () => ({ + getBalanceConfig: jest.fn(), +})); + jest.mock('node-fetch'); jest.mock('jsonwebtoken/decode'); jest.mock('~/server/services/Files/strategies', () => ({ @@ -11,11 +50,7 @@ jest.mock('~/server/services/Files/strategies', () => ({ saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), })), })); -jest.mock('~/models/userMethods', () => ({ - findUser: jest.fn(), - createUser: jest.fn(), - updateUser: jest.fn(), -})); + jest.mock('~/server/utils/crypto', () => ({ hashToken: jest.fn().mockResolvedValue('hashed-token'), })); @@ -109,7 +144,8 @@ describe('setupOpenId', () => { picture: 'https://example.com/avatar.png', }), }; - + const { registerModels } = require('@librechat/data-schemas'); + User = registerModels().User; beforeEach(async () => { // Clear previous mock calls and reset implementations jest.clearAllMocks(); @@ -134,13 +170,16 @@ describe('setupOpenId', () => { roles: ['requiredRole'], }); + User.findUser = jest.fn(); // By default, assume that no user is found, so createUser will be called - findUser.mockResolvedValue(null); - createUser.mockImplementation(async (userData) => { + + const balance = await getBalanceConfig.mockResolvedValue({ enabled: false }); + User.createUser.mockImplementation(async (userData, balance) => { // simulate created user with an _id property return { _id: 'newUserId', ...userData }; }); - updateUser.mockImplementation(async (id, userData) => { + // User.updateUser = jest.fn(); + User.updateUser.mockImplementation(async (id, userData) => { return { _id: id, ...userData }; }); @@ -166,7 +205,7 @@ describe('setupOpenId', () => { // Assert expect(user.username).toBe(userinfo.username); - expect(createUser).toHaveBeenCalledWith( + expect(User.createUser).toHaveBeenCalledWith( expect.objectContaining({ provider: 'openid', openidId: userinfo.sub, @@ -174,6 +213,7 @@ describe('setupOpenId', () => { email: userinfo.email, name: `${userinfo.given_name} ${userinfo.family_name}`, }), + { enabled: false }, true, true, ); @@ -191,8 +231,9 @@ describe('setupOpenId', () => { // Assert expect(user.username).toBe(expectUsername); - expect(createUser).toHaveBeenCalledWith( + expect(User.createUser).toHaveBeenCalledWith( expect.objectContaining({ username: expectUsername }), + { enabled: false }, true, true, ); @@ -210,8 +251,9 @@ describe('setupOpenId', () => { // Assert expect(user.username).toBe(expectUsername); - expect(createUser).toHaveBeenCalledWith( + expect(User.createUser).toHaveBeenCalledWith( expect.objectContaining({ username: expectUsername }), + { enabled: false }, true, true, ); @@ -227,8 +269,9 @@ describe('setupOpenId', () => { // Assert – username should equal the sub (converted as-is) expect(user.username).toBe(userinfo.sub); - expect(createUser).toHaveBeenCalledWith( + expect(User.createUser).toHaveBeenCalledWith( expect.objectContaining({ username: userinfo.sub }), + { enabled: false }, true, true, ); @@ -268,11 +311,8 @@ describe('setupOpenId', () => { username: '', name: '', }; - findUser.mockImplementation(async (query) => { - if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { - return existingUser; - } - return null; + mockFindUser.mockImplementation(async (query) => { + return existingUser; }); const userinfo = tokenset.claims(); @@ -281,7 +321,7 @@ describe('setupOpenId', () => { await validate(tokenset); // Assert – updateUser should be called and the user object updated - expect(updateUser).toHaveBeenCalledWith( + expect(User.updateUser).toHaveBeenCalledWith( existingUser._id, expect.objectContaining({ provider: 'openid', diff --git a/api/strategies/process.js b/api/strategies/process.js index e9a908ffd0..a7b7699682 100644 --- a/api/strategies/process.js +++ b/api/strategies/process.js @@ -1,7 +1,8 @@ const { FileSources } = require('librechat-data-provider'); -const { createUser, updateUser, getUserById } = require('~/models/userMethods'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); +const db = require('~/lib/db/connectDb'); +const { getBalanceConfig } = require('~/server/services/Config'); /** * Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter @@ -34,7 +35,7 @@ const handleExistingUser = async (oldUser, avatarUrl) => { } if (updatedAvatar) { - await updateUser(oldUser._id, { avatar: updatedAvatar }); + await db.models.User.updateUser(oldUser._id, { avatar: updatedAvatar }); } }; @@ -78,7 +79,8 @@ const createSocialUser = async ({ emailVerified, }; - const newUserId = await createUser(update); + const balanceConfig = await getBalanceConfig(); + const newUserId = await db.models.User.createUser(update, balanceConfig); const fileStrategy = process.env.CDN_PROVIDER; const isLocal = fileStrategy === FileSources.local; @@ -89,10 +91,10 @@ const createSocialUser = async ({ }); const { processAvatar } = getStrategyFunctions(fileStrategy); const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId }); - await updateUser(newUserId, { avatar }); + await User.updateUser(newUserId, { avatar }); } - return await getUserById(newUserId); + return await User.getUserById(newUserId); }; module.exports = { diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index 925c2de34d..ffe0a0b82c 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -1,7 +1,7 @@ const { createSocialUser, handleExistingUser } = require('./process'); const { isEnabled } = require('~/server/utils'); -const { findUser } = require('~/models'); const { logger } = require('~/config'); +const db = require('~/lib/db/connectDb'); const socialLogin = (provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => { @@ -11,7 +11,7 @@ const socialLogin = profile, }); - const oldUser = await findUser({ email: email.trim() }); + const oldUser = await db.models.User.findUser({ email: email.trim() }); const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION); if (oldUser) { diff --git a/config/add-balance.js b/config/add-balance.js index fcd506382d..d775f0c12d 100644 --- a/config/add-balance.js +++ b/config/add-balance.js @@ -2,8 +2,8 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); const { isEnabled } = require('~/server/utils/handleText'); -const { Transaction } = require('~/models/Transaction'); -const User = require('~/models/User'); +const { createTransaction } = require('~/models/Transaction'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); (async () => { @@ -65,7 +65,7 @@ const connect = require('./connect'); } // Validate the user - const user = await User.findOne({ email }).lean(); + const user = await db.models.User.findOne({ email }).lean(); if (!user) { console.red('Error: No user with that email was found!'); silentExit(1); @@ -78,7 +78,7 @@ const connect = require('./connect'); */ let result; try { - result = await Transaction.create({ + result = await createTransaction({ user: user._id, tokenType: 'credits', context: 'admin', diff --git a/config/ban-user.js b/config/ban-user.js index 89dc70c30a..711dc9a25d 100644 --- a/config/ban-user.js +++ b/config/ban-user.js @@ -2,7 +2,7 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); const banViolation = require('~/cache/banViolation'); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); (async () => { @@ -44,7 +44,7 @@ const connect = require('./connect'); silentExit(1); } - const user = await User.findOne({ email }).lean(); + const user = await db.models.User.findOne({ email }).lean(); if (!user) { console.red('Error: No user with that email was found!'); silentExit(1); diff --git a/config/connect.js b/config/connect.js index 00c5be8579..893523d03a 100644 --- a/config/connect.js +++ b/config/connect.js @@ -5,7 +5,7 @@ const moduleAlias = require('module-alias'); const basePath = path.resolve(__dirname, '..', 'api'); moduleAlias.addAlias('~', basePath); -const connectDb = require('~/lib/db/connectDb'); +const {connectDb }= require('~/lib/db/connectDb'); require('./helpers'); async function connect() { diff --git a/config/create-user.js b/config/create-user.js index 8c5429b538..cd00455b15 100644 --- a/config/create-user.js +++ b/config/create-user.js @@ -2,7 +2,7 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { registerUser } = require('~/server/services/AuthService'); const { askQuestion, silentExit } = require('./helpers'); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); (async () => { @@ -92,7 +92,7 @@ or the user will need to attempt logging in to have a verification link sent to } } - const userExists = await User.findOne({ $or: [{ email }, { username }] }); + const userExists = await db.models.User.findOne({ $or: [{ email }, { username }] }); if (userExists) { console.red('Error: A user with that email or username already exists!'); silentExit(1); diff --git a/config/delete-user.js b/config/delete-user.js index 220cd2d18e..8044c74090 100644 --- a/config/delete-user.js +++ b/config/delete-user.js @@ -1,7 +1,7 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); (async () => { @@ -20,10 +20,10 @@ const connect = require('./connect'); } else { email = await askQuestion('Email:'); } - let user = await User.findOne({ email: email }); + let user = await db.models.User.findOne({ email: email }); if (user !== null) { if ((await askQuestion(`Delete user ${user}?`)) === 'y') { - user = await User.findOneAndDelete({ _id: user._id }); + user = await db.models.User.findOneAndDelete({ _id: user._id }); if (user !== null) { console.yellow(`Deleted user ${user}`); } else { diff --git a/config/invite-user.js b/config/invite-user.js index 79925ec054..ad92cdefec 100644 --- a/config/invite-user.js +++ b/config/invite-user.js @@ -3,7 +3,7 @@ require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { sendEmail, checkEmailConfig } = require('~/server/utils'); const { askQuestion, silentExit } = require('./helpers'); const { createInvite } = require('~/models/inviteUser'); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); (async () => { @@ -40,7 +40,7 @@ const connect = require('./connect'); } // Check if the user already exists - const userExists = await User.findOne({ email }); + const userExists = await db.models.User.findOne({ email }); if (userExists) { console.red('Error: A user with that email already exists!'); silentExit(1); diff --git a/config/list-balances.js b/config/list-balances.js index 0878d19a57..1b446d4e4e 100644 --- a/config/list-balances.js +++ b/config/list-balances.js @@ -2,7 +2,7 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { silentExit } = require('./helpers'); const Balance = require('~/models/Balance'); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); (async () => { @@ -15,7 +15,7 @@ const connect = require('./connect'); console.purple('Show the balance of all users'); console.purple('-----------------------------'); - let users = await User.find({}); + let users = await db.models.User.find({}); for (const user of users) { let balance = await Balance.findOne({ user: user._id }); if (balance !== null) { diff --git a/config/list-users.js b/config/list-users.js index bda6aff391..f97b20629f 100644 --- a/config/list-users.js +++ b/config/list-users.js @@ -1,12 +1,12 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const User = require('../api/models/User'); const connect = require('./connect'); +const db = require('~/lib/db/connectDb'); const listUsers = async () => { try { await connect(); - const users = await User.find({}, 'email provider avatar username name createdAt'); + const users = await db.models.User.find({}, 'email provider avatar username name createdAt'); console.log('\nUser List:'); console.log('----------------------------------------'); diff --git a/config/reset-terms.js b/config/reset-terms.js index 5dd6210818..69c3e50802 100644 --- a/config/reset-terms.js +++ b/config/reset-terms.js @@ -1,6 +1,6 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); const { askQuestion, silentExit } = require('./helpers'); @@ -20,7 +20,7 @@ const { askQuestion, silentExit } = require('./helpers'); } try { - const result = await User.updateMany({}, { $set: { termsAccepted: false } }); + const result = await db.models.User.updateMany({}, { $set: { termsAccepted: false } }); console.green(`Updated ${result.modifiedCount} user(s).`); } catch (error) { console.red('Error resetting terms acceptance:', error); diff --git a/config/set-balance.js b/config/set-balance.js index 6fb3b8ca95..4fb7c74723 100644 --- a/config/set-balance.js +++ b/config/set-balance.js @@ -2,9 +2,8 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); const { isEnabled } = require('~/server/utils/handleText'); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); -const Balance = require('~/models/Balance'); (async () => { await connect(); @@ -57,7 +56,7 @@ const Balance = require('~/models/Balance'); } // Validate the user - const user = await User.findOne({ email }).lean(); + const user = await db.models.User.findOne({ email }).lean(); if (!user) { console.red('Error: No user with that email was found!'); silentExit(1); @@ -65,7 +64,7 @@ const Balance = require('~/models/Balance'); console.purple(`Found user: ${user.email}`); } - let balance = await Balance.findOne({ user: user._id }).lean(); + let balance = await db.models.Balance.findOne({ user: user._id }).lean(); if (!balance) { console.purple('User has no balance!'); } else { @@ -86,7 +85,7 @@ const Balance = require('~/models/Balance'); */ let result; try { - result = await Balance.findOneAndUpdate( + result = await db.models.Balance.findOneAndUpdate( { user: user._id }, { tokenCredits: amount }, { upsert: true, new: true }, diff --git a/config/update-banner.js b/config/update-banner.js index 2f2d65bc2d..6e3a254ef0 100644 --- a/config/update-banner.js +++ b/config/update-banner.js @@ -2,8 +2,8 @@ const path = require('path'); const { v5: uuidv5 } = require('uuid'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, askMultiLineQuestion, silentExit } = require('./helpers'); -const { Banner } = require('~/models/Banner'); const connect = require('./connect'); +const db = require('~/lib/db/connectDb'); (async () => { await connect(); @@ -87,6 +87,7 @@ const connect = require('./connect'); let result; try { + const { Banner } = db.models; // There is always only one Banner record in the DB. // If a Banner exists in the DB, it will be updated. // If it doesn't exist, a new one will be added. diff --git a/config/user-stats.js b/config/user-stats.js index 629469bdd6..f37b1dabe9 100644 --- a/config/user-stats.js +++ b/config/user-stats.js @@ -1,9 +1,7 @@ const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { silentExit } = require('./helpers'); -const Conversation = require('~/models/schema/convoSchema'); -const Message = require('~/models/schema/messageSchema'); -const User = require('~/models/User'); +const db = require('~/lib/db/connectDb'); const connect = require('./connect'); (async () => { @@ -16,6 +14,7 @@ const connect = require('./connect'); console.purple('Show the stats of all users'); console.purple('-----------------------------'); + const { User, Conversation, Message } = db.models; let users = await User.find({}); let userData = []; for (const user of users) { diff --git a/e2e/setup/cleanupUser.ts b/e2e/setup/cleanupUser.ts index fda3b45e97..5f9b82676d 100644 --- a/e2e/setup/cleanupUser.ts +++ b/e2e/setup/cleanupUser.ts @@ -1,11 +1,5 @@ -import connectDb from '@librechat/backend/lib/db/connectDb'; -import { - deleteMessages, - deleteConvos, - User, - deleteAllUserSessions, - Balance, -} from '@librechat/backend/models'; +import { connectDb, getModels } from '@librechat/backend/lib/db/connectDb'; +import { deleteMessages, deleteConvos, User, Balance } from '@librechat/backend/models'; import { Transaction } from '@librechat/backend/models/Transaction'; type TUser = { email: string; password: string }; @@ -33,7 +27,8 @@ export default async function cleanupUser(user: TUser) { } // TODO: fix this to delete all user sessions with the user's email - await deleteAllUserSessions(user); + const { User, Session } = getModels(); + await Session.deleteAllUserSessions(user); await User.deleteMany({ _id: user }); await Balance.deleteMany({ user }); diff --git a/package-lock.json b/package-lock.json index cec3766730..33cddc8580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "librechat-data-provider": "*", "librechat-mcp": "*", "lodash": "^4.17.21", - "meilisearch": "^0.38.0", + "meilisearch": "^0.50.0", "memorystore": "^1.6.7", "mime": "^3.0.0", "module-alias": "^2.2.3", @@ -2123,14 +2123,6 @@ "undici-types": "~5.26.4" } }, - "api/node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "api/node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -2140,14 +2132,6 @@ "node": ">= 14" } }, - "api/node_modules/bson": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", - "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", - "engines": { - "node": ">=16.20.1" - } - }, "api/node_modules/cookie-parser": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", @@ -25022,6 +25006,12 @@ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==" }, + "node_modules/@types/traverse": { + "version": "0.6.37", + "resolved": "https://registry.npmjs.org/@types/traverse/-/traverse-0.6.37.tgz", + "integrity": "sha512-c90MVeDiUI1FhOZ6rLQ3kDWr50YE8+paDpM+5zbHjbmsqEp2DlMYkqnZnwbK9oI+NvDe8yRajup4jFwnVX6xsA==", + "dev": true + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -25048,6 +25038,14 @@ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/winston": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/winston/-/winston-2.4.4.tgz", @@ -25774,7 +25772,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -25922,7 +25919,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", @@ -26008,7 +26004,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -26078,7 +26073,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "dependencies": { "possible-typed-array-names": "^1.0.0" }, @@ -26632,6 +26626,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", + "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -26737,7 +26739,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -27945,7 +27946,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -27963,7 +27963,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -27981,7 +27980,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -28150,7 +28148,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -28179,7 +28176,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -28703,7 +28699,6 @@ "version": "1.23.9", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", - "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -28858,7 +28853,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -28883,7 +28877,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7", @@ -30410,7 +30403,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, "dependencies": { "is-callable": "^1.1.3" } @@ -30600,7 +30592,6 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -30621,7 +30612,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -30806,7 +30796,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -30908,7 +30897,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -31131,7 +31119,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -31148,7 +31135,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -31160,7 +31146,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" @@ -31188,7 +31173,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -31943,7 +31927,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -32061,7 +32044,6 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -32085,7 +32067,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, "license": "MIT", "dependencies": { "async-function": "^1.0.0", @@ -32105,7 +32086,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" @@ -32132,7 +32112,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -32174,7 +32153,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -32201,7 +32179,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -32219,7 +32196,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -32268,7 +32244,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -32306,7 +32281,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -32359,7 +32333,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -32402,7 +32375,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -32468,7 +32440,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -32496,7 +32467,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -32509,7 +32479,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -32536,7 +32505,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -32553,7 +32521,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -32571,7 +32538,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -32587,7 +32553,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -32600,7 +32565,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -32616,7 +32580,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -35595,12 +35558,10 @@ } }, "node_modules/meilisearch": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.38.0.tgz", - "integrity": "sha512-bHaq8nYxSKw9/Qslq1Zes5g9tHgFkxy/I9o8942wv2PqlNOT0CzptIkh/x98N52GikoSZOXSQkgt6oMjtf5uZw==", - "dependencies": { - "cross-fetch": "^3.1.6" - } + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.50.0.tgz", + "integrity": "sha512-9IzIkobvnuS18Eg4dq/eJB9W+eXqeLZjNRgq/kKMswSmVYYSQsXqGgSuCA0JkF+o5RwJlwIsieQee6rh313VhA==", + "license": "MIT" }, "node_modules/memory-pager": { "version": "1.5.0", @@ -36395,6 +36356,38 @@ "node": "*" } }, + "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==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", @@ -36915,7 +36908,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -36924,7 +36916,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -37167,7 +37158,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", @@ -37807,7 +37797,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -40062,7 +40051,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -40119,7 +40107,6 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -41193,7 +41180,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -41213,7 +41199,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/safe-buffer": { @@ -41239,7 +41224,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -41256,14 +41240,12 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -41427,7 +41409,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -41444,7 +41425,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -41460,7 +41440,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -42130,7 +42109,6 @@ "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -42152,7 +42130,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -42171,7 +42148,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -43172,7 +43148,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -43187,7 +43162,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -43207,7 +43181,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -43229,7 +43202,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -43251,6 +43223,27 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, + "node_modules/typedarray.prototype.slice": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.5.tgz", + "integrity": "sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "math-intrinsics": "^1.1.0", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-offset": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", @@ -43341,7 +43334,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -44435,7 +44427,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", @@ -44455,7 +44446,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -44483,14 +44473,12 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/which-collection": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, "license": "MIT", "dependencies": { "is-map": "^2.0.3", @@ -44509,7 +44497,6 @@ "version": "1.1.18", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -45431,6 +45418,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/auth": { + "name": "@librechat/auth", + "version": "0.0.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^7.0.6", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.12.1", + "openid-client": "^6.5.0", + "passport": "^0.7.0", + "passport-facebook": "^3.0.0" + }, + "devDependencies": { + "@librechat/data-schemas": "^0.0.7", + "@rollup/plugin-alias": "^5.1.0", + "@rollup/plugin-commonjs": "^25.0.2", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.1.0", + "@rollup/plugin-replace": "^5.0.5", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.2", + "@types/diff": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.0", + "jest": "^29.5.0", + "jest-junit": "^16.0.0", + "rimraf": "^5.0.1", + "rollup": "^4.22.4", + "rollup-plugin-generate-package-json": "^3.2.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-typescript2": "^0.35.0", + "ts-node": "^10.9.2", + "typescript": "^5.0.4" + }, + "peerDependencies": { + "keyv": "^5.3.2" + } + }, "packages/data-provider": { "name": "librechat-data-provider", "version": "0.7.86", @@ -45576,7 +45603,14 @@ "version": "0.0.7", "license": "MIT", "dependencies": { - "mongoose": "^8.12.1" + "jsonwebtoken": "^9.0.2", + "klona": "^2.0.6", + "lodash": "^4.17.21", + "meilisearch": "^0.50.0", + "mongoose": "^8.12.1", + "traverse": "^0.6.11", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@rollup/plugin-alias": "^5.1.0", @@ -45590,6 +45624,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.0", + "@types/traverse": "^0.6.37", "jest": "^29.5.0", "jest-junit": "^16.0.0", "rimraf": "^5.0.1", @@ -45604,14 +45639,6 @@ "keyv": "^5.3.2" } }, - "packages/data-schemas/node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "packages/data-schemas/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -45622,14 +45649,6 @@ "balanced-match": "^1.0.0" } }, - "packages/data-schemas/node_modules/bson": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", - "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", - "engines": { - "node": ">=16.20.1" - } - }, "packages/data-schemas/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -45667,6 +45686,22 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "packages/data-schemas/node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "packages/data-schemas/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -45728,15 +45763,6 @@ } } }, - "packages/data-schemas/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==", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^14.1.0 || ^13.0.0" - } - }, "packages/data-schemas/node_modules/mongoose": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz", @@ -45758,6 +45784,27 @@ "url": "https://opencollective.com/mongoose" } }, + "packages/data-schemas/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "packages/data-schemas/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "packages/data-schemas/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -45774,27 +45821,71 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/data-schemas/node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "packages/data-schemas/node_modules/traverse": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.11.tgz", + "integrity": "sha512-vxXDZg8/+p3gblxB6BhhG5yWVn1kGRlaL8O78UDXc3wRnPizB5g83dcvWV1jpDMIPnjZjOFuxlMmE82XJ4407w==", "dependencies": { - "punycode": "^2.3.1" + "gopd": "^1.2.0", + "typedarray.prototype.slice": "^1.0.5", + "which-typed-array": "^1.1.18" }, "engines": { - "node": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "packages/data-schemas/node_modules/whatwg-url": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", - "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", + "packages/data-schemas/node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "dependencies": { - "tr46": "^5.0.0", - "webidl-conversions": "^7.0.0" + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" }, "engines": { - "node": ">=18" + "node": ">= 12.0.0" + } + }, + "packages/data-schemas/node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "packages/data-schemas/node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" } }, "packages/mcp": { diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 1a0f3fa152..999a3814c4 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -48,6 +48,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.0", + "@types/traverse": "^0.6.37", "jest": "^29.5.0", "jest-junit": "^16.0.0", "rimraf": "^5.0.1", @@ -59,7 +60,14 @@ "typescript": "^5.0.4" }, "dependencies": { - "mongoose": "^8.12.1" + "jsonwebtoken": "^9.0.2", + "klona": "^2.0.6", + "lodash": "^4.17.21", + "meilisearch": "^0.50.0", + "mongoose": "^8.12.1", + "traverse": "^0.6.11", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" }, "peerDependencies": { "keyv": "^5.3.2" diff --git a/packages/data-schemas/src/config/parsers.ts b/packages/data-schemas/src/config/parsers.ts new file mode 100644 index 0000000000..95a1cf8d67 --- /dev/null +++ b/packages/data-schemas/src/config/parsers.ts @@ -0,0 +1,228 @@ +import { klona } from 'klona'; +import winston from 'winston'; +import traverse from 'traverse'; + +const SPLAT_SYMBOL = Symbol.for('splat'); +const MESSAGE_SYMBOL = Symbol.for('message'); +const CONSOLE_JSON_STRING_LENGTH: number = + parseInt(process.env.CONSOLE_JSON_STRING_LENGTH || '', 10) || 255; + +const sensitiveKeys: RegExp[] = [ + /^(sk-)[^\s]+/, // OpenAI API key pattern + /(Bearer )[^\s]+/, // Header: Bearer token pattern + /(api-key:? )[^\s]+/, // Header: API key pattern + /(key=)[^\s]+/, // URL query param: sensitive key pattern (Google) +]; + +/** + * Determines if a given value string is sensitive and returns matching regex patterns. + * + * @param valueStr - The value string to check. + * @returns An array of regex patterns that match the value string. + */ +function getMatchingSensitivePatterns(valueStr: string): RegExp[] { + if (valueStr) { + // Filter and return all regex patterns that match the value string + return sensitiveKeys.filter((regex) => regex.test(valueStr)); + } + return []; +} + +/** + * Redacts sensitive information from a console message and trims it to a specified length if provided. + * @param str - The console message to be redacted. + * @param trimLength - The optional length at which to trim the redacted message. + * @returns The redacted and optionally trimmed console message. + */ +function redactMessage(str: string, trimLength?: number): string { + if (!str) { + return ''; + } + + const patterns = getMatchingSensitivePatterns(str); + patterns.forEach((pattern) => { + str = str.replace(pattern, '$1[REDACTED]'); + }); + + if (trimLength !== undefined && str.length > trimLength) { + return `${str.substring(0, trimLength)}...`; + } + + return str; +} + +/** + * Redacts sensitive information from log messages if the log level is 'error'. + * Note: Intentionally mutates the object. + * @param info - The log information object. + * @returns The modified log information object. + */ +const redactFormat = winston.format((info: winston.Logform.TransformableInfo) => { + if (info.level === 'error') { + info.message = redactMessage(info.message); + if (info[MESSAGE_SYMBOL]) { + info[MESSAGE_SYMBOL] = redactMessage(info[MESSAGE_SYMBOL]); + } + } + return info; +}); + +/** + * Truncates long strings, especially base64 image data, within log messages. + * + * @param value - The value to be inspected and potentially truncated. + * @param length - The length at which to truncate the value. Default: 100. + * @returns The truncated or original value. + */ +const truncateLongStrings = (value: any, length = 100): any => { + if (typeof value === 'string') { + return value.length > length ? value.substring(0, length) + '... [truncated]' : value; + } + + return value; +}; + +/** + * An array mapping function that truncates long strings (objects converted to JSON strings). + * @param item - The item to be condensed. + * @returns The condensed item. + */ +const condenseArray = (item: any): any => { + if (typeof item === 'string') { + return truncateLongStrings(JSON.stringify(item)); + } else if (typeof item === 'object') { + return truncateLongStrings(JSON.stringify(item)); + } + return item; +}; + +/** + * Formats log messages for debugging purposes. + * - Truncates long strings within log messages. + * - Condenses arrays by truncating long strings and objects as strings within array items. + * - Redacts sensitive information from log messages if the log level is 'error'. + * - Converts log information object to a formatted string. + * + * @param options - The options for formatting log messages. + * @returns The formatted log message. + */ +const debugTraverse = winston.format.printf( + ({ level, message, timestamp, ...metadata }: Record) => { + if (!message) { + return `${timestamp} ${level}`; + } + + if (!message?.trim || typeof message !== 'string') { + return `${timestamp} ${level}: ${JSON.stringify(message)}`; + } + + let msg = `${timestamp} ${level}: ${truncateLongStrings(message.trim(), 150)}`; + + try { + if (level !== 'debug') { + return msg; + } + + if (!metadata) { + return msg; + } + + const debugValue = metadata[SPLAT_SYMBOL]?.[0]; + + if (!debugValue) { + return msg; + } + + if (Array.isArray(debugValue)) { + msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`; + return msg; + } + + if (typeof debugValue !== 'object') { + return `${msg} ${debugValue}`; + } + + msg += '\n{'; + + const copy = klona(metadata); + + traverse(copy).forEach(function (this: any, value: any) { + if (typeof this?.key === 'symbol') { + return; + } + + let _parentKey = ''; + const parent = this.parent; + + if (typeof parent?.key !== 'symbol' && parent?.key) { + _parentKey = parent.key; + } + + const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`; + const tabs = `${parent && parent.notRoot ? ' ' : ' '}`; + const currentKey = this?.key ?? 'unknown'; + + if (this.isLeaf && typeof value === 'string') { + const truncatedText = truncateLongStrings(value); + msg += `\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`; + } else if (this.notLeaf && Array.isArray(value) && value.length > 0) { + const currentMessage = `\n${tabs}// ${value.length} ${currentKey.replace(/s$/, '')}(s)`; + this.update(currentMessage, true); + msg += currentMessage; + const stringifiedArray = value.map(condenseArray); + msg += `\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`; + } else if (this.isLeaf && typeof value === 'function') { + msg += `\n${tabs}${parentKey}${currentKey}: function,`; + } else if (this.isLeaf) { + msg += `\n${tabs}${parentKey}${currentKey}: ${value},`; + } + }); + + msg += '\n}'; + return msg; + } catch (e: any) { + return `${msg}\n[LOGGER PARSING ERROR] ${e.message}`; + } + }, +); + +/** + * Truncates long string values in JSON log objects. + * Prevents outputting extremely long values (e.g., base64, blobs). + */ +const jsonTruncateFormat = winston.format((info: any) => { + const truncateLongStrings = (str: string, maxLength: number): string => + str.length > maxLength ? str.substring(0, maxLength) + '...' : str; + + const seen = new WeakSet(); + + const truncateObject = (obj: any): any => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + // Handle circular references + if (seen.has(obj)) { + return '[Circular]'; + } + seen.add(obj); + + if (Array.isArray(obj)) { + return obj.map((item) => truncateObject(item)); + } + + const newObj: Record = {}; + Object.entries(obj).forEach(([key, value]) => { + if (typeof value === 'string') { + newObj[key] = truncateLongStrings(value, CONSOLE_JSON_STRING_LENGTH); + } else { + newObj[key] = truncateObject(value); + } + }); + return newObj; + }; + + return truncateObject(info); +}); + +export { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat }; diff --git a/packages/data-schemas/src/config/winston.ts b/packages/data-schemas/src/config/winston.ts new file mode 100644 index 0000000000..09e8dafc8c --- /dev/null +++ b/packages/data-schemas/src/config/winston.ts @@ -0,0 +1,129 @@ +import path from 'path'; +import winston from 'winston'; +import 'winston-daily-rotate-file'; +import { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } from './parsers'; + +// Define log directory +const logDir = path.join(__dirname, '..', 'logs'); + +// Type-safe environment variables +const { NODE_ENV, DEBUG_LOGGING, CONSOLE_JSON, DEBUG_CONSOLE } = process.env; + +const useConsoleJson = + (typeof CONSOLE_JSON === 'string' && CONSOLE_JSON.toLowerCase() === 'true') || + CONSOLE_JSON === true; + +const useDebugConsole = + (typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE.toLowerCase() === 'true') || + DEBUG_CONSOLE === true; + +const useDebugLogging = + (typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING.toLowerCase() === 'true') || + DEBUG_LOGGING === true; + +// Define custom log levels +const levels: winston.config.AbstractConfigSetLevels = { + error: 0, + warn: 1, + info: 2, + http: 3, + verbose: 4, + debug: 5, + activity: 6, + silly: 7, +}; + +winston.addColors({ + info: 'green', + warn: 'italic yellow', + error: 'red', + debug: 'blue', +}); + +const level = (): string => { + const env = NODE_ENV || 'development'; + return env === 'development' ? 'debug' : 'warn'; +}; + +const fileFormat = winston.format.combine( + redactFormat(), + winston.format.timestamp({ format: () => new Date().toISOString() }), + winston.format.errors({ stack: true }), + winston.format.splat(), +); + +const transports: winston.transport[] = [ + new winston.transports.DailyRotateFile({ + level: 'error', + filename: `${logDir}/error-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + format: fileFormat, + }), +]; + +if (useDebugLogging) { + transports.push( + new winston.transports.DailyRotateFile({ + level: 'debug', + filename: `${logDir}/debug-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + format: winston.format.combine(fileFormat, debugTraverse), + }), + ); +} + +const consoleFormat = winston.format.combine( + redactFormat(), + winston.format.colorize({ all: true }), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf((info) => { + const message = `${info.timestamp} ${info.level}: ${info.message}`; + return info.level.includes('error') ? redactMessage(message) : message; + }), +); + +let consoleLogLevel: string = 'info'; +if (useDebugConsole) { + consoleLogLevel = 'debug'; +} + +// Add console transport +if (useDebugConsole) { + transports.push( + new winston.transports.Console({ + level: consoleLogLevel, + format: useConsoleJson + ? winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json()) + : winston.format.combine(fileFormat, debugTraverse), + }), + ); +} else if (useConsoleJson) { + transports.push( + new winston.transports.Console({ + level: consoleLogLevel, + format: winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json()), + }), + ); +} else { + transports.push( + new winston.transports.Console({ + level: consoleLogLevel, + format: consoleFormat, + }), + ); +} + +// Create logger +const logger = winston.createLogger({ + level: level(), + levels, + transports, +}); + +export default logger; diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 4b3af06b8e..4252ba6990 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -66,3 +66,4 @@ export type { ITransaction } from './schema/transaction'; export { default as userSchema } from './schema/user'; export type { IUser } from './schema/user'; +export { registerModels } from './models'; diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts new file mode 100644 index 0000000000..f293c63ba6 --- /dev/null +++ b/packages/data-schemas/src/models/index.ts @@ -0,0 +1,173 @@ +import type { Mongoose } from 'mongoose'; +import { + agentSchema, + assistantSchema, + balanceSchema, + categoriesSchema, + messageSchema, + sessionSchema, + tokenSchema, + userSchema, + conversationTagSchema, + convoSchema, + fileSchema, + keySchema, + presetSchema, + projectSchema, + promptSchema, + roleSchema, + shareSchema, + toolCallSchema, + transactionSchema, + bannerSchema, + promptGroupSchema, +} from '..'; +import mongoMeili from './plugins/mongoMeili'; + +export const registerModels = (mongoose: Mongoose) => { + const User = registerUserModel(mongoose); + const Session = registerSessionModel(mongoose); + const Token = registerTokenModel(mongoose); + const Message = registerMessageModel(mongoose); + const Agent = registerAgentModel(mongoose); + const Assistant = registerAssistantModel(mongoose); + const Balance = registerBalanceModel(mongoose); + const Banner = registerBannerModel(mongoose); + const Categories = registerCategoriesModel(mongoose); + const ConversationTag = registerConversationTagModel(mongoose); + const File = registerFileModel(mongoose); + const Key = registerKeyModel(mongoose); + const Preset = registerPresetModel(mongoose); + const Project = registerProjectModel(mongoose); + const Prompt = registerPromptModel(mongoose); + const PromptGroup = registerPromptGroupModel(mongoose); + const Role = registerRoleModel(mongoose); + const SharedLink = registerShareModel(mongoose); + const ToolCall = registerToolCallModel(mongoose); + const Transaction = registerTransactionModel(mongoose); + const Conversation = registerConversationModel(mongoose); + + return { + User, + Session, + Token, + Message, + Agent, + Assistant, + Balance, + Banner, + Categories, + ConversationTag, + File, + Key, + Preset, + Project, + Prompt, + PromptGroup + Role, + SharedLink, + ToolCall, + Transaction, + Conversation, + }; +}; + +const registerSessionModel = (mongoose: Mongoose) => { + return mongoose.models.Session || mongoose.model('Session', sessionSchema); +}; + +const registerUserModel = (mongoose: Mongoose) => { + return mongoose.models.User || mongoose.model('User', userSchema); +}; + +const registerTokenModel = (mongoose: Mongoose) => { + return mongoose.models.Token || mongoose.model('Token', tokenSchema); +}; + +const registerMessageModel = (mongoose: Mongoose) => { + + if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { + messageSchema.plugin(mongoMeili, { + host: process.env.MEILI_HOST, + apiKey: process.env.MEILI_MASTER_KEY, + indexName: 'messages', + primaryKey: 'messageId', + }); + } + + return mongoose.models.Message || mongoose.model('Message', messageSchema); +}; + +const registerAgentModel = (mongoose: Mongoose) => { + return mongoose.models.Agent || mongoose.model('Agent', agentSchema); +}; + +const registerAssistantModel = (mongoose: Mongoose) => { + return mongoose.models.Assistant || mongoose.model('Assistant', assistantSchema); +}; + +const registerBalanceModel = (mongoose: Mongoose) => { + return mongoose.models.Balance || mongoose.model('Balance', balanceSchema); +}; + +const registerBannerModel = (mongoose: Mongoose) => { + return mongoose.models.Banner || mongoose.model('Banner', bannerSchema); +}; + +const registerCategoriesModel = (mongoose: Mongoose) => { + return mongoose.models.Categories || mongoose.model('Categories', categoriesSchema); +}; + +const registerConversationTagModel = (mongoose: Mongoose) => { + return ( + mongoose.models.ConversationTag || mongoose.model('ConversationTag', conversationTagSchema) + ); +}; +const registerFileModel = (mongoose: Mongoose) => { + return mongoose.models.File || mongoose.model('File', fileSchema); +}; + +const registerKeyModel = (mongoose: Mongoose) => { + return mongoose.models.Key || mongoose.model('Key', keySchema); +}; +const registerPresetModel = (mongoose: Mongoose) => { + return mongoose.models.Preset || mongoose.model('Preset', presetSchema); +}; + +const registerProjectModel = (mongoose: Mongoose) => { + return mongoose.models.Project || mongoose.model('Project', projectSchema); +}; +const registerPromptModel = (mongoose: Mongoose) => { + return mongoose.models.Prompt || mongoose.model('Prompt', promptSchema); +}; +const registerPromptGroupModel = (mongoose: Mongoose) => { + return mongoose.models.PromptGroup || mongoose.model('PromptGroup', promptGroupSchema); +}; + +const registerRoleModel = (mongoose: Mongoose) => { + return mongoose.models.Role || mongoose.model('Role', roleSchema); +}; +const registerShareModel = (mongoose: Mongoose) => { + return mongoose.models.SharedLink || mongoose.model('SharedLink', shareSchema); +}; + +const registerToolCallModel = (mongoose: Mongoose) => { + return mongoose.models.ToolCall || mongoose.model('ToolCall', toolCallSchema); +}; + +const registerTransactionModel = (mongoose: Mongoose) => { + return mongoose.models.Transaction || mongoose.model('Trasaction', transactionSchema); +}; +const registerConversationModel = (mongoose: Mongoose) => { + if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { + convoSchema.plugin(mongoMeili, { + host: process.env.MEILI_HOST, + apiKey: process.env.MEILI_MASTER_KEY, + /** Note: Will get created automatically if it doesn't exist already */ + indexName: 'convos', + primaryKey: 'conversationId', + }); + } + + return mongoose.models.Conversation || mongoose.model('Conversation', convoSchema); +}; diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts new file mode 100644 index 0000000000..470f1f5bdb --- /dev/null +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -0,0 +1,494 @@ +import _ from 'lodash'; +import mongoose, { Schema, Document, Model } from 'mongoose'; +import { MeiliSearch, Index } from 'meilisearch'; +const { parseTextParts } = require('librechat-data-provider'); +const logger = require('~/config/meiliLogger'); + +interface MongoMeiliOptions { + host: string; + apiKey: string; + indexName: string; + primaryKey: string; +} + +interface MeiliIndexable { + [key: string]: any; + _meiliIndex?: boolean; +} + +// Environment flags +/** + * Flag to indicate if search is enabled based on environment variables. + * @type {boolean} + */ +const searchEnabled = process.env.SEARCH && process.env.SEARCH.toLowerCase() === 'true'; + +/** + * Flag to indicate if MeiliSearch is enabled based on required environment variables. + * @type {boolean} + */ +const meiliEnabled = process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY && searchEnabled; + +/** + * Validates the required options for configuring the mongoMeili plugin. + * + * @param {Object} options - The configuration options. + * @param {string} options.host - The MeiliSearch host. + * @param {string} options.apiKey - The MeiliSearch API key. + * @param {string} options.indexName - The name of the index. + * @throws {Error} Throws an error if any required option is missing. + */ +const validateOptions = function (options: any) { + const requiredKeys = ['host', 'apiKey', 'indexName']; + requiredKeys.forEach((key) => { + if (!options[key]) { + throw new Error(`Missing mongoMeili Option: ${key}`); + } + }); +}; + +/** + * Factory function to create a MeiliMongooseModel class which extends a Mongoose model. + * This class contains static and instance methods to synchronize and manage the MeiliSearch index + * corresponding to the MongoDB collection. + * + * @param {Object} config - Configuration object. + * @param {Object} config.index - The MeiliSearch index object. + * @param {Array} config.attributesToIndex - List of attributes to index. + * @returns {Function} A class definition that will be loaded into the Mongoose schema. + */ +const createMeiliMongooseModel = function ({ + index, + attributesToIndex, +}: { + index: Index; + attributesToIndex: string[]; +}) { + // The primary key is assumed to be the first attribute in the attributesToIndex array. + const primaryKey = attributesToIndex[0]; + + class MeiliMongooseModel { + /** + * Synchronizes the data between the MongoDB collection and the MeiliSearch index. + * + * The synchronization process involves: + * 1. Fetching all documents from the MongoDB collection and MeiliSearch index. + * 2. Comparing documents from both sources. + * 3. Deleting documents from MeiliSearch that no longer exist in MongoDB. + * 4. Adding documents to MeiliSearch that exist in MongoDB but not in the index. + * 5. Updating documents in MeiliSearch if key fields (such as `text` or `title`) differ. + * 6. Updating the `_meiliIndex` field in MongoDB to indicate the indexing status. + * + * Note: The function processes documents in batches because MeiliSearch's + * `index.getDocuments` requires an exact limit and `index.addDocuments` does not handle + * partial failures in a batch. + * + * @returns {Promise} Resolves when the synchronization is complete. + */ + static async syncWithMeili(this: Model) { + try { + let moreDocuments = true; + // Retrieve all MongoDB documents from the collection as plain JavaScript objects. + const mongoDocuments = await this.find().lean(); + + // Helper function to format a document by selecting only the attributes to index + // and omitting keys starting with '$'. + const format = (doc: Record) => + _.omitBy(_.pick(doc, attributesToIndex), (v, k) => k.startsWith('$')); + + // Build a map of MongoDB documents for quick lookup based on the primary key. + const mongoMap = new Map(mongoDocuments.map((doc) => [doc[primaryKey], format(doc)])); + const indexMap = new Map(); + let offset = 0; + const batchSize = 1000; + + // Fetch documents from the MeiliSearch index in batches. + while (moreDocuments) { + const batch = await index.getDocuments({ limit: batchSize, offset }); + if (batch.results.length === 0) { + moreDocuments = false; + } + for (const doc of batch.results) { + indexMap.set(doc[primaryKey], format(doc)); + } + offset += batchSize; + } + + logger.debug('[syncWithMeili]', { indexMap: indexMap.size, mongoMap: mongoMap.size }); + + const updateOps = []; + + // Process documents present in the MeiliSearch index. + for (const [id, doc] of indexMap) { + const update: any = {}; + update[primaryKey] = id; + if (mongoMap.has(id)) { + // If document exists in MongoDB, check for discrepancies in key fields. + if ( + (doc.text && doc.text !== mongoMap.get(id)?.text) || + (doc.title && doc.title !== mongoMap.get(id)?.title) + ) { + logger.debug( + `[syncWithMeili] ${id} had document discrepancy in ${ + doc.text ? 'text' : 'title' + } field`, + ); + updateOps.push({ + updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, + }); + await index.addDocuments([doc]); + } + } else { + // If the document does not exist in MongoDB, delete it from MeiliSearch. + await index.deleteDocument(id); + updateOps.push({ + updateOne: { filter: update, update: { $set: { _meiliIndex: false } } }, + }); + } + } + + // Process documents present in MongoDB. + for (const [id, doc] of mongoMap) { + const update: any = {}; + update[primaryKey] = id; + // If the document is missing in the Meili index, add it. + if (!indexMap.has(id)) { + await index.addDocuments([doc]); + updateOps.push({ + updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, + }); + } else if (doc._meiliIndex === false) { + // If the document exists but is marked as not indexed, update the flag. + updateOps.push({ + updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, + }); + } + } + + // Execute bulk update operations in MongoDB to update the _meiliIndex flags. + if (updateOps.length > 0) { + await this.collection.bulkWrite(updateOps); + logger.debug( + `[syncWithMeili] Finished indexing ${ + primaryKey === 'messageId' ? 'messages' : 'conversations' + }`, + ); + } + } catch (error) { + logger.error('[syncWithMeili] Error adding document to Meili', error); + } + } + + /** + * Updates settings for the MeiliSearch index. + * + * @param {Object} settings - The settings to update on the MeiliSearch index. + * @returns {Promise} Promise resolving to the update result. + */ + static async setMeiliIndexSettings(settings: any) { + return await index.updateSettings(settings); + } + + /** + * Searches the MeiliSearch index and optionally populates the results with data from MongoDB. + * + * @param {string} q - The search query. + * @param {Object} params - Additional search parameters for MeiliSearch. + * @param {boolean} populate - Whether to populate search hits with full MongoDB documents. + * @returns {Promise} The search results with populated hits if requested. + */ + static async meiliSearch(this: Model, q: string, params: any, populate: boolean) { + const data = await index.search(q, params); + + if (populate) { + // Build a query using the primary key values from the search hits. + const query: Record = {}; + query[primaryKey] = _.map(data.hits, (hit) => cleanUpPrimaryKeyValue(hit[primaryKey])); + + // Build a projection object, including only keys that do not start with '$'. + const projection = Object.keys(this.schema.obj).reduce>( + (acc, key) => { + if (!key.startsWith('$')) acc[key] = 1; + return acc; + }, + { _id: 1, __v: 1 }, + ); + + // Retrieve the full documents from MongoDB. + const hitsFromMongoose = await this.find(query, projection).lean(); + + // Merge the MongoDB documents with the search hits. + const populatedHits = data.hits.map(function (hit) { + const query = {}; + query[primaryKey] = hit[primaryKey]; + const originalHit = _.find(hitsFromMongoose, query); + + return { + ...(originalHit ?? {}), + ...hit, + }; + }); + data.hits = populatedHits; + } + + return data; + } + + /** + * Preprocesses the current document for indexing. + * + * This method: + * - Picks only the defined attributes to index. + * - Omits any keys starting with '$'. + * - Replaces pipe characters ('|') in `conversationId` with '--'. + * - Extracts and concatenates text from an array of content items. + * + * @returns {Object} The preprocessed object ready for indexing. + */ + preprocessObjectForIndex(this: Document) { + const object = _.omitBy(_.pick(this.toJSON(), attributesToIndex), (v, k) => + k.startsWith('$'), + ); + if (object.conversationId && object.conversationId.includes('|')) { + object.conversationId = object.conversationId.replace(/\|/g, '--'); + } + + if (object.content && Array.isArray(object.content)) { + object.text = parseTextParts(object.content); + delete object.content; + } + + return object; + } + + /** + * Adds the current document to the MeiliSearch index. + * + * The method preprocesses the document, adds it to MeiliSearch, and then updates + * the MongoDB document's `_meiliIndex` flag to true. + * + * @returns {Promise} + */ + async addObjectToMeili(this: Document) { + const object = this.preprocessObjectForIndex(); + try { + await index.addDocuments([object]); + } catch (error) { + // Error handling can be enhanced as needed. + logger.error('[addObjectToMeili] Error adding document to Meili', error); + } + + await this.collection.updateMany({ _id: this._id }, { $set: { _meiliIndex: true } }); + } + + /** + * Updates the current document in the MeiliSearch index. + * + * @returns {Promise} + */ + async updateObjectToMeili(this: Document) { + const object = _.omitBy(_.pick(this.toJSON(), attributesToIndex), (v, k) => + k.startsWith('$'), + ); + await index.updateDocuments([object]); + } + + /** + * Deletes the current document from the MeiliSearch index. + * + * @returns {Promise} + */ + async deleteObjectFromMeili(this: Document) { + await index.deleteDocument(this._id); + } + + /** + * Post-save hook to synchronize the document with MeiliSearch. + * + * If the document is already indexed (i.e. `_meiliIndex` is true), it updates it; + * otherwise, it adds the document to the index. + */ + postSaveHook(this: Document) { + if (this._meiliIndex) { + this.updateObjectToMeili(); + } else { + this.addObjectToMeili(); + } + } + + /** + * Post-update hook to update the document in MeiliSearch. + * + * This hook is triggered after a document update, ensuring that changes are + * propagated to the MeiliSearch index if the document is indexed. + */ + postUpdateHook() { + if (this._meiliIndex) { + this.updateObjectToMeili(); + } + } + + /** + * Post-remove hook to delete the document from MeiliSearch. + * + * This hook is triggered after a document is removed, ensuring that the document + * is also removed from the MeiliSearch index if it was previously indexed. + */ + postRemoveHook(this: Document) { + if (this._meiliIndex) { + this.deleteObjectFromMeili(); + } + } + } + + return MeiliMongooseModel; +}; + +const cleanUpPrimaryKeyValue = (value) => { + // For Bing convoId handling + return value.replace(/--/g, '|'); +}; +/** + * Mongoose plugin to synchronize MongoDB collections with a MeiliSearch index. + * + * This plugin: + * - Validates the provided options. + * - Adds a `_meiliIndex` field to the schema to track indexing status. + * - Sets up a MeiliSearch client and creates an index if it doesn't already exist. + * - Loads class methods for syncing, searching, and managing documents in MeiliSearch. + * - Registers Mongoose hooks (post-save, post-update, post-remove, etc.) to maintain index consistency. + * + * @param {mongoose.Schema} schema - The Mongoose schema to which the plugin is applied. + * @param {Object} options - Configuration options. + * @param {string} options.host - The MeiliSearch host. + * @param {string} options.apiKey - The MeiliSearch API key. + * @param {string} options.indexName - The name of the MeiliSearch index. + * @param {string} options.primaryKey - The primary key field for indexing. + */ +export default function mongoMeili(schema: Schema, options: MongoMeiliOptions) { + validateOptions(options); + + // Add _meiliIndex field to the schema to track if a document has been indexed in MeiliSearch. + schema.add({ + _meiliIndex: { + type: Boolean, + required: false, + select: false, + default: false, + }, + }); + + const { host, apiKey, indexName, primaryKey } = options; + + // Setup the MeiliSearch client. + const client = new MeiliSearch({ host, apiKey }); + + // Create the index asynchronously if it doesn't exist. + client.createIndex(indexName, { primaryKey }); + + // Setup the MeiliSearch index for this schema. + const index = client.index(indexName); + + // Collect attributes from the schema that should be indexed. + const attributesToIndex = [ + ..._.reduce( + schema.obj, + function (results, value, key) { + return value.meiliIndex ? [...results, key] : results; + }, + [], + ), + ]; + + // Load the class methods into the schema. + schema.loadClass(createMeiliMongooseModel({ index, client, attributesToIndex })); + + // Register Mongoose hooks to synchronize with MeiliSearch. + + // Post-save: synchronize after a document is saved. + schema.post('save', function (doc) { + doc.postSaveHook(); + }); + + // Post-update: synchronize after a document is updated. + schema.post('update', function (doc) { + doc.postUpdateHook(); + }); + + // Post-remove: synchronize after a document is removed. + schema.post('remove', function (doc) { + doc.postRemoveHook(); + }); + + // Pre-deleteMany hook: remove corresponding documents from MeiliSearch when multiple documents are deleted. + schema.pre('deleteMany', async function (next) { + if (!meiliEnabled) { + return next(); + } + + try { + // Check if the schema has a "messages" field to determine if it's a conversation schema. + if (Object.prototype.hasOwnProperty.call(schema.obj, 'messages')) { + const convoIndex = client.index('convos'); + const deletedConvos = await mongoose.model('Conversation').find(this._conditions).lean(); + const promises = deletedConvos.map((convo) => + convoIndex.deleteDocument(convo.conversationId), + ); + await Promise.all(promises); + } + + // Check if the schema has a "messageId" field to determine if it's a message schema. + if (Object.prototype.hasOwnProperty.call(schema.obj, 'messageId')) { + const messageIndex = client.index('messages'); + const deletedMessages = await mongoose.model('Message').find(this._conditions).lean(); + const promises = deletedMessages.map((message) => + messageIndex.deleteDocument(message.messageId), + ); + await Promise.all(promises); + } + return next(); + } catch (error) { + if (meiliEnabled) { + logger.error( + '[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion. Next startup may be slow due to syncing.', + error, + ); + } + return next(); + } + }); + + // Post-findOneAndUpdate hook: update MeiliSearch index after a document is updated via findOneAndUpdate. + schema.post('findOneAndUpdate', async function (doc) { + if (!meiliEnabled) { + return; + } + + // If the document is unfinished, do not update the index. + if (doc.unfinished) { + return; + } + + let meiliDoc; + // For conversation documents, try to fetch the document from the "convos" index. + if (doc.messages) { + try { + meiliDoc = await client.index('convos').getDocument(doc.conversationId); + } catch (error) { + logger.debug( + '[MeiliMongooseModel.findOneAndUpdate] Convo not found in MeiliSearch and will index ' + + doc.conversationId, + error, + ); + } + } + + // If the MeiliSearch document exists and the title is unchanged, do nothing. + if (meiliDoc && meiliDoc.title === doc.title) { + return; + } + + // Otherwise, trigger a post-save hook to synchronize the document. + doc.postSaveHook(); + }); +} diff --git a/packages/data-schemas/src/schema/session.ts b/packages/data-schemas/src/schema/session.ts index 558bfd2252..10cd13bb2e 100644 --- a/packages/data-schemas/src/schema/session.ts +++ b/packages/data-schemas/src/schema/session.ts @@ -1,4 +1,7 @@ import mongoose, { Schema, Document, Types } from 'mongoose'; +import jwt from 'jsonwebtoken'; +import logger from '../config/winston'; +const { webcrypto } = require('node:crypto'); export interface ISession extends Document { refreshTokenHash: string; @@ -23,4 +26,224 @@ const sessionSchema: Schema = new Schema({ }, }); +/** + * Error class for Session-related errors + */ +class SessionError extends Error { + constructor(message, code = 'SESSION_ERROR') { + super(message); + this.name = 'SessionError'; + this.code = code; + } +} +const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; +const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; // 7 days default + +/** + * Creates a new session for a user + * @param {string} userId - The ID of the user + * @param {Object} options - Additional options for session creation + * @param {Date} options.expiration - Custom expiration date + * @returns {Promise<{session: Session, refreshToken: string}>} + * @throws {SessionError} + */ +sessionSchema.statics.createSession = async function (userId, options = {}) { + if (!userId) { + throw new SessionError('User ID is required', 'INVALID_USER_ID'); + } + + try { + const session = { + _id: new Types.ObjectId(), + user: userId, + expiration: options.expiration || new Date(Date.now() + expires), + }; + const refreshToken = await this.generateRefreshToken(session); + + return { session, refreshToken }; + } catch (error) { + logger.error('[createSession] Error creating session:', error); + throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED'); + } +}; + +/** + * Finds a session by various parameters + * @param {Object} params - Search parameters + * @param {string} [params.refreshToken] - The refresh token to search by + * @param {string} [params.userId] - The user ID to search by + * @param {string} [params.sessionId] - The session ID to search by + * @param {Object} [options] - Additional options + * @param {boolean} [options.lean=true] - Whether to return plain objects instead of documents + * @returns {Promise} + * @throws {SessionError} + */ +sessionSchema.statics.findSession = async function (params, options = { lean: true }) { + try { + const query = {}; + + if (!params.refreshToken && !params.userId && !params.sessionId) { + throw new SessionError('At least one search parameter is required', 'INVALID_SEARCH_PARAMS'); + } + + if (params.refreshToken) { + const tokenHash = await hashToken(params.refreshToken); + query.refreshTokenHash = tokenHash; + } + + if (params.userId) { + query.user = params.userId; + } + + if (params.sessionId) { + const sessionId = params.sessionId.sessionId || params.sessionId; + if (!mongoose.Types.ObjectId.isValid(sessionId)) { + throw new SessionError('Invalid session ID format', 'INVALID_SESSION_ID'); + } + query._id = sessionId; + } + + // Add expiration check to only return valid sessions + query.expiration = { $gt: new Date() }; + + const sessionQuery = this.findOne(query); + + if (options.lean) { + return await sessionQuery.lean(); + } + + return await sessionQuery.exec(); + } catch (error) { + logger.error('[findSession] Error finding session:', error); + throw new SessionError('Failed to find session', 'FIND_SESSION_FAILED'); + } +}; + +/** + * Deletes a session by refresh token or session ID + * @param {Object} params - Delete parameters + * @param {string} [params.refreshToken] - The refresh token of the session to delete + * @param {string} [params.sessionId] - The ID of the session to delete + * @returns {Promise} + * @throws {SessionError} + */ +sessionSchema.statics.deleteSession = async function (params) { + try { + if (!params.refreshToken && !params.sessionId) { + throw new SessionError( + 'Either refreshToken or sessionId is required', + 'INVALID_DELETE_PARAMS', + ); + } + + const query = {}; + + if (params.refreshToken) { + query.refreshTokenHash = await hashToken(params.refreshToken); + } + + if (params.sessionId) { + query._id = params.sessionId; + } + + const result = await this.deleteOne(query); + + if (result.deletedCount === 0) { + logger.warn('[deleteSession] No session found to delete'); + } + + return result; + } catch (error) { + logger.error('[deleteSession] Error deleting session:', error); + throw new SessionError('Failed to delete session', 'DELETE_SESSION_FAILED'); + } +}; + +/** + * Generates a refresh token for a session + * @param {Session} session - The session to generate a token for + * @returns {Promise} + * @throws {SessionError} + */ +sessionSchema.statics.generateRefreshToken = async function (session) { + if (!session || !session.user) { + throw new SessionError('Invalid session object', 'INVALID_SESSION'); + } + + try { + const expiresIn = session.expiration ? session.expiration.getTime() : Date.now() + expires; + if (!session.expiration) { + session.expiration = new Date(expiresIn); + } + + const refreshToken = await signPayload({ + payload: { + id: session.user, + sessionId: session._id, + }, + secret: process.env.JWT_REFRESH_SECRET, + expirationTime: Math.floor((expiresIn - Date.now()) / 1000), + }); + + session.refreshTokenHash = await hashToken(refreshToken); + await this.create(session); + return refreshToken; + } catch (error) { + logger.error('[generateRefreshToken] Error generating refresh token:', error); + throw new SessionError('Failed to generate refresh token', 'GENERATE_TOKEN_FAILED'); + } +}; + +/** + * Deletes all sessions for a user + * @param {string} userId - The ID of the user + * @param {Object} [options] - Additional options + * @param {boolean} [options.excludeCurrentSession] - Whether to exclude the current session + * @param {string} [options.currentSessionId] - The ID of the current session to exclude + * @returns {Promise} + * @throws {SessionError} + */ +sessionSchema.statics.deleteAllUserSessions = async function (userId, options = {}) { + try { + if (!userId) { + throw new SessionError('User ID is required', 'INVALID_USER_ID'); + } + + // Extract userId if it's passed as an object + const userIdString = userId.userId || userId; + + if (!mongoose.Types.ObjectId.isValid(userIdString)) { + throw new SessionError('Invalid user ID format', 'INVALID_USER_ID_FORMAT'); + } + + const query = { user: userIdString }; + + if (options.excludeCurrentSession && options.currentSessionId) { + query._id = { $ne: options.currentSessionId }; + } + + const result = await this.deleteMany(query); + + if (result.deletedCount > 0) { + logger.debug( + `[deleteAllUserSessions] Deleted ${result.deletedCount} sessions for user ${userIdString}.`, + ); + } + + return result; + } catch (error) { + logger.error('[deleteAllUserSessions] Error deleting user sessions:', error); + throw new SessionError('Failed to delete user sessions', 'DELETE_ALL_SESSIONS_FAILED'); + } +}; + +export async function signPayload({ payload, secret, expirationTime }) { + return jwt.sign(payload, secret, { expiresIn: expirationTime }); +} + +export async function hashToken(str) { + const data = new TextEncoder().encode(str); + const hashBuffer = await webcrypto.subtle.digest('SHA-256', data); + return Buffer.from(hashBuffer).toString('hex'); +} export default sessionSchema; diff --git a/packages/data-schemas/src/schema/token.ts b/packages/data-schemas/src/schema/token.ts index f238408209..f5b91ba49e 100644 --- a/packages/data-schemas/src/schema/token.ts +++ b/packages/data-schemas/src/schema/token.ts @@ -1,4 +1,5 @@ import { Schema, Document, Types } from 'mongoose'; +import { logger } from '~/config'; export interface IToken extends Document { userId: Types.ObjectId; @@ -47,4 +48,116 @@ const tokenSchema: Schema = new Schema({ tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); +/** + * Creates a new Token instance. + * @param {Object} tokenData - The data for the new Token. + * @param {mongoose.Types.ObjectId} tokenData.userId - The user's ID. It is required. + * @param {String} tokenData.email - The user's email. + * @param {String} tokenData.token - The token. It is required. + * @param {Number} tokenData.expiresIn - The number of seconds until the token expires. + * @returns {Promise} The new Token instance. + * @throws Will throw an error if token creation fails. + */ +tokenSchema.statics.createToken = async function (tokenData) { + try { + const currentTime = new Date(); + const expiresAt = new Date(currentTime.getTime() + tokenData.expiresIn * 1000); + + const newTokenData = { + ...tokenData, + createdAt: currentTime, + expiresAt, + }; + + return await this.create(newTokenData); + } catch (error) { + logger.debug('An error occurred while creating token:', error); + throw error; + } +}; + +/** + * Updates a Token document that matches the provided query. + * @param {Object} query - The query to match against. + * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. + * @param {String} query.token - The token value. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. + * @param {Object} updateData - The data to update the Token with. + * @returns {Promise} The updated Token document, or null if not found. + * @throws Will throw an error if the update operation fails. + */ +tokenSchema.statics.updateToken = async function (query, updateData) { + try { + return await this.findOneAndUpdate(query, updateData, { new: true }); + } catch (error) { + logger.debug('An error occurred while updating token:', error); + throw error; + } +}; + +/** + * Deletes all Token documents that match the provided token, user ID, or email. + * @param {Object} query - The query to match against. + * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. + * @param {String} query.token - The token value. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. + * @returns {Promise} The result of the delete operation. + * @throws Will throw an error if the delete operation fails. + */ +tokenSchema.statics.deleteTokens = async function (query) { + try { + return await Token.deleteMany({ + $or: [ + { userId: query.userId }, + { token: query.token }, + { email: query.email }, + { identifier: query.identifier }, + ], + }); + } catch (error) { + logger.debug('An error occurred while deleting tokens:', error); + throw error; + } +}; + +/** + * Finds a Token document that matches the provided query. + * @param {Object} query - The query to match against. + * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. + * @param {String} query.token - The token value. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. + * @returns {Promise} The matched Token document, or null if not found. + * @throws Will throw an error if the find operation fails. + */ +tokenSchema.statics.findToken = async function (query) { + try { + const conditions = []; + + if (query.userId) { + conditions.push({ userId: query.userId }); + } + if (query.token) { + conditions.push({ token: query.token }); + } + if (query.email) { + conditions.push({ email: query.email }); + } + if (query.identifier) { + conditions.push({ identifier: query.identifier }); + } + + const token = await this.findOne({ + $and: conditions, + }).lean(); + + return token; + } catch (error) { + logger.debug('An error occurred while finding token:', error); + throw error; + } +}; + export default tokenSchema; diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 8e5fade2f3..b5645a32fa 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -1,6 +1,7 @@ -import { Schema, Document } from 'mongoose'; +import mongoose, { Schema, Document, Model, Types } from 'mongoose'; import { SystemRoles } from 'librechat-data-provider'; - +import { default as balanceSchema } from './balance'; +import { signPayload } from './session'; export interface IUser extends Document { name?: string; username?: string; @@ -56,7 +57,7 @@ const BackupCodeSchema = new Schema( { _id: false }, ); -const User = new Schema( +const userSchema = new Schema( { name: { type: String, @@ -166,4 +167,165 @@ const User = new Schema( { timestamps: true }, ); -export default User; \ No newline at end of file +/** + * Search for a single user based on partial data and return matching user document as plain object. + * @param {Partial} searchCriteria - The partial data to use for searching the user. + * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. + * @returns {Promise} A plain object representing the user document, or `null` if no user is found. + */ +userSchema.statics.findUser = async function ( + searchCriteria: Partial, + fieldsToSelect: string | string[] | null = null, +) { + const query = this.findOne(searchCriteria); + if (fieldsToSelect) { + query.select(fieldsToSelect); + } + return await query.lean(); +}; + +/** + * Count the number of user documents in the collection based on the provided filter. + * + * @param {Object} [filter={}] - The filter to apply when counting the documents. + * @returns {Promise} The count of documents that match the filter. + */ +userSchema.statics.countUsers = async function (filter: Record = {}) { + return await this.countDocuments(filter); +}; +/** + * Creates a new user, optionally with a TTL of 1 week. + * @param {MongoUser} data - The user data to be created, must contain user_id. + * @param {boolean} [disableTTL=true] - Whether to disable the TTL. Defaults to `true`. + * @param {boolean} [returnUser=false] - Whether to return the created user object. + * @returns {Promise} A promise that resolves to the created user document ID or user object. + * @throws {Error} If a user with the same user_id already exists. + */ +userSchema.statics.createUser = async function ( + data: Partial, + balanceConfig: any, + disableTTL: boolean = true, + returnUser: boolean = false, +) { + const userData: Partial = { + ...data, + expiresAt: disableTTL ? null : new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds + }; + + if (disableTTL) { + delete userData.expiresAt; + } + + const user = await this.create(userData); + + // If balance is enabled, create or update a balance record for the user using global.interfaceConfig.balance + if (balanceConfig?.enabled && balanceConfig?.startBalance) { + const update = { + $inc: { tokenCredits: balanceConfig.startBalance }, + }; + + if ( + balanceConfig.autoRefillEnabled && + balanceConfig.refillIntervalValue != null && + balanceConfig.refillIntervalUnit != null && + balanceConfig.refillAmount != null + ) { + update.$set = { + autoRefillEnabled: true, + refillIntervalValue: balanceConfig.refillIntervalValue, + refillIntervalUnit: balanceConfig.refillIntervalUnit, + refillAmount: balanceConfig.refillAmount, + }; + } + + const balanceModel = mongoose.model('Balance', balanceSchema); + await balanceModel + .findOneAndUpdate({ user: user._id }, update, { upsert: true, new: true }) + .lean(); + } + + if (returnUser) { + return user.toObject(); + } + return user._id; +}; +/** + * Update a user with new data without overwriting existing properties. + * + * @param {string} userId - The ID of the user to update. + * @param {Object} updateData - An object containing the properties to update. + * @returns {Promise} The updated user document as a plain object, or `null` if no user is found. + */ +userSchema.statics.updateUser = async function (userId: string, updateData: Partial) { + const updateOperation = { + $set: updateData, + $unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL + }; + return await this.findByIdAndUpdate(userId, updateOperation, { + new: true, + runValidators: true, + }).lean(); +}; + +/** + * Retrieve a user by ID and convert the found user document to a plain object. + * + * @param {string} userId - The ID of the user to find and return as a plain object. + * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. + * @returns {Promise} A plain object representing the user document, or `null` if no user is found. + */ +userSchema.statics.getUserById = async function ( + userId: string, + fieldsToSelect: string | string[] | null = null, +) { + const query = this.findById(userId); + if (fieldsToSelect) { + query.select(fieldsToSelect); + } + return await query.lean(); +}; + +/** + * Delete a user by their unique ID. + * + * @param {string} userId - The ID of the user to delete. + * @returns {Promise<{ deletedCount: number }>} An object indicating the number of deleted documents. + */ +userSchema.statics.deleteUserById = async function (userId: string) { + try { + const result = await this.deleteOne({ _id: userId }); + if (result.deletedCount === 0) { + return { deletedCount: 0, message: 'No user found with that ID.' }; + } + return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' }; + } catch (error: any) { + throw new Error('Error deleting user: ' + error?.message); + } +}; + +/** + * Generates a JWT token for a given user. + * + * @param {MongoUser} user - The user for whom the token is being generated. + * @returns {Promise} A promise that resolves to a JWT token. + */ +userSchema.methods.generateToken = async function (user: IUser): Promise { + if (!user) { + throw new Error('No user provided'); + } + + const expires = eval(process.env.SESSION_EXPIRY ?? '0') ?? 1000 * 60 * 15; + + return await signPayload({ + payload: { + id: user._id, + username: user.username, + provider: user.provider, + email: user.email, + }, + secret: process.env.JWT_SECRET, + expirationTime: expires / 1000, + }); +}; + +export default userSchema; diff --git a/packages/data-schemas/tsconfig.json b/packages/data-schemas/tsconfig.json index 7c5cf16cb2..fb6cf87a43 100644 --- a/packages/data-schemas/tsconfig.json +++ b/packages/data-schemas/tsconfig.json @@ -15,5 +15,6 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] + "exclude": ["node_modules", "dist", "tests"], + "esModuleInterop": true }