mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 20:32:58 +00:00
fix: enforce forced retention on global tag rename and delete
Per-conversation tag writes already convert their conversation under forced ephemeral retention, but global bookmark-tag renames and deletes rewrite conversation rows via Conversation.updateMany without setting isTemporary/expiredAt. A permanent chat tagged before the install switched to ephemeral, touched only by a tag rename or delete, would keep its retention fields unset and stay visible and non-expiring. Add cascadeForcedRetentionByTag plus an applyForcedRetentionToTag method that bulk-converts every conversation carrying a tag (and backfills its messages and caps its shares) through the shared gap filter, so it never extends a chat that already expires sooner. Load the interface config on PUT /:tag and DELETE /:tag and route those writes through it.
This commit is contained in:
parent
339520fb90
commit
e764c983cf
4 changed files with 210 additions and 2 deletions
|
|
@ -9,6 +9,7 @@ const {
|
|||
deleteConversationTag,
|
||||
getConversationTags,
|
||||
applyForcedRetention,
|
||||
applyForcedRetentionToTag,
|
||||
getRoleByName,
|
||||
} = require('~/models');
|
||||
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
|
||||
|
|
@ -35,6 +36,18 @@ const enforceForcedRetention = (req, conversationId, context) =>
|
|||
{ context },
|
||||
);
|
||||
|
||||
/**
|
||||
* Enforces forced (ephemeral) retention on every conversation carrying a tag, for global
|
||||
* tag renames/deletes that rewrite conversation rows without converting them; a no-op
|
||||
* outside forced retention.
|
||||
*/
|
||||
const enforceForcedRetentionForTag = (req, tag, context) =>
|
||||
applyForcedRetentionToTag(
|
||||
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
|
||||
{ tag },
|
||||
{ context },
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* Retrieves all conversation tags for the authenticated user.
|
||||
|
|
@ -80,11 +93,12 @@ router.post('/', configMiddleware, async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.put('/:tag', async (req, res) => {
|
||||
router.put('/:tag', configMiddleware, async (req, res) => {
|
||||
try {
|
||||
const decodedTag = decodeURIComponent(req.params.tag);
|
||||
const tag = await updateConversationTag(req.user.id, decodedTag, req.body);
|
||||
if (tag) {
|
||||
await enforceForcedRetentionForTag(req, req.body?.tag || decodedTag, 'PUT /api/tags/:tag');
|
||||
res.status(200).json(tag);
|
||||
} else {
|
||||
res.status(404).json({ error: 'Tag not found' });
|
||||
|
|
@ -101,9 +115,10 @@ router.put('/:tag', async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.delete('/:tag', async (req, res) => {
|
||||
router.delete('/:tag', configMiddleware, async (req, res) => {
|
||||
try {
|
||||
const decodedTag = decodeURIComponent(req.params.tag);
|
||||
await enforceForcedRetentionForTag(req, decodedTag, 'DELETE /api/tags/:tag');
|
||||
const tag = await deleteConversationTag(req.user.id, decodedTag);
|
||||
if (tag) {
|
||||
res.status(200).json(tag);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ let saveMessage: ReturnType<typeof createMessageMethods>['saveMessage'];
|
|||
let getMessages: ReturnType<typeof createMessageMethods>['getMessages'];
|
||||
let updateMessage: ReturnType<typeof createMessageMethods>['updateMessage'];
|
||||
let applyForcedRetention: ReturnType<typeof createMessageMethods>['applyForcedRetention'];
|
||||
let applyForcedRetentionToTag: ReturnType<typeof createMessageMethods>['applyForcedRetentionToTag'];
|
||||
let deleteMessages: ReturnType<typeof createMessageMethods>['deleteMessages'];
|
||||
let bulkSaveMessages: ReturnType<typeof createMessageMethods>['bulkSaveMessages'];
|
||||
let updateMessageText: ReturnType<typeof createMessageMethods>['updateMessageText'];
|
||||
|
|
@ -42,6 +43,7 @@ beforeAll(async () => {
|
|||
getMessages = methods.getMessages;
|
||||
updateMessage = methods.updateMessage;
|
||||
applyForcedRetention = methods.applyForcedRetention;
|
||||
applyForcedRetentionToTag = methods.applyForcedRetentionToTag;
|
||||
deleteMessages = methods.deleteMessages;
|
||||
bulkSaveMessages = methods.bulkSaveMessages;
|
||||
updateMessageText = methods.updateMessageText;
|
||||
|
|
@ -1149,6 +1151,101 @@ describe('Message Operations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('applyForcedRetentionToTag', () => {
|
||||
const Conversation = () => mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await Conversation().deleteMany({});
|
||||
});
|
||||
|
||||
it('converts every permanent conversation carrying the tag under ephemeral mode', async () => {
|
||||
const taggedA = uuidv4();
|
||||
const taggedB = uuidv4();
|
||||
const untagged = uuidv4();
|
||||
await Conversation().create([
|
||||
{ conversationId: taggedA, user: 'user123', endpoint: 'openAI', tags: ['work'] },
|
||||
{ conversationId: taggedB, user: 'user123', endpoint: 'openAI', tags: ['work', 'urgent'] },
|
||||
{ conversationId: untagged, user: 'user123', endpoint: 'openAI', tags: ['personal'] },
|
||||
]);
|
||||
await Message.create([
|
||||
{ messageId: uuidv4(), conversationId: taggedA, user: 'user123', text: 'a' },
|
||||
{ messageId: uuidv4(), conversationId: taggedB, user: 'user123', text: 'b' },
|
||||
{ messageId: uuidv4(), conversationId: untagged, user: 'user123', text: 'c' },
|
||||
]);
|
||||
|
||||
await applyForcedRetentionToTag(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ tag: 'work' },
|
||||
{ context: 'DELETE /api/tags/:tag' },
|
||||
);
|
||||
|
||||
for (const conversationId of [taggedA, taggedB]) {
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt).toBeInstanceOf(Date);
|
||||
const messages = await getMessages({ conversationId, user: 'user123' });
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
}
|
||||
|
||||
const untouched = await Conversation().findOne({ conversationId: untagged }).lean();
|
||||
expect(untouched?.isTemporary ?? null).not.toBe(true);
|
||||
expect(untouched?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('does not extend a tagged conversation that already expires sooner', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const soonerExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
tags: ['work'],
|
||||
isTemporary: true,
|
||||
expiredAt: soonerExpiry,
|
||||
});
|
||||
|
||||
await applyForcedRetentionToTag(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ tag: 'work' },
|
||||
{ context: 'PUT /api/tags/:tag' },
|
||||
);
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
|
||||
});
|
||||
|
||||
it('is a no-op outside forced retention', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
tags: ['work'],
|
||||
});
|
||||
|
||||
await applyForcedRetentionToTag(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.ALL },
|
||||
},
|
||||
{ tag: 'work' },
|
||||
{ context: 'PUT /api/tags/:tag' },
|
||||
);
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message cursor pagination', () => {
|
||||
/**
|
||||
* Helper to create messages with specific timestamps
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types';
|
|||
import {
|
||||
capForcedRetentionToParent,
|
||||
cascadeForcedConversationRetention,
|
||||
cascadeForcedRetentionByTag,
|
||||
createFallbackRetentionDate,
|
||||
} from '~/utils/retention';
|
||||
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
|
||||
|
|
@ -47,6 +48,11 @@ export interface MessageMethods {
|
|||
params: { conversationId: string; messageId?: string },
|
||||
metadata?: { context?: string; capExpiryToConversation?: boolean },
|
||||
): Promise<void>;
|
||||
applyForcedRetentionToTag(
|
||||
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
|
||||
params: { tag: string },
|
||||
metadata?: { context?: string },
|
||||
): Promise<void>;
|
||||
deleteMessagesSince(
|
||||
userId: string,
|
||||
params: { messageId: string; conversationId: string },
|
||||
|
|
@ -419,6 +425,45 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces forced (ephemeral) retention on every conversation carrying a bookmark tag,
|
||||
* for tag-scoped writes that bypass `saveConvo`/`applyForcedRetention` — global tag renames
|
||||
* and deletes that `Conversation.updateMany` the tag on/off existing chats. Without this an
|
||||
* older permanent chat touched only by a tag change after an install switches to ephemeral
|
||||
* would stay visible and never expire. A no-op outside forced retention.
|
||||
*/
|
||||
async function applyForcedRetentionToTag(
|
||||
{ userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
|
||||
{ tag }: { tag: string },
|
||||
metadata?: { context?: string },
|
||||
): Promise<void> {
|
||||
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let forcedExpiredAt: Date;
|
||||
try {
|
||||
forcedExpiredAt = createTempChatExpirationDate(interfaceConfig);
|
||||
} catch (err) {
|
||||
logger.error('Error creating temporary chat expiration date:', err);
|
||||
logger.info(`---\`applyForcedRetentionToTag\` context: ${metadata?.context}`);
|
||||
forcedExpiredAt = createFallbackRetentionDate();
|
||||
}
|
||||
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
|
||||
await cascadeForcedRetentionByTag(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
userId,
|
||||
tag,
|
||||
forcedExpiredAt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes messages in a conversation since a specific message.
|
||||
*/
|
||||
|
|
@ -556,6 +601,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
updateMessageText,
|
||||
updateMessage,
|
||||
applyForcedRetention,
|
||||
applyForcedRetentionToTag,
|
||||
deleteMessagesSince,
|
||||
getMessages,
|
||||
getMessage,
|
||||
|
|
|
|||
|
|
@ -168,3 +168,53 @@ export const cascadeForcedConversationRetention = async (
|
|||
await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const cascadeForcedRetentionByTag = async (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
tag: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> => {
|
||||
const taggedConversations = await Conversation.find(
|
||||
{ user: userId, tags: tag },
|
||||
'conversationId',
|
||||
).lean<Array<{ conversationId: string }>>();
|
||||
if (taggedConversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
const conversationIds = taggedConversations.map((convo) => convo.conversationId);
|
||||
await Conversation.updateMany(
|
||||
{
|
||||
user: userId,
|
||||
conversationId: { $in: conversationIds },
|
||||
...forcedRetentionGapFilter<IConversation>(forcedExpiredAt),
|
||||
},
|
||||
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
|
||||
);
|
||||
await Message.updateMany(
|
||||
{
|
||||
user: userId,
|
||||
conversationId: { $in: conversationIds },
|
||||
...forcedRetentionGapFilter<IMessage>(forcedExpiredAt),
|
||||
},
|
||||
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
|
||||
);
|
||||
await SharedLink.updateMany(
|
||||
{
|
||||
user: userId,
|
||||
conversationId: { $in: conversationIds },
|
||||
$or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }],
|
||||
},
|
||||
{ $set: { expiredAt: forcedExpiredAt } },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue