mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-03 04:42:11 +00:00
fix: cap existing shared links when forced retention converts a conversation
A SharedLink embeds a snapshot of the conversation (message refs and file snapshots) and its TTL index keys off expiredAt alone, while shared-link reads use activeExpirationFilter. So a permanent share (expiredAt null) created before an install switched to ephemeral stayed publicly readable indefinitely after the forced-temporary conversation and messages TTL out. Add capConversationSharedLinks and call it wherever forced retention converts a conversation - saveConvo's backfill, the shared cascade, and the cap-to-parent path - so existing shares with no expiration or a later one are capped to the forced deadline and expire with the conversation.
This commit is contained in:
parent
da5b1e222b
commit
13d677c4ac
5 changed files with 102 additions and 5 deletions
|
|
@ -2,7 +2,7 @@ import mongoose from 'mongoose';
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
|
||||
import type { IChatProject, IConversation, IMessage } from '../types';
|
||||
import type { IChatProject, IConversation, IMessage, ISharedLink } from '../types';
|
||||
import { ConversationMethods, createConversationMethods } from './conversation';
|
||||
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
|
||||
import { createModels } from '../models';
|
||||
|
|
@ -984,6 +984,35 @@ describe('Conversation Operations', () => {
|
|||
const reloaded = await Message().findById(lateMessage._id).lean();
|
||||
expect(reloaded?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('caps an existing permanent shared link when ephemeral converts a conversation', async () => {
|
||||
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
await SharedLink.deleteMany({});
|
||||
const conversationId = uuidv4();
|
||||
await Conversation.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
const share = await SharedLink.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
shareId: uuidv4(),
|
||||
});
|
||||
expect(share.expiredAt ?? null).toBeNull();
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
const reloaded = await SharedLink.findOne({ conversationId }).lean();
|
||||
expect(reloaded?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchConversation', () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, SortOrder } from 'mongoose';
|
||||
import type { DeleteResult } from 'mongoose';
|
||||
import type { AppConfig, IChatProjectDocument, IConversation, IMessage } from '~/types';
|
||||
import type {
|
||||
AppConfig,
|
||||
IChatProjectDocument,
|
||||
IConversation,
|
||||
IMessage,
|
||||
ISharedLink,
|
||||
} from '~/types';
|
||||
import type { MessageMethods } from './message';
|
||||
import {
|
||||
buildRetentionVisibilityFilter,
|
||||
capConversationSharedLinks,
|
||||
conversationNeedsForcedRetention,
|
||||
createFallbackRetentionDate,
|
||||
forceConversationMessagesTemporary,
|
||||
|
|
@ -350,7 +357,9 @@ export function createConversationMethods(
|
|||
conversationNeedsForcedRetention(parentRetention, forcedExpiredAt)
|
||||
) {
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt);
|
||||
await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt);
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -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 { IConversation, IMessage } from '..';
|
||||
import type { IConversation, IMessage, ISharedLink } from '..';
|
||||
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
|
||||
import { createMessageMethods } from './message';
|
||||
import { createModels } from '../models';
|
||||
|
|
@ -978,6 +978,31 @@ describe('Message Operations', () => {
|
|||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('caps an existing permanent shared link when converting via a message save', async () => {
|
||||
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
await SharedLink.deleteMany({});
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await SharedLink.create({ conversationId, user: 'user123', shareId: uuidv4() });
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch', capExpiryToConversation: true },
|
||||
);
|
||||
|
||||
const reloaded = await SharedLink.findOne({ conversationId }).lean();
|
||||
expect(reloaded?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyForcedRetention', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider';
|
||||
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
|
||||
import type { AppConfig, IConversation, IMessage } from '~/types';
|
||||
import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types';
|
||||
import {
|
||||
capForcedRetentionToParent,
|
||||
cascadeForcedConversationRetention,
|
||||
|
|
@ -167,9 +167,11 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
metadata?.capExpiryToConversation === true
|
||||
) {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
update.expiredAt = await capForcedRetentionToParent(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
userId,
|
||||
conversationId,
|
||||
forcedExpiredAt,
|
||||
|
|
@ -197,9 +199,11 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
|
||||
if (isForcedRetention && forcedExpiredAt instanceof Date) {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
await cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
userId,
|
||||
conversationId,
|
||||
forcedExpiredAt,
|
||||
|
|
@ -384,11 +388,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>;
|
||||
|
||||
if (metadata?.capExpiryToConversation === true) {
|
||||
forcedExpiredAt = await capForcedRetentionToParent(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
userId,
|
||||
conversationId,
|
||||
forcedExpiredAt,
|
||||
|
|
@ -402,6 +408,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
await cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
userId,
|
||||
conversationId,
|
||||
forcedExpiredAt,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { FilterQuery, Model } from 'mongoose';
|
||||
import type { IConversation, IMessage } from '~/types';
|
||||
import type { IConversation, IMessage, ISharedLink } from '~/types';
|
||||
import { DEFAULT_RETENTION_HOURS } from './tempChatRetention';
|
||||
|
||||
export type RetentionFilterDocument = {
|
||||
|
|
@ -91,6 +91,29 @@ export const forceConversationMessagesTemporary = async (
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps a conversation's shared links to the forced deadline. A share embeds a snapshot of
|
||||
* the conversation (message refs and file snapshots) and its TTL index keys off `expiredAt`
|
||||
* alone, so a permanent share (`expiredAt: null`) created before forced retention would stay
|
||||
* publicly readable after the conversation and messages expire. Only links with no
|
||||
* expiration or a later one are touched, so it is a no-op once a conversation conforms.
|
||||
*/
|
||||
export const capConversationSharedLinks = async (
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> => {
|
||||
await SharedLink.updateMany(
|
||||
{
|
||||
conversationId,
|
||||
user: userId,
|
||||
$or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }],
|
||||
},
|
||||
{ $set: { expiredAt: forcedExpiredAt } },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps a message-only forced save to a parent that already expires sooner than the freshly
|
||||
* computed window. Returns the parent's earlier deadline (so the message cannot outlive it)
|
||||
|
|
@ -102,6 +125,7 @@ export const forceConversationMessagesTemporary = async (
|
|||
export const capForcedRetentionToParent = async (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
|
|
@ -116,6 +140,7 @@ export const capForcedRetentionToParent = async (
|
|||
parent.expiredAt.getTime() < forcedExpiredAt.getTime()
|
||||
) {
|
||||
await forceConversationMessagesTemporary(Message, userId, conversationId, parent.expiredAt);
|
||||
await capConversationSharedLinks(SharedLink, userId, conversationId, parent.expiredAt);
|
||||
return parent.expiredAt;
|
||||
}
|
||||
return forcedExpiredAt;
|
||||
|
|
@ -130,6 +155,7 @@ export const capForcedRetentionToParent = async (
|
|||
export const cascadeForcedConversationRetention = async (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
|
|
@ -140,5 +166,6 @@ export const cascadeForcedConversationRetention = async (
|
|||
);
|
||||
if (convoResult.modifiedCount > 0) {
|
||||
await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt);
|
||||
await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue