mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 16:23:44 +00:00
fix: cap uploaded files in forced-retention convert-on-touch cascades
The migration sweep caps a conversation's File records, but the runtime convert-on-touch cascades did not: converting a pre-existing chat through a message save, edit, feedback, tag, or project write set deadlines on the conversation, messages, and shares while leaving File documents at expiredAt: null. File cleanup only sweeps rows whose own expiredAt is set, so those uploads outlived the ephemeral chat's TTL. Cap the conversation's files alongside messages and shares in both cascadeForcedConversationRetention (single) and the bulk tag/project cascade, reusing capConversationFiles and threading the File model through the cascade helpers and their callers. Files are scoped by conversationId (a unique per-chat id) since File.user is an ObjectId rather than the string user id.
This commit is contained in:
parent
fb095b9241
commit
8bcfb0772c
4 changed files with 56 additions and 8 deletions
|
|
@ -2,7 +2,7 @@ import mongoose from 'mongoose';
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type { IChatProject, IConversation, IMessage, ISharedLink } from '~/types';
|
||||
import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types';
|
||||
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
|
||||
import { createModels } from '~/models';
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ let ChatProject: mongoose.Model<IChatProject>;
|
|||
let Conversation: mongoose.Model<IConversation>;
|
||||
let Message: mongoose.Model<IMessage>;
|
||||
let SharedLink: mongoose.Model<ISharedLink>;
|
||||
let File: mongoose.Model<IMongoFile>;
|
||||
let methods: ChatProjectMethods;
|
||||
let modelsToCleanup: string[] = [];
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ beforeAll(async () => {
|
|||
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
Message = mongoose.models.Message as mongoose.Model<IMessage>;
|
||||
SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
File = mongoose.models.File as mongoose.Model<IMongoFile>;
|
||||
methods = createChatProjectMethods(mongoose);
|
||||
|
||||
await mongoose.connect(mongoUri);
|
||||
|
|
@ -59,6 +61,7 @@ afterEach(async () => {
|
|||
await Conversation.deleteMany({});
|
||||
await Message.deleteMany({});
|
||||
await SharedLink.deleteMany({});
|
||||
await File.deleteMany({});
|
||||
});
|
||||
|
||||
async function createConversation(user: string, conversationId: string, title: string) {
|
||||
|
|
@ -304,7 +307,7 @@ describe('ChatProject methods', () => {
|
|||
expect(deleteResult.deletedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('forces ephemeral retention on a permanent chat, its messages, and shares when assigning', async () => {
|
||||
it('forces ephemeral retention on a permanent chat, its messages, shares, and files when assigning', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Ephemeral' });
|
||||
await createConversation(user, 'convo-1', 'Permanent');
|
||||
await Message.create([
|
||||
|
|
@ -312,6 +315,13 @@ describe('ChatProject methods', () => {
|
|||
{ messageId: uuidv4(), conversationId: 'convo-1', user, text: 'second' },
|
||||
]);
|
||||
await SharedLink.create({ conversationId: 'convo-1', user, shareId: uuidv4() });
|
||||
const fileId = uuidv4();
|
||||
await File.collection.insertOne({
|
||||
file_id: fileId,
|
||||
conversationId: 'convo-1',
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
expiredAt: null,
|
||||
});
|
||||
|
||||
const result = await methods.assignConversationToProject(
|
||||
user,
|
||||
|
|
@ -340,6 +350,9 @@ describe('ChatProject methods', () => {
|
|||
|
||||
const share = await SharedLink.findOne({ user, conversationId: 'convo-1' }).lean<ISharedLink>();
|
||||
expect(share?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const file = await File.findOne({ file_id: fileId }).lean<IMongoFile>();
|
||||
expect(file?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('forces ephemeral retention when removing a chat from its project', async () => {
|
||||
|
|
@ -380,6 +393,13 @@ describe('ChatProject methods', () => {
|
|||
await methods.assignConversationToProject(user, 'convo-1', projectId);
|
||||
await methods.assignConversationToProject(user, 'convo-2', projectId);
|
||||
await Message.create({ messageId: uuidv4(), conversationId: 'convo-1', user, text: 'hi' });
|
||||
const fileId = uuidv4();
|
||||
await File.collection.insertOne({
|
||||
file_id: fileId,
|
||||
conversationId: 'convo-1',
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
expiredAt: null,
|
||||
});
|
||||
|
||||
await methods.deleteChatProject(user, projectId, ephemeralConfig);
|
||||
|
||||
|
|
@ -397,5 +417,8 @@ describe('ChatProject methods', () => {
|
|||
const message = await Message.findOne({ user, conversationId: 'convo-1' }).lean<IMessage>();
|
||||
expect(message?.isTemporary).toBe(true);
|
||||
expect(message?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const file = await File.findOne({ file_id: fileId }).lean<IMongoFile>();
|
||||
expect(file?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
IChatProjectDocument,
|
||||
IConversation,
|
||||
IMessage,
|
||||
IMongoFile,
|
||||
ISharedLink,
|
||||
} from '~/types';
|
||||
import {
|
||||
|
|
@ -287,10 +288,12 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
|
|||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
return cascadeForcedRetentionByProject(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
user,
|
||||
chatProjectId,
|
||||
resolveForcedRetentionDate(interfaceConfig),
|
||||
|
|
@ -308,10 +311,12 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
|
|||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
return cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
user,
|
||||
conversationId,
|
||||
resolveForcedRetentionDate(interfaceConfig),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider';
|
||||
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
|
||||
import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types';
|
||||
import type { AppConfig, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types';
|
||||
import {
|
||||
capForcedRetentionExpiry,
|
||||
capForcedRetentionToParent,
|
||||
|
|
@ -213,10 +213,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
if (isForcedRetention && cascadeExpiredAt instanceof Date) {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
await cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
conversationId,
|
||||
cascadeExpiredAt,
|
||||
|
|
@ -409,6 +411,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
|
||||
if (metadata?.capExpiryToConversation === true) {
|
||||
forcedExpiredAt = await capForcedRetentionToParent(
|
||||
|
|
@ -435,6 +438,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
conversationId,
|
||||
forcedExpiredAt,
|
||||
|
|
@ -475,11 +479,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
|
||||
await cascadeForcedRetentionByTag(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
tag,
|
||||
forcedExpiredAt,
|
||||
|
|
|
|||
|
|
@ -219,14 +219,15 @@ export const capForcedRetentionToParent = async (
|
|||
|
||||
/**
|
||||
* Converts or re-caps a parent conversation to the forced deadline and, when that first
|
||||
* brings the conversation into the forced window, backfills its lagging messages. Shared
|
||||
* by every forced-retention message-write path so a single conversation/message rule is
|
||||
* enforced regardless of which save touched the chat.
|
||||
* brings the conversation into the forced window, backfills its lagging messages, shares, and
|
||||
* files. Shared by every forced-retention message-write path so a single conversation/message
|
||||
* rule is enforced regardless of which save touched the chat.
|
||||
*/
|
||||
export const cascadeForcedConversationRetention = async (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
|
|
@ -243,6 +244,7 @@ export const cascadeForcedConversationRetention = async (
|
|||
if (convoResult.modifiedCount > 0) {
|
||||
await forceConversationMessagesTemporary(Message, userId, conversationId, expiredAt);
|
||||
await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt);
|
||||
await capConversationFiles(File, conversationId, expiredAt);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -252,13 +254,14 @@ export const cascadeForcedConversationRetention = async (
|
|||
* (`Conversation.updateMany`) without setting `isTemporary`/`expiredAt` would otherwise leave a
|
||||
* permanent chat visible and non-expiring after an install switched to ephemeral. Chats are
|
||||
* bucketed by their capped deadline so each bucket converts the chats, backfills their messages,
|
||||
* and caps their shares in one pass; the gap filter keeps it a no-op for chats that already
|
||||
* conform and never extends a chat that already expires sooner.
|
||||
* and caps their shares and files in one pass; the gap filter keeps it a no-op for chats that
|
||||
* already conform and never extends a chat that already expires sooner.
|
||||
*/
|
||||
const cascadeForcedRetentionForConversationSet = async (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
conversationMatch: FilterQuery<IConversation>,
|
||||
forcedExpiredAt: Date,
|
||||
|
|
@ -319,6 +322,13 @@ const cascadeForcedRetentionForConversationSet = async (
|
|||
},
|
||||
{ $set: { expiredAt } },
|
||||
);
|
||||
await File.updateMany(
|
||||
{
|
||||
conversationId: { $in: conversationIds },
|
||||
$or: [{ expiredAt: null }, { expiredAt: { $gt: expiredAt } }],
|
||||
},
|
||||
{ $set: { expiredAt } },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -332,6 +342,7 @@ export const cascadeForcedRetentionByTag = (
|
|||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
tag: string,
|
||||
forcedExpiredAt: Date,
|
||||
|
|
@ -340,6 +351,7 @@ export const cascadeForcedRetentionByTag = (
|
|||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
{ tags: tag } as FilterQuery<IConversation>,
|
||||
forcedExpiredAt,
|
||||
|
|
@ -355,6 +367,7 @@ export const cascadeForcedRetentionByProject = (
|
|||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
chatProjectId: string,
|
||||
forcedExpiredAt: Date,
|
||||
|
|
@ -363,6 +376,7 @@ export const cascadeForcedRetentionByProject = (
|
|||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
{ chatProjectId } as FilterQuery<IConversation>,
|
||||
forcedExpiredAt,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue