mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 16:23:44 +00:00
fix: enforce ephemeral retention on chat project membership changes
Project membership writes bypassed forced retention: assigning a chat to a project, removing it, or deleting the project rewrote the conversation row (chatProjectId) without setting isTemporary/expiredAt. On a deployment that switched to ephemeral mode, touching an older permanent chat this way left it visible and non-expiring. Load the interface config on the assign and delete project routes and cascade forced retention through the touched conversation(s): assign/remove caps the single conversation, its messages, and its shares; delete caps every member chat before unassigning. Extract the bucketed tag cascade into a shared helper so the project cascade reuses the same conversion/backfill/cap logic, and add a shared resolveForcedRetentionDate helper. All paths are a no-op outside forced retention, so non-ephemeral behavior is unchanged.
This commit is contained in:
parent
cc44aa402f
commit
38057713ce
5 changed files with 274 additions and 24 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { isValidObjectIdString, logger } from '@librechat/data-schemas';
|
||||
|
||||
import type {
|
||||
AppConfig,
|
||||
ChatProjectMethods,
|
||||
ChatProjectSortBy,
|
||||
ChatProjectSortDirection,
|
||||
|
|
@ -23,6 +24,7 @@ interface ProjectUser {
|
|||
|
||||
interface ProjectRequest extends Request {
|
||||
user?: ProjectUser;
|
||||
config?: AppConfig;
|
||||
}
|
||||
|
||||
type ProjectHandlerDependencies = Pick<
|
||||
|
|
@ -139,6 +141,7 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): {
|
|||
getUserId(req),
|
||||
conversationId,
|
||||
projectId,
|
||||
req.config?.interfaceConfig,
|
||||
);
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
|
||||
|
|
@ -208,7 +211,11 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await deps.deleteChatProject(getUserId(req), projectId);
|
||||
const result = await deps.deleteChatProject(
|
||||
getUserId(req),
|
||||
projectId,
|
||||
req.config?.interfaceConfig,
|
||||
);
|
||||
if (!result.deletedCount) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { createModels } from '~/models';
|
||||
import type { IChatProject, IConversation } from '~/types';
|
||||
import type { IChatProject, IConversation, IMessage, ISharedLink } from '~/types';
|
||||
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
|
||||
import { createModels } from '~/models';
|
||||
|
||||
const ephemeralConfig = {
|
||||
temporaryChatRetention: 24,
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
};
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
|
|
@ -14,6 +21,8 @@ jest.mock('~/config/winston', () => ({
|
|||
let mongoServer: InstanceType<typeof MongoMemoryServer>;
|
||||
let ChatProject: mongoose.Model<IChatProject>;
|
||||
let Conversation: mongoose.Model<IConversation>;
|
||||
let Message: mongoose.Model<IMessage>;
|
||||
let SharedLink: mongoose.Model<ISharedLink>;
|
||||
let methods: ChatProjectMethods;
|
||||
let modelsToCleanup: string[] = [];
|
||||
|
||||
|
|
@ -27,6 +36,8 @@ beforeAll(async () => {
|
|||
|
||||
ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
|
||||
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
Message = mongoose.models.Message as mongoose.Model<IMessage>;
|
||||
SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
methods = createChatProjectMethods(mongoose);
|
||||
|
||||
await mongoose.connect(mongoUri);
|
||||
|
|
@ -46,6 +57,8 @@ afterAll(async () => {
|
|||
afterEach(async () => {
|
||||
await ChatProject.deleteMany({});
|
||||
await Conversation.deleteMany({});
|
||||
await Message.deleteMany({});
|
||||
await SharedLink.deleteMany({});
|
||||
});
|
||||
|
||||
async function createConversation(user: string, conversationId: string, title: string) {
|
||||
|
|
@ -290,4 +303,95 @@ describe('ChatProject methods', () => {
|
|||
expect(assignment).toBeNull();
|
||||
expect(deleteResult.deletedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('forces ephemeral retention on a permanent chat, its messages, and shares when assigning', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Ephemeral' });
|
||||
await createConversation(user, 'convo-1', 'Permanent');
|
||||
await Message.create([
|
||||
{ messageId: uuidv4(), conversationId: 'convo-1', user, text: 'first' },
|
||||
{ messageId: uuidv4(), conversationId: 'convo-1', user, text: 'second' },
|
||||
]);
|
||||
await SharedLink.create({ conversationId: 'convo-1', user, shareId: uuidv4() });
|
||||
|
||||
await methods.assignConversationToProject(
|
||||
user,
|
||||
'convo-1',
|
||||
project._id!.toString(),
|
||||
ephemeralConfig,
|
||||
);
|
||||
|
||||
const conversation = await Conversation.findOne({
|
||||
user,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
expect(conversation?.isTemporary).toBe(true);
|
||||
expect(conversation?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const messages = await Message.find({ user, conversationId: 'convo-1' }).lean<IMessage[]>();
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
|
||||
const share = await SharedLink.findOne({ user, conversationId: 'convo-1' }).lean<ISharedLink>();
|
||||
expect(share?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('forces ephemeral retention when removing a chat from its project', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Ephemeral' });
|
||||
await createConversation(user, 'convo-1', 'Permanent');
|
||||
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
|
||||
|
||||
await methods.assignConversationToProject(user, 'convo-1', null, ephemeralConfig);
|
||||
|
||||
const conversation = await Conversation.findOne({
|
||||
user,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
expect(conversation?.chatProjectId).toBeUndefined();
|
||||
expect(conversation?.isTemporary).toBe(true);
|
||||
expect(conversation?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('leaves retention untouched when assigning outside ephemeral mode', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Standard' });
|
||||
await createConversation(user, 'convo-1', 'Permanent');
|
||||
|
||||
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
|
||||
|
||||
const conversation = await Conversation.findOne({
|
||||
user,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
expect(conversation?.isTemporary ?? null).not.toBe(true);
|
||||
expect(conversation?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('forces ephemeral retention on member chats when deleting a project', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Ephemeral' });
|
||||
const projectId = project._id!.toString();
|
||||
await createConversation(user, 'convo-1', 'First');
|
||||
await createConversation(user, 'convo-2', 'Second');
|
||||
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' });
|
||||
|
||||
await methods.deleteChatProject(user, projectId, ephemeralConfig);
|
||||
|
||||
const conversations = await Conversation.find({
|
||||
user,
|
||||
conversationId: { $in: ['convo-1', 'convo-2'] },
|
||||
}).lean<IConversation[]>();
|
||||
expect(conversations).toHaveLength(2);
|
||||
for (const conversation of conversations) {
|
||||
expect(conversation.chatProjectId).toBeUndefined();
|
||||
expect(conversation.isTemporary).toBe(true);
|
||||
expect(conversation.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
|
||||
const message = await Message.findOne({ user, conversationId: 'convo-1' }).lean<IMessage>();
|
||||
expect(message?.isTemporary).toBe(true);
|
||||
expect(message?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
import { isForcedTemporaryRetention } from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
|
||||
import logger from '~/config/winston';
|
||||
import type {
|
||||
AppConfig,
|
||||
IChatProject,
|
||||
IChatProjectDocument,
|
||||
IConversation,
|
||||
IMessage,
|
||||
ISharedLink,
|
||||
} from '~/types';
|
||||
import {
|
||||
buildRetentionVisibilityFilter,
|
||||
cascadeForcedConversationRetention,
|
||||
cascadeForcedRetentionByProject,
|
||||
resolveForcedRetentionDate,
|
||||
} from '~/utils/retention';
|
||||
import { isValidObjectIdString } from '~/utils/objectId';
|
||||
import { buildRetentionVisibilityFilter } from '~/utils/retention';
|
||||
import { escapeRegExp } from '~/utils/string';
|
||||
import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
|
||||
export type ChatProjectSortDirection = 'asc' | 'desc';
|
||||
|
|
@ -51,11 +64,16 @@ export interface ChatProjectMethods {
|
|||
projectId: string,
|
||||
input: UpdateChatProjectInput,
|
||||
): Promise<IChatProject | null>;
|
||||
deleteChatProject(user: string, projectId: string): Promise<DeleteChatProjectResult>;
|
||||
deleteChatProject(
|
||||
user: string,
|
||||
projectId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<DeleteChatProjectResult>;
|
||||
assignConversationToProject(
|
||||
user: string,
|
||||
conversationId: string,
|
||||
projectId: string | null,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<AssignConversationToProjectResult | null>;
|
||||
refreshChatProjectStats(user: string, projectId: string): Promise<IChatProject | null>;
|
||||
}
|
||||
|
|
@ -252,6 +270,54 @@ export async function updateChatProjectLastConversationForUser(
|
|||
}
|
||||
|
||||
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
|
||||
/**
|
||||
* Converts a project's conversations to the forced (ephemeral) window when the deployment runs
|
||||
* in ephemeral mode. Assigning, removing, or bulk-unassigning a chat rewrites its row without
|
||||
* setting `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched
|
||||
* to ephemeral would otherwise stay visible and never expire. A no-op outside forced retention.
|
||||
*/
|
||||
function forceProjectConversationRetention(
|
||||
user: string,
|
||||
chatProjectId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<void> {
|
||||
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
return cascadeForcedRetentionByProject(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
user,
|
||||
chatProjectId,
|
||||
resolveForcedRetentionDate(interfaceConfig),
|
||||
);
|
||||
}
|
||||
|
||||
function forceConversationRetention(
|
||||
user: string,
|
||||
conversationId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<void> {
|
||||
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
return cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
user,
|
||||
conversationId,
|
||||
resolveForcedRetentionDate(interfaceConfig),
|
||||
);
|
||||
}
|
||||
|
||||
async function createChatProject(
|
||||
user: string,
|
||||
input: CreateChatProjectInput,
|
||||
|
|
@ -360,6 +426,7 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
|
|||
async function deleteChatProject(
|
||||
user: string,
|
||||
projectId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<DeleteChatProjectResult> {
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return { deletedCount: 0, modifiedCount: 0 };
|
||||
|
|
@ -373,6 +440,8 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
|
|||
return { deletedCount: 0, modifiedCount: 0 };
|
||||
}
|
||||
|
||||
await forceProjectConversationRetention(user, projectId, interfaceConfig);
|
||||
|
||||
const [conversationResult, deleteResult] = await Promise.all([
|
||||
Conversation.updateMany(
|
||||
{ user, chatProjectId: projectId },
|
||||
|
|
@ -391,6 +460,7 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
|
|||
user: string,
|
||||
conversationId: string,
|
||||
projectId: string | null,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<AssignConversationToProjectResult | null> {
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
|
|
@ -430,6 +500,8 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
|
|||
return null;
|
||||
}
|
||||
|
||||
await forceConversationRetention(user, conversationId, interfaceConfig);
|
||||
|
||||
const projectIds = new Set(
|
||||
[previousProjectId, normalizedProjectId].filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { FilterQuery, Model } from 'mongoose';
|
||||
import type { IConversation, IMessage, ISharedLink } from '~/types';
|
||||
import { DEFAULT_RETENTION_HOURS } from './tempChatRetention';
|
||||
import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types';
|
||||
import { createTempChatExpirationDate, DEFAULT_RETENTION_HOURS } from './tempChatRetention';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
export type RetentionFilterDocument = {
|
||||
isTemporary?: boolean | null;
|
||||
|
|
@ -32,6 +33,21 @@ export const buildRetentionVisibilityFilter = <
|
|||
export const createFallbackRetentionDate = (now: number = Date.now()): Date =>
|
||||
new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Resolves the forced-retention deadline from the interface config, falling back to the default
|
||||
* window when the configured retention hours cannot be computed.
|
||||
*/
|
||||
export const resolveForcedRetentionDate = (
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Date => {
|
||||
try {
|
||||
return createTempChatExpirationDate(interfaceConfig);
|
||||
} catch (err) {
|
||||
logger.error('Error creating forced retention expiration date:', err);
|
||||
return createFallbackRetentionDate();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches retention documents that do not yet conform to a forced (ephemeral) deadline:
|
||||
* not temporary, missing an expiration, or expiring later than the forced window. The
|
||||
|
|
@ -204,31 +220,32 @@ export const cascadeForcedConversationRetention = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename
|
||||
* or delete writes conversation rows directly (`Conversation.updateMany`) without setting
|
||||
* `isTemporary`/`expiredAt`, so a permanent chat tagged before the install switched to
|
||||
* ephemeral would otherwise stay visible and never expire. One pass converts the chats,
|
||||
* backfills their messages, and caps their shares; the gap filter keeps it a no-op for chats
|
||||
* that already conform and never extends a chat that already expires sooner.
|
||||
* Bulk-applies forced retention to the user's conversations selected by `conversationMatch`
|
||||
* (a bookmark tag, a chat project, etc.). Writes that touch these rows directly
|
||||
* (`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.
|
||||
*/
|
||||
export const cascadeForcedRetentionByTag = async (
|
||||
const cascadeForcedRetentionForConversationSet = async (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
tag: string,
|
||||
conversationMatch: FilterQuery<IConversation>,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> => {
|
||||
const taggedConversations = await Conversation.find(
|
||||
{ user: userId, tags: tag },
|
||||
const conversations = await Conversation.find(
|
||||
{ user: userId, ...conversationMatch } as FilterQuery<IConversation>,
|
||||
'conversationId isTemporary expiredAt',
|
||||
).lean<Array<RetentionFilterDocument & { conversationId?: string }>>();
|
||||
if (taggedConversations.length === 0) {
|
||||
if (conversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retentionBuckets = new Map<number, { expiredAt: Date; conversationIds: string[] }>();
|
||||
for (const convo of taggedConversations) {
|
||||
for (const convo of conversations) {
|
||||
if (typeof convo.conversationId !== 'string' || convo.conversationId.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -278,6 +295,52 @@ export const cascadeForcedRetentionByTag = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename or
|
||||
* delete writes conversation rows directly without setting `isTemporary`/`expiredAt`, so a
|
||||
* permanent chat tagged before the install switched to ephemeral would otherwise stay visible
|
||||
* and never expire.
|
||||
*/
|
||||
export const cascadeForcedRetentionByTag = (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
tag: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> =>
|
||||
cascadeForcedRetentionForConversationSet(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
userId,
|
||||
{ tags: tag } as FilterQuery<IConversation>,
|
||||
forcedExpiredAt,
|
||||
);
|
||||
|
||||
/**
|
||||
* Bulk-applies forced retention to every conversation in a chat project. Assigning a chat to a
|
||||
* project, removing it, or deleting the project rewrites conversation rows without setting
|
||||
* `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched to
|
||||
* ephemeral would otherwise stay visible and never expire.
|
||||
*/
|
||||
export const cascadeForcedRetentionByProject = (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
chatProjectId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> =>
|
||||
cascadeForcedRetentionForConversationSet(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
userId,
|
||||
{ chatProjectId } as FilterQuery<IConversation>,
|
||||
forcedExpiredAt,
|
||||
);
|
||||
|
||||
/**
|
||||
* One-time backfill of forced (ephemeral) retention over pre-existing data. Convert-on-touch
|
||||
* only converts conversations that are subsequently written, so enabling ephemeral mode on a
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue