Move usermethods and models to data-schema

This commit is contained in:
Cha 2025-05-29 16:37:31 +08:00 committed by Danny Avila
parent 4808c5be48
commit 4049b5572c
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
93 changed files with 2396 additions and 1267 deletions

View file

@ -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<string, any>) => {
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<string, any> = {};
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 };

View file

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

View file

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

View file

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

View file

@ -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<string>} 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<MeiliIndexable>;
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<void>} Resolves when the synchronization is complete.
*/
static async syncWithMeili(this: Model<any>) {
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<string, any>) =>
_.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<Object>} 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<Object>} The search results with populated hits if requested.
*/
static async meiliSearch(this: Model<any>, 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<string, any> = {};
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<Record<string, number>>(
(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<void>}
*/
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<void>}
*/
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<void>}
*/
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<MeiliIndexable>(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();
});
}

View file

@ -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<ISession> = 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<Session|null>}
* @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<Object>}
* @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<string>}
* @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<Object>}
* @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;

View file

@ -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<IToken> = 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<mongoose.Document>} 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<mongoose.Document|null>} 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<Object>} 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<Object|null>} 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;

View file

@ -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<IUser>(
const userSchema = new Schema<IUser>(
{
name: {
type: String,
@ -166,4 +167,165 @@ const User = new Schema<IUser>(
{ timestamps: true },
);
export default User;
/**
* Search for a single user based on partial data and return matching user document as plain object.
* @param {Partial<MongoUser>} 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<MongoUser>} A plain object representing the user document, or `null` if no user is found.
*/
userSchema.statics.findUser = async function (
searchCriteria: Partial<IUser>,
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<number>} The count of documents that match the filter.
*/
userSchema.statics.countUsers = async function (filter: Record<string, any> = {}) {
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<ObjectId|MongoUser>} 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<IUser>,
balanceConfig: any,
disableTTL: boolean = true,
returnUser: boolean = false,
) {
const userData: Partial<IUser> = {
...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<MongoUser>} The updated user document as a plain object, or `null` if no user is found.
*/
userSchema.statics.updateUser = async function (userId: string, updateData: Partial<IUser>) {
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<MongoUser>} 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<string>} A promise that resolves to a JWT token.
*/
userSchema.methods.generateToken = async function (user: IUser): Promise<string> {
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;