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:
Marco Beretta 2026-07-04 16:51:41 +02:00
parent fb095b9241
commit 8bcfb0772c
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 56 additions and 8 deletions

View file

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

View file

@ -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),

View file

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

View file

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