From 8bcfb0772c67cdd1713ef7ec6aa43f40dfc0d94a Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:51:41 +0200 Subject: [PATCH] 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. --- .../src/methods/chatProject.spec.ts | 27 +++++++++++++++++-- .../data-schemas/src/methods/chatProject.ts | 5 ++++ packages/data-schemas/src/methods/message.ts | 8 +++++- packages/data-schemas/src/utils/retention.ts | 24 +++++++++++++---- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/data-schemas/src/methods/chatProject.spec.ts b/packages/data-schemas/src/methods/chatProject.spec.ts index 53f283a5a0..f21a075665 100644 --- a/packages/data-schemas/src/methods/chatProject.spec.ts +++ b/packages/data-schemas/src/methods/chatProject.spec.ts @@ -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; let Conversation: mongoose.Model; let Message: mongoose.Model; let SharedLink: mongoose.Model; +let File: mongoose.Model; let methods: ChatProjectMethods; let modelsToCleanup: string[] = []; @@ -38,6 +39,7 @@ beforeAll(async () => { Conversation = mongoose.models.Conversation as mongoose.Model; Message = mongoose.models.Message as mongoose.Model; SharedLink = mongoose.models.SharedLink as mongoose.Model; + File = mongoose.models.File as mongoose.Model; 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(); expect(share?.expiredAt).toBeInstanceOf(Date); + + const file = await File.findOne({ file_id: fileId }).lean(); + 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(); expect(message?.isTemporary).toBe(true); expect(message?.expiredAt).toBeInstanceOf(Date); + + const file = await File.findOne({ file_id: fileId }).lean(); + expect(file?.expiredAt).toBeInstanceOf(Date); }); }); diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts index 45772512d7..d2a98036c7 100644 --- a/packages/data-schemas/src/methods/chatProject.ts +++ b/packages/data-schemas/src/methods/chatProject.ts @@ -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; const Message = mongoose.models.Message as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; 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; const Message = mongoose.models.Message as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; return cascadeForcedConversationRetention( Conversation, Message, SharedLink, + File, user, conversationId, resolveForcedRetentionDate(interfaceConfig), diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index c666b818cc..f2ec44b0df 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -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; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; 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; const Conversation = mongoose.models.Conversation as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; 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; const Conversation = mongoose.models.Conversation as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; await cascadeForcedRetentionByTag( Conversation, Message, SharedLink, + File, userId, tag, forcedExpiredAt, diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 946d6244f6..76d2562a5c 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -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, Message: Model, SharedLink: Model, + File: Model, 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, Message: Model, SharedLink: Model, + File: Model, userId: string, conversationMatch: FilterQuery, 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, Message: Model, SharedLink: Model, + File: Model, userId: string, tag: string, forcedExpiredAt: Date, @@ -340,6 +351,7 @@ export const cascadeForcedRetentionByTag = ( Conversation, Message, SharedLink, + File, userId, { tags: tag } as FilterQuery, forcedExpiredAt, @@ -355,6 +367,7 @@ export const cascadeForcedRetentionByProject = ( Conversation: Model, Message: Model, SharedLink: Model, + File: Model, userId: string, chatProjectId: string, forcedExpiredAt: Date, @@ -363,6 +376,7 @@ export const cascadeForcedRetentionByProject = ( Conversation, Message, SharedLink, + File, userId, { chatProjectId } as FilterQuery, forcedExpiredAt,