snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
14
apps/meteor/server/lib/dataExport/copyFileUpload.ts
Normal file
14
apps/meteor/server/lib/dataExport/copyFileUpload.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { FileProp } from '@rocket.chat/core-typings';
|
||||
import { Uploads } from '@rocket.chat/models';
|
||||
|
||||
import { joinPath } from '../fileUtils';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
|
||||
export const copyFileUpload = async (attachmentData: Pick<FileProp, '_id' | 'name'>, assetsPath: string): Promise<void> => {
|
||||
const file = await Uploads.findOneById(attachmentData._id);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
await FileUpload.copy(file, joinPath(assetsPath, `${attachmentData._id}-${attachmentData.name}`));
|
||||
};
|
||||
322
apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts
Normal file
322
apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType, IExportOperation } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
|
||||
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
|
||||
import { settings } from '../../settings';
|
||||
import { joinPath } from '../fileUtils';
|
||||
import { i18n } from '../i18n';
|
||||
|
||||
const hideUserName = (username: string, userData: Pick<IUser, 'username'> | undefined, usersMap: Record<string, string>) => {
|
||||
if (!usersMap[username]) {
|
||||
if (userData && username === userData.username) {
|
||||
usersMap[username] = username;
|
||||
} else {
|
||||
usersMap[username] = `User_${Object.keys(usersMap).length + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
return usersMap[username];
|
||||
};
|
||||
|
||||
const getAttachmentData = (attachment: MessageAttachment, message: IMessage) => {
|
||||
return {
|
||||
type: 'type' in attachment ? attachment.type : undefined,
|
||||
title: attachment.title,
|
||||
title_link: attachment.title_link,
|
||||
image_url: 'image_url' in attachment ? attachment.image_url : undefined,
|
||||
audio_url: 'audio_url' in attachment ? attachment.audio_url : undefined,
|
||||
video_url: 'video_url' in attachment ? attachment.video_url : undefined,
|
||||
message_link: 'message_link' in attachment ? attachment.message_link : undefined,
|
||||
image_type: 'image_type' in attachment ? attachment.image_type : undefined,
|
||||
image_size: 'image_size' in attachment ? attachment.image_size : undefined,
|
||||
video_size: 'video_size' in attachment ? attachment.video_size : undefined,
|
||||
video_type: 'video_type' in attachment ? attachment.video_type : undefined,
|
||||
audio_size: 'audio_size' in attachment ? attachment.audio_size : undefined,
|
||||
audio_type: 'audio_type' in attachment ? attachment.audio_type : undefined,
|
||||
url:
|
||||
attachment.title_link ||
|
||||
('image_url' in attachment ? attachment.image_url : undefined) ||
|
||||
('audio_url' in attachment ? attachment.audio_url : undefined) ||
|
||||
('video_url' in attachment ? attachment.video_url : undefined) ||
|
||||
('message_link' in attachment ? attachment.message_link : undefined) ||
|
||||
null,
|
||||
remote: !message.file?._id,
|
||||
fileId: message.file?._id,
|
||||
fileName: message.file?.name,
|
||||
};
|
||||
};
|
||||
|
||||
export type MessageData = Pick<IMessage, 'msg' | 'ts'> & {
|
||||
username?: IUser['username'];
|
||||
attachments?: ReturnType<typeof getAttachmentData>[];
|
||||
type?: IMessage['t'];
|
||||
};
|
||||
|
||||
export const getMessageData = (
|
||||
msg: IMessage,
|
||||
hideUsers: boolean,
|
||||
userData: Pick<IUser, 'username'> | undefined,
|
||||
usersMap: IExportOperation['userNameTable'],
|
||||
): MessageData => {
|
||||
const username = hideUsers ? hideUserName(msg.u.username || msg.u.name || '', userData, usersMap) : msg.u.username;
|
||||
|
||||
const messageObject = {
|
||||
msg: msg.msg,
|
||||
username,
|
||||
ts: msg.ts,
|
||||
...(msg.attachments && {
|
||||
attachments: msg.attachments.map((attachment) => getAttachmentData(attachment, msg)),
|
||||
}),
|
||||
...(msg.t && { type: msg.t }),
|
||||
};
|
||||
|
||||
switch (msg.t) {
|
||||
case 'uj':
|
||||
messageObject.msg = i18n.t('User_joined_the_channel');
|
||||
break;
|
||||
case 'ul':
|
||||
messageObject.msg = i18n.t('User_left_this_channel');
|
||||
break;
|
||||
case 'ui':
|
||||
messageObject.msg = i18n.t('User_invited_to_room', {
|
||||
user_invited: hideUserName(msg.msg, userData, usersMap),
|
||||
});
|
||||
break;
|
||||
case 'uir':
|
||||
messageObject.msg = i18n.t('User_rejected_invitation_to_room');
|
||||
break;
|
||||
case 'ult':
|
||||
messageObject.msg = i18n.t('User_left_this_team');
|
||||
break;
|
||||
case 'user-added-room-to-team':
|
||||
messageObject.msg = i18n.t('added__roomName__to_this_team', {
|
||||
roomName: msg.msg,
|
||||
});
|
||||
break;
|
||||
case 'user-converted-to-team':
|
||||
messageObject.msg = i18n.t('Converted__roomName__to_a_team', {
|
||||
roomName: msg.msg,
|
||||
});
|
||||
break;
|
||||
case 'user-converted-to-channel':
|
||||
messageObject.msg = i18n.t('Converted__roomName__to_a_channel', {
|
||||
roomName: msg.msg,
|
||||
});
|
||||
break;
|
||||
case 'user-deleted-room-from-team':
|
||||
messageObject.msg = i18n.t('Deleted__roomName__room', {
|
||||
roomName: msg.msg,
|
||||
});
|
||||
break;
|
||||
case 'user-removed-room-from-team':
|
||||
messageObject.msg = i18n.t('Removed__roomName__from_the_team', {
|
||||
roomName: msg.msg,
|
||||
});
|
||||
break;
|
||||
case 'ujt':
|
||||
messageObject.msg = i18n.t('User_joined_the_team');
|
||||
break;
|
||||
case 'au':
|
||||
messageObject.msg = i18n.t('User_added_to', {
|
||||
user_added: hideUserName(msg.msg, userData, usersMap),
|
||||
user_by: username,
|
||||
});
|
||||
break;
|
||||
case 'added-user-to-team':
|
||||
messageObject.msg = i18n.t('Added__username__to_this_team', {
|
||||
user_added: msg.msg,
|
||||
});
|
||||
break;
|
||||
case 'r':
|
||||
messageObject.msg = i18n.t('Room_name_changed_to', {
|
||||
room_name: msg.msg,
|
||||
user_by: username,
|
||||
});
|
||||
break;
|
||||
case 'ru':
|
||||
messageObject.msg = i18n.t('User_has_been_removed', {
|
||||
user_removed: hideUserName(msg.msg, userData, usersMap),
|
||||
user_by: username,
|
||||
});
|
||||
break;
|
||||
case 'removed-user-from-team':
|
||||
messageObject.msg = i18n.t('Removed__username__from_the_team', {
|
||||
user_removed: hideUserName(msg.msg, userData, usersMap),
|
||||
});
|
||||
break;
|
||||
case 'wm':
|
||||
messageObject.msg = i18n.t('Welcome', { interpolation: { escapeValue: false }, user: username });
|
||||
break;
|
||||
case 'livechat-close':
|
||||
messageObject.msg = i18n.t('Conversation_finished');
|
||||
break;
|
||||
case 'livechat-started':
|
||||
messageObject.msg = i18n.t('Chat_started');
|
||||
break;
|
||||
case 'abac-removed-user-from-room':
|
||||
messageObject.msg = i18n.t('abac_removed_user_from_the_room');
|
||||
break;
|
||||
}
|
||||
|
||||
return messageObject;
|
||||
};
|
||||
|
||||
export const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFiles: FileProp[] = []): string => {
|
||||
if (type === 'json') {
|
||||
return JSON.stringify(messageObject);
|
||||
}
|
||||
|
||||
const file = [];
|
||||
|
||||
const messageType = messageObject.type;
|
||||
const timestamp = messageObject.ts ? new Date(messageObject.ts).toUTCString() : '';
|
||||
|
||||
const italicTypes: IMessage['t'][] = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close'];
|
||||
|
||||
const safeMsg = escapeHTML(messageObject.msg ?? '');
|
||||
const message = italicTypes.includes(messageType) ? `<i>${safeMsg}</i>` : safeMsg;
|
||||
|
||||
file.push(`<p><strong>${escapeHTML(messageObject.username ?? '')}</strong> (${timestamp}):<br/>`);
|
||||
file.push(message);
|
||||
|
||||
for (const messageFile of messageFiles) {
|
||||
if (messageFile?._id) {
|
||||
const attachment = messageObject.attachments?.find((att) => att.type === 'file' && att.title_link?.includes(messageFile._id));
|
||||
|
||||
const description = attachment?.title || i18n.t('Message_Attachments');
|
||||
|
||||
const assetUrl = `./assets/${messageFile._id}-${messageFile.name}`;
|
||||
const link = `<br/><a href="${escapeHTML(assetUrl)}">${escapeHTML(description)}</a>`;
|
||||
file.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
file.push('</p>');
|
||||
|
||||
return file.join('\n');
|
||||
};
|
||||
|
||||
export const exportRoomMessages = async (
|
||||
rid: IRoom['_id'],
|
||||
exportType: 'json' | 'html',
|
||||
skip: number,
|
||||
limit: number,
|
||||
userData: any,
|
||||
filter: any = {},
|
||||
usersMap: IExportOperation['userNameTable'] = {},
|
||||
hideUsers = true,
|
||||
) => {
|
||||
const readPreference = readSecondaryPreferred();
|
||||
|
||||
const { cursor, totalCount } = Messages.findPaginated(
|
||||
{ ...filter, rid },
|
||||
{
|
||||
sort: { ts: 1 },
|
||||
skip,
|
||||
limit,
|
||||
readPreference,
|
||||
},
|
||||
);
|
||||
|
||||
const [results, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
const result = {
|
||||
total,
|
||||
exported: results.length,
|
||||
messages: [] as string[],
|
||||
uploads: [] as FileProp[],
|
||||
};
|
||||
|
||||
results.forEach((msg) => {
|
||||
const messageObject = getMessageData(msg, hideUsers, userData, usersMap);
|
||||
|
||||
// handle both new format (msg.files array) and old format (msg.file) for backward compatibility
|
||||
// and filter out thumbnails (typeGroup === 'thumb') to only include actual files
|
||||
const files = (msg.files || (msg.file ? [msg.file] : [])).filter((file) => file && file.typeGroup !== 'thumb');
|
||||
|
||||
result.uploads.push(...files);
|
||||
result.messages.push(exportMessageObject(exportType, messageObject, files));
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const exportRoomMessagesToFile = async function (
|
||||
exportPath: string,
|
||||
assetsPath: string,
|
||||
exportType: 'json' | 'html',
|
||||
roomList: (
|
||||
| {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
userId: string | undefined;
|
||||
exportedCount: number;
|
||||
status: string;
|
||||
type: RoomType;
|
||||
targetFile: string;
|
||||
}
|
||||
| Record<string, never>
|
||||
)[],
|
||||
userData: IUser,
|
||||
messagesFilter = {},
|
||||
usersMap: IExportOperation['userNameTable'] = {},
|
||||
hideUsers = true,
|
||||
) {
|
||||
await mkdir(exportPath, { recursive: true });
|
||||
await mkdir(assetsPath, { recursive: true });
|
||||
|
||||
const result = {
|
||||
fileList: [] as FileProp[],
|
||||
};
|
||||
|
||||
const limit =
|
||||
settings.get<number>('UserData_MessageLimitPerRequest') > 0 ? settings.get<number>('UserData_MessageLimitPerRequest') : 1000;
|
||||
for await (const exportOpRoomData of roomList) {
|
||||
if (!('targetFile' in exportOpRoomData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = joinPath(exportPath, exportOpRoomData.targetFile);
|
||||
if (exportOpRoomData.status === 'pending') {
|
||||
exportOpRoomData.status = 'exporting';
|
||||
if (exportType === 'html') {
|
||||
await writeFile(
|
||||
filePath,
|
||||
[
|
||||
'<meta http-equiv="content-type" content="text/html; charset=utf-8">',
|
||||
`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`,
|
||||
].join('\n'),
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const skip = exportOpRoomData.exportedCount;
|
||||
|
||||
const { total, exported, uploads, messages } = await exportRoomMessages(
|
||||
exportOpRoomData.roomId,
|
||||
exportType,
|
||||
skip,
|
||||
limit,
|
||||
userData,
|
||||
messagesFilter,
|
||||
usersMap,
|
||||
hideUsers,
|
||||
);
|
||||
|
||||
result.fileList.push(...uploads);
|
||||
|
||||
exportOpRoomData.exportedCount += exported;
|
||||
|
||||
if (total <= exportOpRoomData.exportedCount) {
|
||||
exportOpRoomData.status = 'completed';
|
||||
}
|
||||
|
||||
await writeFile(filePath, `${messages.join('\n')}\n`, { encoding: 'utf8', flag: 'a' });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
3
apps/meteor/server/lib/dataExport/getPath.ts
Normal file
3
apps/meteor/server/lib/dataExport/getPath.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const getPath = (path: string): string => {
|
||||
return `/data-export/${path}`;
|
||||
};
|
||||
37
apps/meteor/server/lib/dataExport/getRoomData.ts
Normal file
37
apps/meteor/server/lib/dataExport/getRoomData.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings';
|
||||
import { Rooms } from '@rocket.chat/models';
|
||||
|
||||
export const getRoomData = async (
|
||||
roomId: IRoom['_id'],
|
||||
ownUserId?: IUser['_id'],
|
||||
): Promise<
|
||||
| {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
userId: string | undefined;
|
||||
exportedCount: number;
|
||||
status: string;
|
||||
type: RoomType;
|
||||
targetFile: string;
|
||||
}
|
||||
| Record<string, never>
|
||||
> => {
|
||||
const roomData = await Rooms.findOneById(roomId);
|
||||
|
||||
if (!roomData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const roomName = roomData.name && roomData.t !== 'd' ? roomData.name : roomId;
|
||||
const userId = roomData.t === 'd' ? roomData.uids?.find((uid) => uid !== ownUserId) : undefined;
|
||||
|
||||
return {
|
||||
roomId,
|
||||
roomName,
|
||||
userId,
|
||||
exportedCount: 0,
|
||||
status: 'pending',
|
||||
type: roomData.t,
|
||||
targetFile: '',
|
||||
};
|
||||
};
|
||||
4
apps/meteor/server/lib/dataExport/index.ts
Normal file
4
apps/meteor/server/lib/dataExport/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { sendViaEmail } from './sendViaEmail';
|
||||
export { sendFile } from './sendFile';
|
||||
export { processDataDownloads } from './processDataDownloads';
|
||||
export { getPath } from './getPath';
|
||||
19
apps/meteor/server/lib/dataExport/makeZipFile.ts
Normal file
19
apps/meteor/server/lib/dataExport/makeZipFile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createWriteStream } from 'node:fs';
|
||||
|
||||
import archiver from 'archiver';
|
||||
|
||||
export const makeZipFile = (folderToZip: string, targetFile: string): Promise<void> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const output = createWriteStream(targetFile);
|
||||
|
||||
const archive = archiver('zip');
|
||||
|
||||
output.on('close', () => resolve());
|
||||
|
||||
archive.on('error', (error) => reject(error));
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(folderToZip, false);
|
||||
await archive.finalize();
|
||||
});
|
||||
};
|
||||
251
apps/meteor/server/lib/dataExport/processDataDownloads.spec.ts
Normal file
251
apps/meteor/server/lib/dataExport/processDataDownloads.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import fs from 'node: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,
|
||||
'../../settings': {
|
||||
settings: {
|
||||
get: (_key: string) => {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
'../i18n': {
|
||||
i18n: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { requestDataDownload } = proxyquire.noCallThru().load('../../meteor-methods/platform/requestDataDownload.ts', {
|
||||
'@rocket.chat/models': modelsMock,
|
||||
'../../settings': {
|
||||
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,
|
||||
'../media/file-upload': {
|
||||
FileUpload: {
|
||||
copy: async (fileId: string, _options: any) => {
|
||||
return `copied-${fileId}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
'../../settings': {
|
||||
settings: {
|
||||
get: (_key: string) => {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
'../utils/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;
|
||||
});
|
||||
});
|
||||
287
apps/meteor/server/lib/dataExport/processDataDownloads.ts
Normal file
287
apps/meteor/server/lib/dataExport/processDataDownloads.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { access, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { IExportOperation, IUser, RoomType } from '@rocket.chat/core-typings';
|
||||
import { Avatars, ExportOperations, UserDataFiles, Subscriptions } from '@rocket.chat/models';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import moment from 'moment';
|
||||
|
||||
import { settings } from '../../settings';
|
||||
import { joinPath } from '../fileUtils';
|
||||
import { i18n } from '../i18n';
|
||||
import { copyFileUpload } from './copyFileUpload';
|
||||
import { exportRoomMessagesToFile } from './exportRoomMessagesToFile';
|
||||
import { getPath } from './getPath';
|
||||
import { getRoomData } from './getRoomData';
|
||||
import { makeZipFile } from './makeZipFile';
|
||||
import { sendEmail } from './sendEmail';
|
||||
import { uploadZipFile } from './uploadZipFile';
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
import { getURL } from '../utils/getURL';
|
||||
|
||||
const loadUserSubscriptions = async (_exportOperation: IExportOperation, fileType: 'json' | 'html', userId: IUser['_id']) => {
|
||||
const roomList: (
|
||||
| {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
userId: string | undefined;
|
||||
exportedCount: number;
|
||||
status: string;
|
||||
type: RoomType;
|
||||
targetFile: string;
|
||||
}
|
||||
| Record<string, never>
|
||||
)[] = [];
|
||||
|
||||
const cursor = Subscriptions.findByUserId(userId);
|
||||
for await (const subscription of cursor) {
|
||||
const roomData = await getRoomData(subscription.rid, userId);
|
||||
roomData.targetFile = `${(fileType === 'json' && roomData.roomName) || subscription.rid}.${fileType}`;
|
||||
|
||||
roomList.push(roomData);
|
||||
}
|
||||
|
||||
return roomList;
|
||||
};
|
||||
|
||||
const generateUserFile = async (exportOperation: IExportOperation, userData?: IUser) => {
|
||||
if (!userData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(exportOperation.exportPath, { recursive: true });
|
||||
|
||||
const { username, name, statusText, emails, roles, services } = userData;
|
||||
|
||||
const dataToSave = {
|
||||
username,
|
||||
name,
|
||||
statusText,
|
||||
emails: emails?.map(({ address }) => address),
|
||||
roles,
|
||||
services: services ? Object.keys(services) : undefined,
|
||||
};
|
||||
|
||||
const fileName = joinPath(exportOperation.exportPath, exportOperation.fullExport ? 'user.json' : 'user.html');
|
||||
|
||||
if (exportOperation.fullExport) {
|
||||
await writeFile(fileName, JSON.stringify(dataToSave), { encoding: 'utf8' });
|
||||
|
||||
exportOperation.generatedUserFile = true;
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const stream = createWriteStream(fileName, { encoding: 'utf8' });
|
||||
|
||||
stream.on('finish', resolve);
|
||||
stream.on('error', reject);
|
||||
|
||||
stream.write('<!DOCTYPE html>\n');
|
||||
stream.write('<meta http-equiv="content-type" content="text/html; charset=utf-8">\n');
|
||||
stream.write(`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">\n`);
|
||||
for (const [key, value] of Object.entries(dataToSave)) {
|
||||
stream.write(`<p><strong>${escapeHTML(key)}</strong>:`);
|
||||
if (typeof value === 'string') {
|
||||
stream.write(escapeHTML(value));
|
||||
} else if (Array.isArray(value)) {
|
||||
stream.write('<br/>');
|
||||
|
||||
for (const item of value) {
|
||||
stream.write(`${escapeHTML(String(item))}<br/>`);
|
||||
}
|
||||
}
|
||||
|
||||
stream.write('</p>\n');
|
||||
}
|
||||
|
||||
stream.end();
|
||||
});
|
||||
};
|
||||
|
||||
const generateUserAvatarFile = async (exportOperation: IExportOperation, userData?: IUser) => {
|
||||
if (!userData?.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(exportOperation.exportPath, { recursive: true });
|
||||
|
||||
const file = await Avatars.findOneByName(userData.username);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = joinPath(exportOperation.exportPath, 'avatar');
|
||||
if (await FileUpload.copy?.(file, filePath)) {
|
||||
exportOperation.generatedAvatar = true;
|
||||
}
|
||||
};
|
||||
|
||||
const generateChannelsFile = async (type: 'json' | 'html', exportPath: string, exportOperation: IExportOperation) => {
|
||||
if (type !== 'json') {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(exportOperation.exportPath, { recursive: true });
|
||||
|
||||
const fileName = joinPath(exportPath, 'channels.json');
|
||||
await writeFile(
|
||||
fileName,
|
||||
exportOperation.roomList
|
||||
?.map((roomData) =>
|
||||
JSON.stringify({
|
||||
roomId: roomData.roomId,
|
||||
roomName: roomData.roomName,
|
||||
type: roomData.type,
|
||||
}),
|
||||
)
|
||||
?.join('\n') ?? '',
|
||||
);
|
||||
};
|
||||
|
||||
const isExportComplete = (exportOperation: IExportOperation) => {
|
||||
const incomplete = exportOperation.roomList?.some((exportOpRoomData) => exportOpRoomData.status !== 'completed') ?? false;
|
||||
|
||||
return !incomplete;
|
||||
};
|
||||
|
||||
const continueExportOperation = async function (exportOperation: IExportOperation): Promise<void> {
|
||||
if (exportOperation.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const exportType = exportOperation.fullExport ? 'json' : 'html';
|
||||
|
||||
if (!exportOperation.roomList) {
|
||||
exportOperation.roomList = await loadUserSubscriptions(exportOperation, exportType, exportOperation.userId);
|
||||
|
||||
if (exportOperation.fullExport) {
|
||||
exportOperation.status = 'exporting-rooms';
|
||||
} else {
|
||||
exportOperation.status = 'exporting';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!exportOperation.generatedUserFile) {
|
||||
await generateUserFile(exportOperation, exportOperation.userData);
|
||||
}
|
||||
|
||||
if (!exportOperation.generatedAvatar) {
|
||||
await generateUserAvatarFile(exportOperation, exportOperation.userData);
|
||||
}
|
||||
|
||||
if (exportOperation.status === 'exporting-rooms') {
|
||||
await generateChannelsFile(exportType, exportOperation.exportPath, exportOperation);
|
||||
|
||||
exportOperation.status = 'exporting';
|
||||
}
|
||||
|
||||
// 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,
|
||||
exportType,
|
||||
exportOperation.roomList,
|
||||
exportOperation.userData,
|
||||
{},
|
||||
exportOperation.userNameTable,
|
||||
);
|
||||
if (!exportOperation.fileList) {
|
||||
exportOperation.fileList = [];
|
||||
}
|
||||
exportOperation.fileList.push(...fileList);
|
||||
|
||||
if (isExportComplete(exportOperation)) {
|
||||
exportOperation.status = 'downloading';
|
||||
}
|
||||
}
|
||||
|
||||
const generatedFileName = randomUUID();
|
||||
const zipFolder = settings.get<string>('UserData_FileSystemZipPath')?.trim() || '/tmp/zipFiles';
|
||||
|
||||
if (exportOperation.status === 'downloading') {
|
||||
for await (const attachmentData of exportOperation.fileList) {
|
||||
await copyFileUpload(attachmentData, exportOperation.assetsPath);
|
||||
}
|
||||
|
||||
const targetFile = joinPath(zipFolder, `${generatedFileName}.zip`);
|
||||
await rm(targetFile, { force: true });
|
||||
|
||||
exportOperation.status = 'compressing';
|
||||
}
|
||||
|
||||
if (exportOperation.status === 'compressing') {
|
||||
await mkdir(zipFolder, { recursive: true });
|
||||
|
||||
exportOperation.generatedFile = joinPath(zipFolder, `${generatedFileName}.zip`);
|
||||
try {
|
||||
await access(exportOperation.generatedFile);
|
||||
} catch (error) {
|
||||
await makeZipFile(exportOperation.exportPath, exportOperation.generatedFile);
|
||||
}
|
||||
|
||||
exportOperation.status = 'uploading';
|
||||
}
|
||||
|
||||
if (exportOperation.status === 'uploading') {
|
||||
if (!exportOperation.generatedFile) {
|
||||
throw new Error('No generated file');
|
||||
}
|
||||
|
||||
const { _id: fileId } = await uploadZipFile(exportOperation.generatedFile, exportOperation.userId, exportType);
|
||||
exportOperation.fileId = fileId;
|
||||
|
||||
exportOperation.status = 'completed';
|
||||
await ExportOperations.updateOperation(exportOperation);
|
||||
}
|
||||
|
||||
await ExportOperations.updateOperation(exportOperation);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
export async function processDataDownloads(): Promise<void> {
|
||||
const operation = await ExportOperations.findOnePending();
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.status !== 'pending') {
|
||||
// If the operation has started but was not updated in over a day, then skip it
|
||||
if (operation._updatedAt && moment().diff(moment(operation._updatedAt), 'days') > 1) {
|
||||
operation.status = 'skipped';
|
||||
await ExportOperations.updateOperation(operation);
|
||||
return processDataDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
await continueExportOperation(operation);
|
||||
await ExportOperations.updateOperation(operation);
|
||||
|
||||
if (operation.status === 'completed') {
|
||||
const file = operation.fileId
|
||||
? await UserDataFiles.findOneById(operation.fileId)
|
||||
: await UserDataFiles.findLastFileByUser(operation.userId);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = i18n.t('UserDataDownload_EmailSubject');
|
||||
const body = i18n.t('UserDataDownload_EmailBody', {
|
||||
download_link: getURL(getPath(file._id), { cdn: false, full: true }),
|
||||
});
|
||||
|
||||
await sendEmail(operation.userData, subject, body);
|
||||
}
|
||||
}
|
||||
27
apps/meteor/server/lib/dataExport/sendEmail.ts
Normal file
27
apps/meteor/server/lib/dataExport/sendEmail.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getUserEmailAddress } from '../../../lib/getUserEmailAddress';
|
||||
import { settings } from '../../settings';
|
||||
import * as Mailer from '../notifications/email/api';
|
||||
|
||||
export const sendEmail = async (userData: Pick<IUser, 'name' | 'emails'>, subject: string, body: string): Promise<void> => {
|
||||
const emailAddress = getUserEmailAddress(userData);
|
||||
|
||||
if (!emailAddress) {
|
||||
throw new Error('no email address found for user');
|
||||
}
|
||||
|
||||
const to = `${userData.name} <${emailAddress}>`;
|
||||
const from = settings.get('From_Email') as string;
|
||||
|
||||
if (!Mailer.checkAddressFormat(emailAddress)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Mailer.send({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
html: body,
|
||||
});
|
||||
};
|
||||
85
apps/meteor/server/lib/dataExport/sendFile.ts
Normal file
85
apps/meteor/server/lib/dataExport/sendFile.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { mkdir, mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
import { i18n } from '../i18n';
|
||||
import { copyFileUpload } from './copyFileUpload';
|
||||
import { exportRoomMessagesToFile } from './exportRoomMessagesToFile';
|
||||
import { getPath } from './getPath';
|
||||
import { getRoomData } from './getRoomData';
|
||||
import { makeZipFile } from './makeZipFile';
|
||||
import { sendEmail } from './sendEmail';
|
||||
import { uploadZipFile } from './uploadZipFile';
|
||||
import { getURL } from '../utils/getURL';
|
||||
|
||||
type ExportFile = {
|
||||
rid: string;
|
||||
dateFrom: Date;
|
||||
dateTo: Date;
|
||||
format: 'html' | 'json';
|
||||
};
|
||||
|
||||
export const sendFile = async (data: ExportFile, user: IUser): Promise<void> => {
|
||||
const exportType = data.format;
|
||||
|
||||
const baseDir = await mkdtemp(join(tmpdir(), 'exportFile-'));
|
||||
|
||||
const exportPath = baseDir;
|
||||
const assetsPath = path.join(baseDir, 'assets');
|
||||
|
||||
await mkdir(exportPath, { recursive: true });
|
||||
await mkdir(assetsPath, { recursive: true });
|
||||
|
||||
const roomData = await getRoomData(data.rid);
|
||||
|
||||
roomData.targetFile = `${(data.format === 'json' && roomData.roomName) || roomData.roomId}.${data.format}`;
|
||||
|
||||
const fullFileList: any[] = [];
|
||||
|
||||
const roomsToExport = [roomData];
|
||||
|
||||
const filter =
|
||||
!data.dateFrom && !data.dateTo
|
||||
? {}
|
||||
: {
|
||||
ts: {
|
||||
...(data.dateFrom && { $gte: data.dateFrom }),
|
||||
...(data.dateTo && { $lte: data.dateTo }),
|
||||
},
|
||||
};
|
||||
|
||||
const exportMessages = async (): Promise<void> => {
|
||||
const { fileList } = await exportRoomMessagesToFile(exportPath, assetsPath, exportType, roomsToExport, user, filter, {}, false);
|
||||
|
||||
fullFileList.push(...fileList);
|
||||
|
||||
const [roomData] = roomsToExport;
|
||||
|
||||
if (roomData.status !== 'completed') {
|
||||
await exportMessages();
|
||||
}
|
||||
};
|
||||
|
||||
await exportMessages();
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
for await (const attachmentData of fullFileList) {
|
||||
promises.push(copyFileUpload(attachmentData, assetsPath));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
const exportFile = `${baseDir}-export.zip`;
|
||||
await makeZipFile(exportPath, exportFile);
|
||||
|
||||
const file = await uploadZipFile(exportFile, user._id, exportType);
|
||||
|
||||
const subject = i18n.t('Channel_Export');
|
||||
|
||||
const body = i18n.t('UserDataDownload_EmailBody', {
|
||||
download_link: getURL(getPath(file._id), { cdn: false, full: true }),
|
||||
});
|
||||
|
||||
await sendEmail(user, subject, body);
|
||||
};
|
||||
86
apps/meteor/server/lib/dataExport/sendViaEmail.ts
Normal file
86
apps/meteor/server/lib/dataExport/sendViaEmail.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { IMessage, IUser } from '@rocket.chat/core-typings';
|
||||
import { Messages, Users } from '@rocket.chat/models';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import moment from 'moment';
|
||||
|
||||
import { settings } from '../../settings';
|
||||
import { getMomentLocale } from '../getMomentLocale';
|
||||
import { Message } from '../messaging/Message';
|
||||
import * as Mailer from '../notifications/email/api';
|
||||
|
||||
export async function sendViaEmail(
|
||||
data: {
|
||||
rid: string;
|
||||
toUsers: string[];
|
||||
toEmails: string[];
|
||||
subject: string;
|
||||
messages: string[];
|
||||
language: string;
|
||||
},
|
||||
user: IUser,
|
||||
): Promise<{
|
||||
missing: string[];
|
||||
}> {
|
||||
const emails = data.toEmails.map((email) => email.trim()).filter(Boolean);
|
||||
|
||||
const missing = [...data.toUsers].filter(Boolean);
|
||||
|
||||
(
|
||||
await Users.findUsersByUsernames(data.toUsers, {
|
||||
projection: { 'username': 1, 'emails.address': 1 },
|
||||
}).toArray()
|
||||
).forEach((user: IUser) => {
|
||||
const emailAddress = user.emails?.[0].address;
|
||||
|
||||
if (!emailAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Mailer.checkAddressFormat(emailAddress)) {
|
||||
throw new Error('error-invalid-email');
|
||||
}
|
||||
|
||||
const found = missing.indexOf(String(user.username));
|
||||
if (found !== -1) {
|
||||
missing.splice(found, 1);
|
||||
}
|
||||
|
||||
emails.push(emailAddress);
|
||||
});
|
||||
|
||||
const email = user.emails?.[0]?.address;
|
||||
const lang = data.language || user.language || 'en';
|
||||
|
||||
const localMoment = moment();
|
||||
|
||||
if (lang !== 'en') {
|
||||
const localeFn = await getMomentLocale(lang);
|
||||
if (localeFn) {
|
||||
Function(localeFn).call({ moment });
|
||||
localMoment.locale(lang);
|
||||
}
|
||||
}
|
||||
|
||||
const html = (
|
||||
await Messages.findByRoomIdAndMessageIds(data.rid, data.messages, {
|
||||
sort: { ts: 1 },
|
||||
}).toArray()
|
||||
)
|
||||
.map((message: IMessage) => {
|
||||
const dateTime = moment(message.ts).locale(lang).format('L LT');
|
||||
return `<p style='margin-bottom: 5px'><b>${escapeHTML(
|
||||
message.u.username ?? '',
|
||||
)}</b> <span style='color: #aaa; font-size: 12px'>${dateTime}</span><br/>${Message.parse(message, data.language)}</p>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
await Mailer.send({
|
||||
to: emails,
|
||||
from: settings.get('From_Email'),
|
||||
replyTo: email,
|
||||
subject: data.subject,
|
||||
html,
|
||||
});
|
||||
|
||||
return { missing };
|
||||
}
|
||||
41
apps/meteor/server/lib/dataExport/uploadZipFile.ts
Normal file
41
apps/meteor/server/lib/dataExport/uploadZipFile.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
|
||||
import { FileUpload } from '../media/file-upload';
|
||||
|
||||
export const uploadZipFile = async (filePath: string, userId: IUser['_id'], exportType: 'json' | 'html'): Promise<any> => {
|
||||
const contentType = 'application/zip';
|
||||
const { size } = await stat(filePath);
|
||||
|
||||
const user = await Users.findOneById(userId);
|
||||
let userDisplayName = userId;
|
||||
if (user) {
|
||||
userDisplayName = user.name || user.username || userId;
|
||||
}
|
||||
|
||||
const utcDate = new Date().toISOString().split('T')[0];
|
||||
const fileSuffix = exportType === 'json' ? '-data' : '';
|
||||
const fileId = Random.id();
|
||||
|
||||
const newFileName = encodeURIComponent(`${utcDate}-${userDisplayName}${fileSuffix}-${fileId}.zip`);
|
||||
|
||||
const details = {
|
||||
_id: fileId,
|
||||
userId,
|
||||
type: contentType,
|
||||
size,
|
||||
name: newFileName,
|
||||
};
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
const userDataStore = FileUpload.getStore('UserDataFiles');
|
||||
|
||||
const file = await userDataStore.insert(details, stream);
|
||||
|
||||
return file;
|
||||
};
|
||||
Reference in New Issue
Block a user