diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 1d20d224af..3a607ee833 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -81,6 +81,7 @@ jest.mock('~/models', () => ({ getSharedLink: jest.fn(), getSharedLinkFile: jest.fn(), backfillSharedLinkFiles: jest.fn(), + applyForcedRetention: jest.fn(), getRoleByName: jest.fn(), })); @@ -123,6 +124,7 @@ const { updateSharedLink, getSharedLinkFile, backfillSharedLinkFiles, + applyForcedRetention, getRoleByName, } = require('~/models'); const shareRouter = require('../share'); @@ -341,6 +343,50 @@ describe('share routes', () => { expect(createSharedLink).not.toHaveBeenCalled(); }); + it('converts the source conversation under forced retention when creating a share', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + const response = await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL })) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123' }); + + expect(response.status).toBe(200); + expect(applyForcedRetention).toHaveBeenCalledWith( + { userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } }, + { conversationId: 'convo-123' }, + expect.objectContaining({ context: expect.any(String) }), + ); + }); + + it('does not convert the source conversation when the share is not created', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue(null); + + const response = await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL })) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123' }); + + expect(response.status).toBe(404); + expect(applyForcedRetention).not.toHaveBeenCalled(); + }); + + it('converts the source conversation under forced retention when updating a share', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); + + await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL })) + .patch('/api/share/share-123') + .send({ snapshotFiles: false }); + + expect(applyForcedRetention).toHaveBeenCalledWith( + { userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } }, + { conversationId: 'convo-123' }, + expect.objectContaining({ context: expect.any(String) }), + ); + }); + it('rejects new shares for expired conversations in all retention mode', async () => { mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 1a15bd2f73..7bdb0dbb9b 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -32,6 +32,7 @@ const { getSharedLink, getSharedLinkFile, backfillSharedLinkFiles, + applyForcedRetention, getRoleByName, } = require('~/models'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -66,6 +67,18 @@ const resolveSharedLinkExpiration = (req, conversationId) => }, ); +/** + * Converts the shared source conversation (and its messages) under forced (ephemeral) + * retention, so sharing an older permanent chat does not leave it visible and non-expiring + * after the public link itself expires; a no-op outside forced retention. + */ +const enforceForcedRetention = (req, conversationId, context) => + applyForcedRetention( + { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }, + { conversationId }, + { context }, + ); + /** * Shared messages */ @@ -441,6 +454,11 @@ router.post( ); if (created) { await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt); + await enforceForcedRetention( + req, + req.params.conversationId, + 'POST /api/share/:conversationId', + ); res.status(200).json(created); } else { res.status(404).end(); @@ -483,6 +501,9 @@ router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => if (updatedShare._id && expiredAt !== undefined) { await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt); } + if (existing?.conversationId) { + await enforceForcedRetention(req, existing.conversationId, 'PATCH /api/share/:shareId'); + } res.status(200).json(updatedShare); } else { res.status(404).end();