Skip to content

Commit 6d36fc2

Browse files
authored
fix: cleanRoomHistory with filesOnly=true removes all attachments including quotes (#35797)
1 parent 0ff7a54 commit 6d36fc2

File tree

5 files changed

+197
-4
lines changed

5 files changed

+197
-4
lines changed

.changeset/thirty-carrots-decide.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@rocket.chat/model-typings': patch
3+
'@rocket.chat/models': patch
4+
'@rocket.chat/meteor': patch
5+
---
6+
7+
Fixes the room history pruning behavior when filesOnly is true to ensure only file-type attachments are removed, preserving quotes and non-file attachments.

apps/meteor/app/lib/server/functions/cleanRoomHistory.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { api } from '@rocket.chat/core-services';
2-
import type { IRoom } from '@rocket.chat/core-typings';
2+
import type { IRoom, MessageAttachment } from '@rocket.chat/core-typings';
33
import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.chat/models';
44

55
import { deleteRoom } from './deleteRoom';
66
import { i18n } from '../../../../server/lib/i18n';
77
import { FileUpload } from '../../../file-upload/server';
88
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener';
99

10+
const FILE_CLEANUP_BATCH_SIZE = 1000;
11+
async function performFileAttachmentCleanupBatch(idsSet: Set<string>, replaceWith?: MessageAttachment) {
12+
if (idsSet.size === 0) return;
13+
14+
const ids = [...idsSet];
15+
await Messages.removeFileAttachmentsByMessageIds(ids, replaceWith);
16+
await Messages.clearFilesByMessageIds(ids);
17+
idsSet.clear();
18+
}
19+
1020
export async function cleanRoomHistory({
1121
rid = '',
1222
latest = new Date(),
@@ -44,15 +54,25 @@ export async function cleanRoomHistory({
4454
limit,
4555
});
4656

57+
const targetMessageIdsForAttachmentRemoval = new Set<string>();
58+
4759
for await (const document of cursor) {
4860
const uploadsStore = FileUpload.getStore('Uploads');
4961

5062
document.files && (await Promise.all(document.files.map((file) => uploadsStore.deleteById(file._id))));
5163

5264
fileCount++;
5365
if (filesOnly) {
54-
await Messages.updateOne({ _id: document._id }, { $unset: { file: 1 }, $set: { attachments: [{ color: '#FD745E', text }] } });
66+
targetMessageIdsForAttachmentRemoval.add(document._id);
5567
}
68+
69+
if (targetMessageIdsForAttachmentRemoval.size >= FILE_CLEANUP_BATCH_SIZE) {
70+
await performFileAttachmentCleanupBatch(targetMessageIdsForAttachmentRemoval, { color: '#FD745E', text });
71+
}
72+
}
73+
74+
if (targetMessageIdsForAttachmentRemoval.size > 0) {
75+
await performFileAttachmentCleanupBatch(targetMessageIdsForAttachmentRemoval, { color: '#FD745E', text });
5676
}
5777

5878
if (filesOnly) {

apps/meteor/tests/end-to-end/api/rooms.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@ import fs from 'fs';
22
import path from 'path';
33

44
import type { Credentials } from '@rocket.chat/api-client';
5-
import type { IMessage, IRole, IRoom, ITeam, IUpload, IUser, ImageAttachmentProps, SettingValue } from '@rocket.chat/core-typings';
6-
import { TEAM_TYPE } from '@rocket.chat/core-typings';
5+
import type {
6+
IMessage,
7+
IRole,
8+
IRoom,
9+
ITeam,
10+
IUpload,
11+
IUser,
12+
ImageAttachmentProps,
13+
MessageAttachment,
14+
SettingValue,
15+
} from '@rocket.chat/core-typings';
16+
import { isFileAttachment, isQuoteAttachment, TEAM_TYPE } from '@rocket.chat/core-typings';
717
import { Random } from '@rocket.chat/random';
818
import { assert, expect } from 'chai';
919
import { after, afterEach, before, beforeEach, describe, it } from 'mocha';
@@ -1176,6 +1186,116 @@ describe('[Rooms]', () => {
11761186
})
11771187
.end(done);
11781188
});
1189+
1190+
it('should remove only files and file attachments when filesOnly is set to true', async () => {
1191+
const message1Response = await sendSimpleMessage({ roomId: publicChannel._id });
1192+
1193+
const mediaUploadResponse = await request
1194+
.post(api(`rooms.media/${publicChannel._id}`))
1195+
.set(credentials)
1196+
.attach('file', imgURL)
1197+
.expect(200);
1198+
1199+
const message2Response = await request
1200+
.post(api(`rooms.mediaConfirm/${publicChannel._id}/${mediaUploadResponse.body.file._id}`))
1201+
.set(credentials)
1202+
.send({ msg: 'message with file only' })
1203+
.expect(200);
1204+
1205+
await request
1206+
.post(api('rooms.cleanHistory'))
1207+
.set(credentials)
1208+
.send({
1209+
roomId: publicChannel._id,
1210+
latest: '9999-12-31T23:59:59.000Z',
1211+
oldest: '0001-01-01T00:00:00.000Z',
1212+
filesOnly: true,
1213+
})
1214+
.expect(200);
1215+
1216+
const res = await request.get(api('channels.messages')).set(credentials).query({ roomId: publicChannel._id }).expect(200);
1217+
1218+
expect(res.body.messages).to.be.an('array');
1219+
const messageIds = res.body.messages.map((m: IMessage) => m._id);
1220+
expect(messageIds).to.contain(message1Response.body.message._id);
1221+
expect(messageIds).to.contain(message2Response.body.message._id);
1222+
const cleanedMessage = res.body.messages.find((m: { _id: any }) => m._id === message2Response.body.message._id);
1223+
expect(cleanedMessage).to.exist;
1224+
expect(cleanedMessage.file).to.be.undefined;
1225+
expect(cleanedMessage.files?.length ?? 0).to.equal(0);
1226+
expect((cleanedMessage.attachments ?? []).find((a: MessageAttachment) => isFileAttachment(a))).to.be.undefined;
1227+
1228+
await request
1229+
.get(api('channels.files'))
1230+
.set(credentials)
1231+
.query({
1232+
roomId: publicChannel._id,
1233+
})
1234+
.expect('Content-Type', 'application/json')
1235+
.expect(200)
1236+
.expect((res) => {
1237+
expect(res.body).to.have.property('success', true);
1238+
expect(res.body).to.have.property('files').and.to.be.an('array');
1239+
expect(res.body.files).to.have.lengthOf(0);
1240+
});
1241+
});
1242+
1243+
it('should not remove quote attachments when filesOnly is set to true', async () => {
1244+
const siteUrl = await getSettingValueById('Site_Url');
1245+
const message1Response = await sendSimpleMessage({ roomId: publicChannel._id });
1246+
const mediaResponse = await request
1247+
.post(api(`rooms.media/${publicChannel._id}`))
1248+
.set(credentials)
1249+
.attach('file', imgURL)
1250+
.expect('Content-Type', 'application/json')
1251+
.expect(200);
1252+
1253+
const message2Response = await request
1254+
.post(api(`rooms.mediaConfirm/${publicChannel._id}/${mediaResponse.body.file._id}`))
1255+
.set(credentials)
1256+
.send({
1257+
msg: new URL(`/${publicChannel.fname}?msg=${message1Response.body.message._id}`, siteUrl as string).toString(),
1258+
})
1259+
.expect(200);
1260+
1261+
await request
1262+
.post(api('rooms.cleanHistory'))
1263+
.set(credentials)
1264+
.send({
1265+
roomId: publicChannel._id,
1266+
latest: '9999-12-31T23:59:59.000Z',
1267+
oldest: '0001-01-01T00:00:00.000Z',
1268+
filesOnly: true,
1269+
})
1270+
.expect('Content-Type', 'application/json')
1271+
.expect(200)
1272+
.expect((res) => {
1273+
expect(res.body).to.have.property('success', true);
1274+
});
1275+
1276+
await request
1277+
.get(api('channels.messages'))
1278+
.set(credentials)
1279+
.query({
1280+
roomId: publicChannel._id,
1281+
})
1282+
.expect('Content-Type', 'application/json')
1283+
.expect(200)
1284+
.expect((res) => {
1285+
expect(res.body).to.have.property('success', true);
1286+
expect(res.body).to.have.property('messages').and.to.be.an('array');
1287+
const message = (res.body.messages.find((m: { _id: any }) => m._id === message2Response.body.message._id) as IMessage) || null;
1288+
expect(message).not.to.be.null;
1289+
expect(message).to.have.property('attachments');
1290+
const fileAttachment = message.attachments?.find((f) => isFileAttachment(f)) || null;
1291+
expect(fileAttachment, 'Expected file attachments to be removed').to.be.null;
1292+
const quoteAttachment = message.attachments?.find((f) => isQuoteAttachment(f)) || null;
1293+
expect(quoteAttachment, 'Expected quote attachments to be present').not.to.be.null;
1294+
expect(message.file).to.be.undefined;
1295+
expect(message.files).to.satisfy((files: IMessage['files']) => files === undefined || files.length === 0);
1296+
});
1297+
});
1298+
11791299
it('should return success when send a valid private channel', (done) => {
11801300
void request
11811301
.post(api('rooms.cleanHistory'))

packages/model-typings/src/models/IMessagesModel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,4 +293,6 @@ export interface IMessagesModel extends IBaseModel<IMessage> {
293293
decreaseReplyCountById(_id: string, inc?: number): Promise<IMessage | null>;
294294
countPinned(options?: CountDocumentsOptions): Promise<number>;
295295
countStarred(options?: CountDocumentsOptions): Promise<number>;
296+
removeFileAttachmentsByMessageIds(_ids: string[], replaceWith?: MessageAttachment): Promise<Document | UpdateResult>;
297+
clearFilesByMessageIds(_ids: string[]): Promise<Document | UpdateResult>;
296298
}

packages/models/src/models/Messages.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,4 +1783,48 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
17831783
};
17841784
return this.findOneAndUpdate(query, update, { returnDocument: 'after' });
17851785
}
1786+
1787+
removeFileAttachmentsByMessageIds(_ids: string[], replaceWith?: MessageAttachment) {
1788+
if (!_ids || _ids.length === 0) {
1789+
return Promise.resolve({ acknowledged: true, modifiedCount: 0, upsertedId: null, upsertedCount: 0, matchedCount: 0 });
1790+
}
1791+
const setAttachments = replaceWith
1792+
? {
1793+
$map: {
1794+
input: '$attachments',
1795+
as: 'att',
1796+
in: {
1797+
$cond: [{ $eq: ['$$att.type', 'file'] }, replaceWith, '$$att'],
1798+
},
1799+
},
1800+
}
1801+
: {
1802+
$filter: {
1803+
input: '$attachments',
1804+
as: 'att',
1805+
cond: { $ne: ['$$att.type', 'file'] },
1806+
},
1807+
};
1808+
1809+
return this.updateMany({ _id: { $in: _ids } }, [
1810+
{
1811+
$set: {
1812+
attachments: setAttachments,
1813+
},
1814+
},
1815+
]);
1816+
}
1817+
1818+
clearFilesByMessageIds(_ids: string[]) {
1819+
if (!_ids || _ids.length === 0) {
1820+
return Promise.resolve({ acknowledged: true, modifiedCount: 0, upsertedId: null, upsertedCount: 0, matchedCount: 0 });
1821+
}
1822+
return this.updateMany(
1823+
{ _id: { $in: _ids } },
1824+
{
1825+
$set: { files: [] },
1826+
$unset: { file: 1 },
1827+
},
1828+
);
1829+
}
17861830
}

0 commit comments

Comments
 (0)