fix: cascade forced retention when sharing an older conversation

Creating or updating a shared link computed a capped expiry for the
SharedLink itself but never converted the source conversation, so under
forced ephemeral retention sharing an older permanent chat left it
expiredAt: null and visible indefinitely after the public link expired,
unlike every other write path that converts a touched permanent chat.

Run applyForcedRetention on the source conversation after the share is
created/updated, converting it and its messages and capping its shares;
a no-op outside forced retention.
This commit is contained in:
Marco Beretta 2026-06-25 16:03:50 +02:00
parent c02143f57b
commit 3b8fe3ba3c
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 67 additions and 0 deletions

View file

@ -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' });

View file

@ -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();