Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funny-rocks-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes an issue where user data exports requested would remain stuck and never complete.
1 change: 1 addition & 0 deletions apps/meteor/.mocharc.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = {
'lib/callbacks.spec.ts',
'server/lib/ldap/*.spec.ts',
'server/lib/ldap/**/*.spec.ts',
'server/lib/dataExport/**/*.spec.ts',
'server/ufs/*.spec.ts',
'ee/server/lib/ldap/*.spec.ts',
'ee/tests/**/*.tests.ts',
Expand Down
26 changes: 9 additions & 17 deletions apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,23 @@
import { mkdir, writeFile } from 'fs/promises';

import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType } from '@rocket.chat/core-typings';
import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType, IExportOperation } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';

import { settings } from '../../../app/settings/server';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { joinPath } from '../fileUtils';
import { i18n } from '../i18n';

const hideUserName = (
username: string,
userData: Pick<IUser, 'username'> | undefined,
usersMap: { userNameTable: Record<string, string> },
) => {
if (!usersMap.userNameTable) {
usersMap.userNameTable = {};
}

if (!usersMap.userNameTable[username]) {
const hideUserName = (username: string, userData: Pick<IUser, 'username'> | undefined, usersMap: Record<string, string>) => {
if (!usersMap[username]) {
if (userData && username === userData.username) {
usersMap.userNameTable[username] = username;
usersMap[username] = username;
} else {
usersMap.userNameTable[username] = `User_${Object.keys(usersMap.userNameTable).length + 1}`;
usersMap[username] = `User_${Object.keys(usersMap).length + 1}`;
}
}

return usersMap.userNameTable[username];
return usersMap[username];
};

const getAttachmentData = (attachment: MessageAttachment, message: IMessage) => {
Expand Down Expand Up @@ -66,7 +58,7 @@ export const getMessageData = (
msg: IMessage,
hideUsers: boolean,
userData: Pick<IUser, 'username'> | undefined,
usersMap: { userNameTable: Record<string, string> },
usersMap: IExportOperation['userNameTable'],
): MessageData => {
const username = hideUsers ? hideUserName(msg.u.username || msg.u.name || '', userData, usersMap) : msg.u.username;

Expand Down Expand Up @@ -199,7 +191,7 @@ export const exportRoomMessages = async (
limit: number,
userData: any,
filter: any = {},
usersMap: any = {},
usersMap: IExportOperation['userNameTable'] = {},
hideUsers = true,
) => {
const readPreference = readSecondaryPreferred();
Expand Down Expand Up @@ -254,7 +246,7 @@ export const exportRoomMessagesToFile = async function (
)[],
userData: IUser,
messagesFilter = {},
usersMap = {},
usersMap: IExportOperation['userNameTable'] = {},
hideUsers = true,
) {
await mkdir(exportPath, { recursive: true });
Expand Down
251 changes: 251 additions & 0 deletions apps/meteor/server/lib/dataExport/processDataDownloads.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import fs from 'fs';

import type { IExportOperation } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import Sinon from 'sinon';

let exportOperation: IExportOperation | null = null;

const modelsMock = {
ExportOperations: {
findLastOperationByUser: async (userId: string, fullExport = false) => {
if (exportOperation?.userId === userId && exportOperation?.fullExport === fullExport) {
return exportOperation;
}
},
countAllPendingBeforeMyRequest: async (requestDay: Date) => {
if (
exportOperation &&
exportOperation.createdAt < requestDay &&
exportOperation.status !== 'completed' &&
exportOperation.status !== 'skipped'
) {
return 1;
}
return 0;
},
create: async (data: any) => {
exportOperation = {
userNameTable: null, // need to keep this null for testing purposes
...data,
_id: 'exportOp1',
createdAt: new Date(),
};
return exportOperation?._id as IExportOperation['_id'];
},
updateOperation: async (data: IExportOperation) => {
if (exportOperation && exportOperation._id === data._id) {
exportOperation = { ...exportOperation, ...data };
}
return { modifiedCount: 1 };
},

findOnePending: async () => {
if (exportOperation && exportOperation.status !== 'completed' && exportOperation.status !== 'skipped') {
return exportOperation;
}
return null;
},
},
UserDataFiles: {
findOneById: async (fileId: string) => {
if (exportOperation?.fileId === fileId) {
return {
_id: fileId,
};
}
},
findLastFileByUser: async (userId: string) => {
if (exportOperation?.userId === userId && exportOperation.fileId) {
return {
_id: exportOperation.fileId,
};
}
},
},
Avatars: {
findOneByName: async (_name: string) => {
return null;
},
},
Subscriptions: {
findByUserId: (_userId: string) => {
return [
{
rid: 'general',
},
];
},
},
Messages: {
findPaginated: (_query: object, _options: object) => {
return {
cursor: {
toArray: async () => [
{
_id: 'msg1',
rid: 'general',
ts: new Date(),
msg: 'Hello World',
u: { _id: 'user1', username: 'userone' },
},
{
_id: 'msg2',
rid: 'general',
ts: new Date(),
msg: 'Second message',
u: { _id: 'user2', username: 'usertwo' },
},
],
},
totalCount: Promise.resolve(0),
};
},
},
};

const { exportRoomMessagesToFile } = proxyquire.noCallThru().load('./exportRoomMessagesToFile.ts', {
'@rocket.chat/models': modelsMock,
'../../../app/settings/server': {
settings: {
get: (_key: string) => {
return undefined;
},
},
},
'../i18n': {
i18n: {
t: (key: string) => key,
},
},
});

const { requestDataDownload } = proxyquire.noCallThru().load('../../methods/requestDataDownload.ts', {
'@rocket.chat/models': modelsMock,
'../../app/settings/server': {
settings: {
get: (_key: string) => {
return undefined;
},
},
},
'../lib/dataExport': {
getPath: (fileId: string) => `/data-download/${fileId}`,
},
'meteor/meteor': {
Meteor: {
methods: Sinon.stub(),
},
},
}) as {
requestDataDownload: (args: { userData: { _id: string }; fullExport?: boolean }) => Promise<{
requested: boolean;
exportOperation: IExportOperation;
url: string | null;
pendingOperationsBeforeMyRequest: number;
}>;
};

const { processDataDownloads } = proxyquire.noCallThru().load('./processDataDownloads.ts', {
'@rocket.chat/models': modelsMock,
'../../../app/file-upload/server': {
FileUpload: {
copy: async (fileId: string, _options: any) => {
return `copied-${fileId}`;
},
},
},
'../../../app/settings/server': {
settings: {
get: (_key: string) => {
return undefined;
},
},
},
'../../../app/utils/server/getURL': {
getURL: (path: string) => `https://example.com${path}`,
},
'../i18n': {
i18n: {
t: (key: string) => key,
},
},
'./copyFileUpload': {
copyFileUpload: (_attachmentData: { _id: string; name: string }, _assetsPath: string) => {
return Promise.resolve();
},
},
'./exportRoomMessagesToFile': {
exportRoomMessagesToFile,
},
'./getRoomData': {
getRoomData: Sinon.stub().resolves({
roomId: 'GENERAL',
roomName: 'general',
type: 'c',
exportedCount: 0,
status: 'pending',
userId: 'user1',
targetFile: 'general.json',
}),
},
'./sendEmail': {
sendEmail: Sinon.stub().resolves(),
},
'./uploadZipFile': {
uploadZipFile: Sinon.stub().resolves({ _id: 'file1' }),
},
}) as {
processDataDownloads: () => Promise<void>;
};

const userData = { _id: 'user1', username: 'userone' };

describe('requestDataDownload', () => {
beforeEach(() => {
exportOperation = null;
});

it('should create a new export operation if none exists', async () => {
const result = await requestDataDownload({ userData, fullExport: false });

expect(result.requested).to.be.true;
expect(result.exportOperation).to.exist;
expect(result.exportOperation.userId).to.equal('user1');
expect(result.exportOperation.fullExport).to.be.false;
expect(result.url).to.be.null;
expect(result.pendingOperationsBeforeMyRequest).to.equal(0);
expect(result.exportOperation.status).to.equal('pending');
});
});

describe('export user data', async () => {
beforeEach(() => {
exportOperation = null;
});
it('should process data download for pending export operations', async () => {
await requestDataDownload({ userData, fullExport: true });

expect(exportOperation).to.not.be.null;
expect(exportOperation?.userId).to.equal('user1');
expect(exportOperation?.fullExport).to.be.true;
expect(exportOperation?.status).to.equal('pending');

await processDataDownloads();

expect(exportOperation?.status).to.equal('completed');
expect(exportOperation?.fileId).to.equal('file1');
expect(exportOperation?.generatedUserFile).to.be.true;
expect(exportOperation?.roomList).to.have.lengthOf(1);
expect(exportOperation?.roomList?.[0].roomId).to.equal('GENERAL');
expect(exportOperation?.roomList?.[0].exportedCount).to.equal(2);
expect(exportOperation?.exportPath).to.be.string;

expect(fs.readFileSync(`${exportOperation?.exportPath}/${exportOperation?.roomList?.[0].targetFile}`, 'utf-8')).to.contain(
'Hello World',
);
expect(exportOperation?.generatedFile).to.be.string;
expect(fs.existsSync(exportOperation?.generatedFile as string)).to.be.true;
});
});
3 changes: 3 additions & 0 deletions apps/meteor/server/lib/dataExport/processDataDownloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ const continueExportOperation = async function (exportOperation: IExportOperatio

// Run every room on every request, to avoid missing new messages on the rooms that finished first.
if (exportOperation.status === 'exporting') {
if (!exportOperation.userNameTable) {
exportOperation.userNameTable = {};
}
const { fileList } = await exportRoomMessagesToFile(
exportOperation.exportPath,
exportOperation.assetsPath,
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/server/methods/requestDataDownload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const requestDataDownload = async ({
generatedFile: undefined,
fullExport,
userData: currentUserData,
userNameTable: {},
} as unknown as IExportOperation; // @todo yikes!

const id = await ExportOperations.create(exportOperation);
Expand Down
Loading