mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: enforce forced retention on per-conversation tag writes
Bookmark-tag writes update Conversation rows directly without saveConvo, so under ephemeral retention adding a tag to a chat (createConversationTag) or changing a chat's tag list (updateTagsForConversation) left an older permanent conversation with isTemporary/expiredAt unset, keeping it visible and non-expiring. Make applyForcedRetention's messageId optional so it can run the conversation cascade alone, load app config on the per-conversation tag routes (POST /api/tags when adding to a conversation, PUT /api/tags/convo/:conversationId), and enforce retention after the write.
This commit is contained in:
parent
28a98c744c
commit
fde5b92248
3 changed files with 70 additions and 13 deletions
|
|
@ -8,9 +8,10 @@ const {
|
|||
createConversationTag,
|
||||
deleteConversationTag,
|
||||
getConversationTags,
|
||||
applyForcedRetention,
|
||||
getRoleByName,
|
||||
} = require('~/models');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -23,6 +24,17 @@ const checkBookmarkAccess = generateCheckAccess({
|
|||
router.use(requireJwtAuth);
|
||||
router.use(checkBookmarkAccess);
|
||||
|
||||
/**
|
||||
* Enforces forced (ephemeral) retention after a bookmark-tag write converts an older
|
||||
* permanent conversation; a no-op outside forced retention.
|
||||
*/
|
||||
const enforceForcedRetention = (req, conversationId, context) =>
|
||||
applyForcedRetention(
|
||||
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
|
||||
{ conversationId },
|
||||
{ context },
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* Retrieves all conversation tags for the authenticated user.
|
||||
|
|
@ -49,9 +61,12 @@ router.get('/', async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
router.post('/', configMiddleware, async (req, res) => {
|
||||
try {
|
||||
const tag = await createConversationTag(req.user.id, req.body);
|
||||
if (req.body?.addToConversation && req.body?.conversationId) {
|
||||
await enforceForcedRetention(req, req.body.conversationId, 'POST /api/tags');
|
||||
}
|
||||
res.status(200).json(tag);
|
||||
} catch (error) {
|
||||
logger.error('Error creating conversation tag:', error);
|
||||
|
|
@ -107,13 +122,18 @@ router.delete('/:tag', async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.put('/convo/:conversationId', async (req, res) => {
|
||||
router.put('/convo/:conversationId', configMiddleware, async (req, res) => {
|
||||
try {
|
||||
const conversationTags = await updateTagsForConversation(
|
||||
req.user.id,
|
||||
req.params.conversationId,
|
||||
req.body.tags,
|
||||
);
|
||||
await enforceForcedRetention(
|
||||
req,
|
||||
req.params.conversationId,
|
||||
'PUT /api/tags/convo/:conversationId',
|
||||
);
|
||||
res.status(200).json(conversationTags);
|
||||
} catch (error) {
|
||||
logger.error('Error updating conversation tags', error);
|
||||
|
|
|
|||
|
|
@ -1302,6 +1302,40 @@ describe('Message Operations', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('converts a permanent conversation and its messages without a messageId (tag write)', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await Message.create([
|
||||
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'first' },
|
||||
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'second' },
|
||||
]);
|
||||
|
||||
await applyForcedRetention(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId },
|
||||
{ context: 'tag' },
|
||||
);
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const messages = await getMessages({ conversationId, user: 'user123' });
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op outside forced retention', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const messageId = uuidv4();
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export interface MessageMethods {
|
|||
): Promise<Partial<IMessage>>;
|
||||
applyForcedRetention(
|
||||
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
|
||||
params: { conversationId: string; messageId: string },
|
||||
params: { conversationId: string; messageId?: string },
|
||||
metadata?: { context?: string; capExpiryToConversation?: boolean },
|
||||
): Promise<void>;
|
||||
deleteMessagesSince(
|
||||
|
|
@ -524,14 +524,15 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
}
|
||||
|
||||
/**
|
||||
* Enforces forced (ephemeral) retention on an existing message and its parent
|
||||
* conversation. Message-write paths that only update a row — edits, feedback — bypass
|
||||
* the `saveMessage`/`saveConvo` enforcement, so an older permanent chat touched after an
|
||||
* install switches to ephemeral would otherwise stay visible and never expire.
|
||||
* Enforces forced (ephemeral) retention on a conversation (and optionally a specific
|
||||
* message) that was touched outside `saveMessage`/`saveConvo` — message edits, feedback,
|
||||
* or bookmark-tag writes. Without these, an older permanent chat touched after an install
|
||||
* switches to ephemeral would stay visible and never expire. Omit `messageId` for
|
||||
* conversation-only writes (e.g. tag changes) to run just the conversation cascade.
|
||||
*/
|
||||
async function applyForcedRetention(
|
||||
{ userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
|
||||
{ conversationId, messageId }: { conversationId: string; messageId: string },
|
||||
{ conversationId, messageId }: { conversationId: string; messageId?: string },
|
||||
metadata?: { context?: string; capExpiryToConversation?: boolean },
|
||||
): Promise<void> {
|
||||
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
|
||||
|
|
@ -562,10 +563,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
);
|
||||
}
|
||||
|
||||
await Message.updateOne(
|
||||
{ messageId, user: userId },
|
||||
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
|
||||
);
|
||||
if (messageId) {
|
||||
await Message.updateOne(
|
||||
{ messageId, user: userId },
|
||||
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
|
||||
);
|
||||
}
|
||||
await cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue