[NEW] Export room messages as file or directly via email (#18606)

pull/18643/head
Diego Sampaio 6 years ago committed by GitHub
parent 41ca8be754
commit 5f17be59b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 67
      app/api/server/v1/rooms.js
  2. 1
      app/authorization/server/startup.js
  3. 3
      app/channel-settings-mail-messages/client/index.js
  4. 20
      app/channel-settings-mail-messages/client/lib/startup.js
  5. 13
      app/channel-settings-mail-messages/client/resetSelection.js
  6. 136
      app/channel-settings-mail-messages/client/views/mailMessagesInstructions.html
  7. 286
      app/channel-settings-mail-messages/client/views/mailMessagesInstructions.js
  8. 2
      app/channel-settings-mail-messages/server/index.js
  9. 11
      app/channel-settings-mail-messages/server/lib/startup.js
  10. 92
      app/channel-settings-mail-messages/server/methods/mailMessages.js
  11. 1
      app/ui-flextab/client/flexTabBar.js
  12. 484
      app/user-data-download/server/cronProcessDownloads.js
  13. 10
      client/channel/ExportMessages/ExportMessages.stories.js
  14. 257
      client/channel/ExportMessages/index.js
  15. 4
      client/channel/adapters.js
  16. 1
      client/importPackages.js
  17. 1
      client/main.js
  18. 18
      client/startup/contextualBar/exportMessages.js
  19. 1
      client/startup/contextualBar/index.js
  20. 29
      package-lock.json
  21. 1
      package.json
  22. 8
      packages/rocketchat-i18n/i18n/en.i18n.json
  23. 1
      server/importPackages.js
  24. 180
      server/lib/channelExport.ts
  25. 15
      server/lib/getMomentLocale.ts
  26. 10
      server/methods/loadLocale.js

@ -5,6 +5,8 @@ import { FileUpload } from '../../../file-upload';
import { Rooms, Messages } from '../../../models';
import { API } from '../api';
import { findAdminRooms, findChannelAndPrivateAutocomplete, findAdminRoom } from '../lib/rooms';
import { sendFile, sendViaEmail } from '../../../../server/lib/channelExport';
import { canAccessRoom, hasPermission } from '../../../authorization/server';
function findRoomByIdOrName({ params, checkedArchived = true }) {
if ((!params.roomId || !params.roomId.trim()) && (!params.roomName || !params.roomName.trim())) {
@ -354,3 +356,68 @@ API.v1.addRoute('rooms.changeArchivationState', { authRequired: true }, {
return API.v1.success({ result });
},
});
API.v1.addRoute('rooms.export', { authRequired: true }, {
post() {
const { rid, type } = this.bodyParams;
if (!rid || !type || !['email', 'file'].includes(type)) {
throw new Meteor.Error('error-invalid-params');
}
if (!hasPermission(this.userId, 'mail-messages')) {
throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed');
}
const room = Rooms.findOneById(rid);
if (!room) {
throw new Meteor.Error('error-invalid-room');
}
const user = Meteor.users.findOne({ _id: this.userId });
if (!canAccessRoom(room, user)) {
throw new Meteor.Error('error-not-allowed', 'Not Allowed');
}
if (type === 'file') {
const { dateFrom, dateTo, format } = this.bodyParams;
if (!['html', 'json'].includes(format)) {
throw new Meteor.Error('error-invalid-format');
}
sendFile({
rid,
format,
...dateFrom && { dateFrom: new Date(dateFrom) },
...dateTo && { dateTo: new Date(dateTo) },
}, user);
return API.v1.success();
}
if (type === 'email') {
const { toUsers, toEmails, subject, messages } = this.bodyParams;
if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) {
throw new Meteor.Error('error-invalid-recipient');
}
if (messages.length === 0) {
throw new Meteor.Error('error-invalid-messages');
}
const result = sendViaEmail({
rid,
toUsers,
toEmails,
subject,
messages,
}, user);
return API.v1.success(result);
}
return API.v1.error();
},
});

@ -118,6 +118,7 @@ Meteor.startup(function() {
{ _id: 'view-livechat-room-customfields', roles: ['livechat-manager', 'livechat-agent', 'admin'] },
{ _id: 'edit-livechat-room-customfields', roles: ['livechat-manager', 'livechat-agent', 'admin'] },
{ _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] },
{ _id: 'mail-messages', roles: ['admin'] },
];
for (const permission of permissions) {

@ -1,3 +0,0 @@
import './lib/startup';
import './views/mailMessagesInstructions.html';
import './views/mailMessagesInstructions';

@ -1,20 +0,0 @@
// import resetSelection from '../resetSelection';
import { Meteor } from 'meteor/meteor';
import { TabBar } from '../../../ui-utils';
import { hasAllPermission } from '../../../authorization';
Meteor.startup(() => {
TabBar.addButton({
groups: ['channel', 'group', 'direct'],
id: 'mail-messages',
anonymous: true,
i18nTitle: 'Mail_Messages',
icon: 'mail',
template: 'mailMessagesInstructions',
order: 12,
condition: () => hasAllPermission('mail-messages'),
});
// RocketChat.callbacks.add('roomExit', () => resetSelection(false), RocketChat.callbacks.priority.MEDIUM, 'room-exit-mail-messages');
});

@ -1,13 +0,0 @@
import { Blaze } from 'meteor/blaze';
export default function resetSelection(reset) {
const [el] = $('.messages-box');
if (!el) {
return;
}
const view = Blaze.getView(el);
if (view && typeof view.templateInstance === 'function') {
const { resetSelection } = view.templateInstance();
typeof resetSelection === 'function' && resetSelection(reset);
}
}

@ -1,136 +0,0 @@
<template name="mailMessagesInstructions">
<main class="rc-user-info__scroll">
{{#if selectedMessages}}
<div class="mail-messages__instructions mail-messages__instructions--selected">
<div class="mail-messages__instructions-wrapper">
{{> icon block="mail-messages__instructions-icon rc-icon--default-size" icon="checkmark-circled"}}
<div class="mail-messages__instructions-text">
<span class="mail-messages__instructions-text-selected">{{selectedMessages.length}} Messages selected</span>
<span>Click here to clear the selection</span>
</div>
</div>
</div>
{{else}}
<div class="mail-messages__instructions">
<div class="mail-messages__instructions-wrapper">
{{> icon block="mail-messages__instructions-icon rc-icon--default-size" icon="hand-pointer"}}
<div class="mail-messages__instructions-text">
{{_ "Click_the_messages_you_would_like_to_send_by_email"}}
</div>
</div>
</div>
{{/if}}
<div class="rc-input rc-input--usernames">
<label class="rc-input__label">
<div class="rc-input__title">{{_ "To_users"}}</div>
<div class="rc-input__wrapper">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg rc-icon--default-size" icon="at"}}
</div>
<div class="rc-tags">
{{#each user in selectedUsers}}
{{> tag user}}
{{/each}}
<input type="text" class="rc-tags__input" placeholder="{{_ "Username_Placeholder"}}" name="users" autocomplete="off"/>
</div>
</div>
{{#with config}}
{{#if autocomplete 'isShowing'}}
{{> popupList data=config items=items ready=(autocomplete 'isLoaded')}}
{{/if}}
{{/with}}
</label>
</div>
<div class="rc-input rc-input--emails">
<label class="rc-input__label">
<div class="rc-input__title">{{_ "To_additional_emails"}}</div>
<div class="rc-input__wrapper">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg rc-icon--default-size" icon="mail"}}
</div>
<div class="rc-tags">
{{#each selectedEmails}}
{{> tag .}}
{{/each}}
<input type="text" class="rc-tags__input" placeholder="{{_ "Email_Placeholder_any"}}" name="emails" autocomplete="off"/>
</div>
</div>
</label>
</div>
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ "Subject"}}</div>
<div class="rc-input__wrapper">
<div class="rc-input__icon">
{{> icon block="rc-input__icon-svg rc-icon--default-size" icon="edit"}}
</div>
<input type="text" class="rc-input__element" value="{{_ "Mail_Messages_Subject" roomName}}" name="subject" autocomplete="off"/>
</div>
</label>
</div>
{{#if errorMessage}}
<div class="mail-messages__instructions mail-messages__instructions--warning">
<div class="mail-messages__instructions-wrapper">
<div class="mail-messages__instructions-text">{{errorMessage}}</div>
</div>
</div>
{{/if}}
</main>
<div class="rc-user-info__flex rc-user-info__row">
<button class="rc-button rc-button--outline js-cancel" title="{{_ 'Cancel'}}">{{_ 'Cancel'}}</button>
<button class="rc-button rc-button--primary js-send" title="{{_ 'Send'}}">{{_ 'Send'}}</button>
</div>
{{#if false}}
<div class="content">
<div class="list-view mail-message">
<p>{{_ "Mail_Messages_Instructions"}}</p>
<form>
<fieldset>
<div class="input-line double-col">
<label>{{_ "From"}}</label>
<div>{{name}}</div>
<div>{{email}}</div>
</div>
<div class="input-line double-col">
<label>{{_ "To_users"}}</label>
<div>
{{> inputAutocomplete settings=autocompleteSettings id="to_users" name="to_users" class="search" autocomplete="off"}}
<ul class="selected-users">
{{#each selectedUsers}}
<li>{{.}} <i class="icon-cancel remove-to-user"></i></li>
{{/each}}
</ul>
</div>
</div>
<div class="input-line double-col">
<label>{{_ "Additional_emails"}}</label>
<div>
<input type="text" name="to_emails" value="" class="rc-input__element"/>
</div>
</div>
<div class="input-line double-col">
<label>{{_ "Subject"}}</label>
<div>
<input type="text" name="subject" class="rc-input__element" value="{{_ "Mail_Messages_Subject" roomName}}" />
</div>
</div>
</fieldset>
</form>
<div class="error error-missing-to alert error-color error-background error-border" style="display: none">
{{_ "Mail_Message_Missing_to"}}
</div>
<div class="error error-invalid-emails alert error-color error-background error-border" style="display: none">
{{_ "Mail_Message_Invalid_emails" erroredEmails}}
</div>
<div class="error error-select alert error-color error-background error-border" style="display: none">
{{{_ "Mail_Message_No_messages_selected_select_all"}}}
</div>
<p style="margin-top: 30px">
<button class="rc-button rc-button--outline cancel">{{_ 'Cancel'}}</button>
<button class="rc-button rc-button--primary send">{{_ 'Send'}}</button>
</p>
</div>
</div>
{{/if}}
</template>

@ -1,286 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { Blaze } from 'meteor/blaze';
import { Session } from 'meteor/session';
import { Template } from 'meteor/templating';
import { Deps } from 'meteor/deps';
import toastr from 'toastr';
import { ChatRoom } from '../../../models';
import { t, isEmail, handleError, roomTypes } from '../../../utils';
import { settings } from '../../../settings';
import resetSelection from '../resetSelection';
import { AutoComplete } from '../../../meteor-autocomplete/client';
const filterNames = (old) => {
const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`);
return [...old.replace(' ', '').toLocaleLowerCase()].filter((f) => reg.test(f)).join('');
};
Template.mailMessagesInstructions.helpers({
name() {
return Meteor.user().name;
},
email() {
const { emails } = Meteor.user();
return emails && emails[0] && emails[0].address;
},
roomName() {
const room = ChatRoom.findOne(Session.get('openedRoom'));
return room && roomTypes.getRoomName(room.t, room);
},
erroredEmails() {
const instance = Template.instance();
return instance && instance.erroredEmails.get().join(', ');
},
autocompleteSettings() {
return {
limit: 10,
rules: [
{
collection: 'CachedChannelList',
endpoint: 'users.autocomplete',
field: 'username',
template: Template.userSearch,
noMatchTemplate: Template.userSearchEmpty,
matchAll: true,
filter: {
exceptions: Template.instance().selectedUsers.get(),
},
selector(match) {
return {
term: match,
};
},
sort: 'username',
},
],
};
},
selectedUsers() {
return Template.instance().selectedUsers.get();
},
selectedEmails() {
return Template.instance().selectedEmails.get();
},
selectedMessages() {
return Template.instance().selectedMessages.get();
},
config() {
const filter = Template.instance().userFilter;
return {
filter: filter.get(),
noMatchTemplate: 'userSearchEmpty',
modifier(text) {
const f = filter.get();
return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), function(part) {
return `<strong>${ part }</strong>`;
}) }`;
},
};
},
autocomplete(key) {
const instance = Template.instance();
const param = instance.ac[key];
return typeof param === 'function' ? param.apply(instance.ac) : param;
},
items() {
return Template.instance().ac.filteredList();
},
errorMessage() {
return Template.instance().errorMessage.get();
},
});
Template.mailMessagesInstructions.events({
'click .mail-messages__instructions--selected'(e, t) {
t.reset(true);
},
'click .js-cancel'(e, t) {
t.reset(true);
t.data.tabBar.close();
},
'click .js-send'(e, instance) {
const { selectedUsers, selectedEmails, selectedMessages } = instance;
const $emailsInput = instance.$('[name="emails"]');
const subject = instance.$('[name="subject"]').val();
if (!selectedUsers.get().length && !selectedEmails.get().length && $emailsInput.val().trim() === '') {
instance.errorMessage.set(t('Mail_Message_Missing_to'));
return false;
}
if ($emailsInput.val() !== '') {
if (isEmail($emailsInput.val())) {
const emailsArr = selectedEmails.get();
emailsArr.push({ text: $emailsInput.val() });
$('[name="emails"]').val('');
selectedEmails.set(emailsArr);
} else {
instance.errorMessage.set(t('Mail_Message_Invalid_emails', $emailsInput.val()));
return false;
}
}
if (!selectedMessages.get().length) {
instance.errorMessage.set(t('Mail_Message_No_messages_selected_select_all'));
return false;
}
const data = {
rid: Session.get('openedRoom'),
to_users: selectedUsers.get().map((user) => user.username),
to_emails: selectedEmails.get().map((email) => email.text).toString(),
subject,
messages: selectedMessages.get(),
language: Meteor._localStorage.getItem('userLanguage'),
};
Meteor.call('mailMessages', data, function(err, result) {
if (err != null) {
return handleError(err);
}
console.log(result);
toastr.success(t('Your_email_has_been_queued_for_sending'));
instance.reset(true);
});
},
'click .rc-input--usernames .rc-tags__tag'({ target }, t) {
const { username } = Blaze.getData(target);
t.selectedUsers.set(t.selectedUsers.get().filter((user) => user.username !== username));
},
'click .rc-input--emails .rc-tags__tag'({ target }, t) {
const { text } = Blaze.getData(target);
t.selectedEmails.set(t.selectedEmails.get().filter((email) => email.text !== text));
},
'click .rc-popup-list__item'(e, t) {
t.ac.onItemClick(this, e);
},
'input [name="users"]'(e, t) {
const input = e.target;
const position = input.selectionEnd || input.selectionStart;
const { length } = input.value;
const modified = filterNames(input.value);
input.value = modified;
document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length);
t.userFilter.set(modified);
},
'keydown [name="emails"]'(e, t) {
const input = e.target;
if ([9, 13, 188].includes(e.keyCode) && isEmail(input.value)) {
e.preventDefault();
const emails = t.selectedEmails;
const emailsArr = emails.get();
emailsArr.push({ text: input.value });
input.value = '';
return emails.set(emailsArr);
}
if ([8, 46].includes(e.keyCode) && input.value === '') {
const emails = t.selectedEmails;
const emailsArr = emails.get();
emailsArr.pop();
return emails.set(emailsArr);
}
},
'keydown [name="users"]'(e, t) {
if ([8, 46].includes(e.keyCode) && e.target.value === '') {
const users = t.selectedUsers;
const usersArr = users.get();
usersArr.pop();
return users.set(usersArr);
}
t.ac.onKeyDown(e);
},
'keyup [name="users"]'(e, t) {
t.ac.onKeyUp(e);
},
'focus [name="users"]'(e, t) {
t.ac.onFocus(e);
},
'blur [name="users"]'(e, t) {
t.ac.onBlur(e);
},
});
Template.mailMessagesInstructions.onRendered(function() {
const users = this.selectedUsers;
this.firstNode.querySelector('[name="users"]').focus();
this.ac.element = this.firstNode.querySelector('[name="users"]');
this.ac.$element = $(this.ac.element);
this.ac.$element.on('autocompleteselect', function(e, { item }) {
const usersArr = users.get();
usersArr.push(item);
users.set(usersArr);
});
const { selectedMessages } = this;
$('.messages-box .message').on('click', function() {
const { id } = this;
const messages = selectedMessages.get();
if ($(this).hasClass('selected')) {
selectedMessages.set(messages.filter((message) => message !== id));
} else {
selectedMessages.set(messages.concat(id));
}
});
});
Template.mailMessagesInstructions.onCreated(function() {
resetSelection(true);
this.selectedEmails = new ReactiveVar([]);
this.selectedMessages = new ReactiveVar([]);
this.errorMessage = new ReactiveVar('');
this.selectedUsers = new ReactiveVar([]);
this.userFilter = new ReactiveVar('');
const filter = { exceptions: this.selectedUsers.get().map((u) => u.username) };
Deps.autorun(() => {
filter.exceptions = this.selectedUsers.get().map((u) => u.username);
});
this.ac = new AutoComplete(
{
selector: {
item: '.rc-popup-list__item',
container: '.rc-popup-list__list',
},
position: 'fixed',
limit: 10,
inputDelay: 300,
rules: [
{
collection: 'UserAndRoom',
endpoint: 'users.autocomplete',
field: 'username',
matchAll: true,
filter,
doNotChangeWidth: false,
selector(match) {
return { term: match };
},
sort: 'username',
},
],
});
this.ac.tmplInst = this;
this.reset = (bool) => {
this.selectedUsers.set([]);
this.selectedEmails.set([]);
this.selectedMessages.set([]);
this.errorMessage.set('');
resetSelection(bool);
};
});
Template.mailMessagesInstructions.onDestroyed(function() {
Template.instance().reset(false);
});

@ -1,2 +0,0 @@
import './lib/startup';
import './methods/mailMessages';

@ -1,11 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { Permissions } from '../../../models';
Meteor.startup(function() {
const permission = {
_id: 'mail-messages',
roles: ['admin'],
};
return Permissions.create(permission._id, permission.roles);
});

@ -1,92 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import _ from 'underscore';
import moment from 'moment';
import { hasPermission } from '../../../authorization';
import { Users, Messages } from '../../../models';
import { settings } from '../../../settings';
import { Message } from '../../../ui-utils';
import * as Mailer from '../../../mailer';
Meteor.methods({
'mailMessages'(data) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'mailMessages',
});
}
check(data, Match.ObjectIncluding({
rid: String,
to_users: [String],
to_emails: String,
subject: String,
messages: [String],
language: String,
}));
const room = Meteor.call('canAccessRoom', data.rid, userId);
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'mailMessages',
});
}
if (!hasPermission(userId, 'mail-messages')) {
throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed', {
method: 'mailMessages',
action: 'Mailing',
});
}
const emails = _.compact(data.to_emails.trim().split(','));
const missing = [];
if (data.to_users.length > 0) {
_.each(data.to_users, (username) => {
const user = Users.findOneByUsernameIgnoringCase(username);
if (user && user.emails && user.emails[0] && user.emails[0].address) {
emails.push(user.emails[0].address);
} else {
missing.push(username);
}
});
}
_.each(emails, (email) => {
if (!Mailer.checkAddressFormat(email.trim())) {
throw new Meteor.Error('error-invalid-email', `Invalid email ${ email }`, {
method: 'mailMessages',
email,
});
}
});
const user = Meteor.user();
const email = user.emails && user.emails[0] && user.emails[0].address;
data.language = data.language.split('-').shift().toLowerCase();
if (data.language !== 'en') {
const localeFn = Meteor.call('loadLocale', data.language);
if (localeFn) {
Function(localeFn).call({ moment });
moment.locale(data.language);
}
}
const html = Messages.findByRoomIdAndMessageIds(data.rid, data.messages, {
sort: { ts: 1 },
}).map(function(message) {
const dateTime = moment(message.ts).locale(data.language).format('L LT');
return `<p style='margin-bottom: 5px'><b>${ message.u.username }</b> <span style='color: #aaa; font-size: 12px'>${ dateTime }</span><br/>${ Message.parse(message, data.language) }</p>`;
}).join('');
Mailer.send({
to: emails,
from: settings.get('From_Email'),
replyTo: email,
subject: data.subject,
html,
});
return {
success: true,
missing,
};
},
});

@ -132,6 +132,7 @@ const action = function(e, t) {
}
$flexTab.attr('template', this.template);
t.tabBar.setData({
...this,
label: this.i18nTitle,
icon: this.icon,
});

@ -1,4 +1,5 @@
import fs from 'fs';
import util from 'util';
import path from 'path';
import _ from 'underscore';
@ -8,11 +9,16 @@ import { SyncedCron } from 'meteor/littledata:synced-cron';
import archiver from 'archiver';
import moment from 'moment';
import { settings } from '../../settings';
import { Subscriptions, Rooms, Users, Uploads, Messages, UserDataFiles, ExportOperations, Avatars } from '../../models';
import { FileUpload } from '../../file-upload';
import { settings } from '../../settings/server';
import { Subscriptions, Rooms, Users, Uploads, Messages, UserDataFiles, ExportOperations, Avatars } from '../../models/server';
import { FileUpload } from '../../file-upload/server';
import * as Mailer from '../../mailer';
const fsStat = util.promisify(fs.stat);
const fsOpen = util.promisify(fs.open);
const fsExists = util.promisify(fs.exists);
const fsUnlink = util.promisify(fs.unlink);
let zipFolder = '/tmp/zipFiles';
if (settings.get('UserData_FileSystemZipPath') != null) {
if (settings.get('UserData_FileSystemZipPath').trim() !== '') {
@ -39,39 +45,45 @@ const createDir = function(folderName) {
}
};
const loadUserSubscriptions = function(exportOperation) {
exportOperation.roomList = [];
export const getRoomData = (roomId, ownUserId) => {
const roomData = Rooms.findOneById(roomId);
if (!roomData) {
return {};
}
const roomName = roomData.name && roomData.t !== 'd' ? roomData.name : roomId;
const [userId] = roomData.t === 'd' ? roomData.uids.filter((uid) => uid !== ownUserId) : [null];
return {
roomId,
roomName,
userId,
exportedCount: 0,
status: 'pending',
type: roomData.t,
targetFile: '',
};
};
const exportUserId = exportOperation.userId;
const cursor = Subscriptions.findByUserId(exportUserId);
export const loadUserSubscriptions = function(exportOperation, fileType, userId) {
const roomList = [];
const cursor = Subscriptions.findByUserId(userId);
cursor.forEach((subscription) => {
const roomId = subscription.rid;
const roomData = Rooms.findOneById(roomId);
const roomName = roomData && roomData.name && subscription.t !== 'd' ? roomData.name : roomId;
const [userId] = subscription.t === 'd' ? roomData.uids.filter((uid) => uid !== exportUserId) : [null];
const fileName = exportOperation.fullExport ? roomId : roomName;
const fileType = exportOperation.fullExport ? 'json' : 'html';
const targetFile = `${ fileName }.${ fileType }`;
exportOperation.roomList.push({
roomId,
roomName,
userId,
exportedCount: 0,
status: 'pending',
const roomData = getRoomData(subscription.rid, userId);
const targetFile = `${ (fileType === 'json' && roomData.roomName) || subscription.rid }.${ fileType }`;
roomList.push({
...roomData,
targetFile,
type: subscription.t,
});
});
if (exportOperation.fullExport) {
exportOperation.status = 'exporting-rooms';
} else {
exportOperation.status = 'exporting';
}
return roomList;
};
const getAttachmentData = function(attachment) {
const getAttachmentData = function(attachment, message) {
const attachmentData = {
type: attachment.type,
title: attachment.title,
@ -95,82 +107,44 @@ const getAttachmentData = function(attachment) {
const url = attachment.title_link || attachment.image_url || attachment.audio_url || attachment.video_url || attachment.message_link;
if (url) {
attachmentData.url = url;
}
const urlMatch = /\:\/\//.exec(url);
if (urlMatch && urlMatch.length > 0) {
attachmentData.remote = true;
} else {
const match = /^\/([^\/]+)\/([^\/]+)\/(.*)/.exec(url);
if (match && match[2]) {
const file = Uploads.findOneById(match[2]);
if (file) {
attachmentData.fileId = file._id;
attachmentData.fileName = file.name;
}
}
}
if (message.file?._id) {
attachmentData.fileId = message.file._id;
attachmentData.fileName = message.file.name;
} else {
attachmentData.remote = true;
}
return attachmentData;
};
const addToFileList = function(exportOperation, attachment) {
const targetFile = path.join(exportOperation.assetsPath, `${ attachment.fileId }-${ attachment.fileName }`);
const attachmentData = {
url: attachment.url,
copied: false,
remote: attachment.remote,
fileId: attachment.fileId,
fileName: attachment.fileName,
targetFile,
};
exportOperation.fileList.push(attachmentData);
};
const hideUserName = function(username, exportOperation) {
if (!exportOperation.userNameTable) {
exportOperation.userNameTable = {};
const hideUserName = function(username, userData, usersMap) {
if (!usersMap.userNameTable) {
usersMap.userNameTable = {};
}
if (!exportOperation.userNameTable[username]) {
if (exportOperation.userData && username === exportOperation.userData.username) {
exportOperation.userNameTable[username] = username;
if (!usersMap.userNameTable[username]) {
if (userData && username === userData.username) {
usersMap.userNameTable[username] = username;
} else {
exportOperation.userNameTable[username] = `User_${ Object.keys(exportOperation.userNameTable).length + 1 }`;
usersMap.userNameTable[username] = `User_${ Object.keys(usersMap.userNameTable).length + 1 }`;
}
}
return exportOperation.userNameTable[username];
return usersMap.userNameTable[username];
};
const getMessageData = function(msg, exportOperation) {
const attachments = [];
if (msg.attachments) {
msg.attachments.forEach((attachment) => {
const attachmentData = getAttachmentData(attachment);
attachments.push(attachmentData);
addToFileList(exportOperation, attachmentData);
});
}
const username = hideUserName(msg.u.username || msg.u.name, exportOperation);
const getMessageData = function(msg, hideUsers, userData, usersMap) {
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)) },
};
if (attachments && attachments.length > 0) {
messageObject.attachments = attachments;
}
if (msg.t) {
messageObject.type = msg.t;
@ -182,13 +156,13 @@ const getMessageData = function(msg, exportOperation) {
messageObject.msg = TAPi18n.__('User_left');
break;
case 'au':
messageObject.msg = TAPi18n.__('User_added_by', { user_added: hideUserName(msg.msg, exportOperation), user_by: username });
messageObject.msg = TAPi18n.__('User_added_by', { user_added: hideUserName(msg.msg, userData, usersMap), user_by: username });
break;
case 'r':
messageObject.msg = TAPi18n.__('Room_name_changed', { room_name: msg.msg, user_by: username });
break;
case 'ru':
messageObject.msg = TAPi18n.__('User_removed_by', { user_removed: hideUserName(msg.msg, exportOperation), user_by: username });
messageObject.msg = TAPi18n.__('User_removed_by', { user_removed: hideUserName(msg.msg, userData, usersMap), user_by: username });
break;
case 'wm':
messageObject.msg = TAPi18n.__('Welcome', { user: username });
@ -202,131 +176,93 @@ const getMessageData = function(msg, exportOperation) {
return messageObject;
};
const copyFile = function(exportOperation, attachmentData) {
if (attachmentData.copied || attachmentData.remote || !attachmentData.fileId) {
attachmentData.copied = true;
export const copyFile = function(attachmentData, assetsPath) {
const file = Uploads.findOneById(attachmentData._id);
if (!file) {
return;
}
FileUpload.copy(file, path.join(assetsPath, `${ attachmentData._id }-${ attachmentData.name }`));
};
const file = Uploads.findOneById(attachmentData.fileId);
if (file) {
if (FileUpload.copy(file, attachmentData.targetFile)) {
attachmentData.copied = true;
}
const exportMessageObject = (type, messageObject, messageFile) => {
if (type === 'json') {
return JSON.stringify(messageObject);
}
};
const continueExportingRoom = function(exportOperation, exportOpRoomData) {
createDir(exportOperation.exportPath);
createDir(exportOperation.assetsPath);
const file = [];
const filePath = path.join(exportOperation.exportPath, exportOpRoomData.targetFile);
const messageType = messageObject.type;
const timestamp = messageObject.ts ? new Date(messageObject.ts).toUTCString() : '';
if (exportOpRoomData.status === 'pending') {
exportOpRoomData.status = 'exporting';
startFile(filePath, '');
if (!exportOperation.fullExport) {
writeToFile(filePath, '<meta http-equiv="content-type" content="text/html; charset=utf-8">');
}
}
const italicTypes = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close'];
let limit = 1000;
if (settings.get('UserData_MessageLimitPerRequest') > 0) {
limit = settings.get('UserData_MessageLimitPerRequest');
}
const message = italicTypes.includes(messageType) ? `<i>${ messageObject.msg }</i>` : messageObject.msg;
const skip = exportOpRoomData.exportedCount;
const cursor = Messages.model.rawCollection().aggregate([
{ $match: { rid: exportOpRoomData.roomId } },
{ $sort: { ts: 1 } },
{ $skip: skip },
{ $limit: limit },
]);
file.push(`<p><strong>${ messageObject.username }</strong> (${ timestamp }):<br/>`);
file.push(message);
const findCursor = Messages.findByRoomId(exportOpRoomData.roomId, { limit: 1 });
const count = findCursor.count();
if (messageFile?._id) {
const attachment = messageObject.attachments.find((att) => att.type === 'file' && att.title_link.includes(messageFile._id));
cursor.forEach(Meteor.bindEnvironment((msg) => {
const messageObject = getMessageData(msg, exportOperation);
const description = attachment.description || attachment.title || TAPi18n.__('Message_Attachments');
if (exportOperation.fullExport) {
const messageString = JSON.stringify(messageObject);
writeToFile(filePath, `${ messageString }\n`);
} else {
const messageType = messageObject.type;
const timestamp = msg.ts ? new Date(msg.ts).toUTCString() : '';
let message = messageObject.msg;
const assetUrl = `./assets/${ messageFile._id }-${ messageFile.name }`;
const link = `<br/><a href="${ assetUrl }">${ description }</a>`;
file.push(link);
}
const italicTypes = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close'];
file.push('</p>');
if (italicTypes.includes(messageType)) {
message = `<i>${ message }</i>`;
}
return file.join('\n');
};
writeToFile(filePath, `<p><strong>${ messageObject.username }</strong> (${ timestamp }):<br/>`);
writeToFile(filePath, message);
export async function exportRoomMessages(rid, exportType, skip, limit, assetsPath, exportOpRoomData, userData, filter = {}, usersMap = {}, hideUsers = true) {
const query = { ...filter, rid };
if (messageObject.attachments && messageObject.attachments.length > 0) {
messageObject.attachments.forEach((attachment) => {
if (attachment.type === 'file') {
const description = attachment.description || attachment.title || TAPi18n.__('Message_Attachments');
const cursor = Messages.model.rawCollection().find(query, {
sort: { ts: 1 },
skip,
limit,
});
const assetUrl = `./assets/${ attachment.fileId }-${ attachment.fileName }`;
const link = `<br/><a href="${ assetUrl }">${ description }</a>`;
writeToFile(filePath, link);
}
});
}
const total = await cursor.count();
const results = await cursor.toArray();
writeToFile(filePath, '</p>');
const result = {
total,
exported: results.length,
messages: [],
uploads: [],
};
results.forEach(Meteor.bindEnvironment((msg) => {
const messageObject = getMessageData(msg, hideUsers, userData, usersMap);
if (msg.file) {
result.uploads.push(msg.file);
}
exportOpRoomData.exportedCount++;
result.messages.push(exportMessageObject(exportType, messageObject, msg.file));
}));
if (count <= exportOpRoomData.exportedCount) {
exportOpRoomData.status = 'completed';
return true;
}
return false;
};
return result;
}
const isExportComplete = function(exportOperation) {
export const isExportComplete = function(exportOperation) {
const incomplete = exportOperation.roomList.some((exportOpRoomData) => exportOpRoomData.status !== 'completed');
return !incomplete;
};
const isDownloadFinished = function(exportOperation) {
const anyDownloadPending = exportOperation.fileList.some((fileData) => !fileData.copied && !fileData.remote);
return !anyDownloadPending;
};
const sendEmail = function(userId, fileId) {
const file = fileId ? UserDataFiles.findOneById(fileId) : UserDataFiles.findLastFileByUser(userId);
if (!file) {
return;
}
const userData = Users.findOneById(userId);
if (!userData || !userData.emails || !userData.emails[0] || !userData.emails[0].address) {
return;
}
export const sendEmail = function(userData, subject, body) {
const emailAddress = `${ userData.name } <${ userData.emails[0].address }>`;
const fromAddress = settings.get('From_Email');
const subject = TAPi18n.__('UserDataDownload_EmailSubject');
const download_link = file.url;
const body = TAPi18n.__('UserDataDownload_EmailBody', { download_link });
if (!Mailer.checkAddressFormat(emailAddress)) {
return;
}
return Mailer.sendNoWrap({
return Mailer.send({
to: emailAddress,
from: fromAddress,
subject,
@ -334,48 +270,28 @@ const sendEmail = function(userId, fileId) {
});
};
const makeZipFile = function(exportOperation) {
createDir(zipFolder);
const targetFile = path.join(zipFolder, `${ exportOperation.userId }.zip`);
if (fs.existsSync(targetFile)) {
exportOperation.status = 'uploading';
return;
}
export const makeZipFile = function(folderToZip, targetFile) {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(targetFile);
exportOperation.generatedFile = targetFile;
const archive = archiver('zip');
output.on('close', () => {
exportOperation.status = 'uploading';
resolve();
});
output.on('close', () => resolve());
archive.on('error', (err) => {
reject(err);
});
archive.on('error', (err) => reject(err));
archive.pipe(output);
archive.directory(exportOperation.exportPath, false);
archive.directory(folderToZip, false);
archive.finalize();
});
};
const uploadZipFile = function(exportOperation, callback) {
const userDataStore = FileUpload.getStore('UserDataFiles');
const filePath = exportOperation.generatedFile;
const stat = Meteor.wrapAsync(fs.stat)(filePath);
const stream = fs.createReadStream(filePath);
export const uploadZipFile = async function(filePath, userId, exportType) {
const stat = await fsStat(filePath);
const contentType = 'application/zip';
const { size } = stat;
const { userId } = exportOperation;
const user = Users.findOneById(userId);
let userDisplayName = userId;
if (user) {
@ -383,7 +299,7 @@ const uploadZipFile = function(exportOperation, callback) {
}
const utcDate = new Date().toISOString().split('T')[0];
const fileSuffix = exportOperation.fullExport ? '-data' : '';
const fileSuffix = exportType === 'json' ? '-data' : '';
const newFileName = encodeURIComponent(`${ utcDate }-${ userDisplayName }${ fileSuffix }.zip`);
@ -394,43 +310,81 @@ const uploadZipFile = function(exportOperation, callback) {
name: newFileName,
};
const file = userDataStore.insert(details, stream, (err) => {
if (err) {
throw new Meteor.Error('invalid-file', 'Invalid Zip File', { method: 'cronProcessDownloads.uploadZipFile' });
} else {
callback();
}
});
const fd = await fsOpen(filePath);
const stream = fs.createReadStream('', { fd });
const userDataStore = FileUpload.getStore('UserDataFiles');
const file = userDataStore.insertSync(details, stream);
exportOperation.fileId = file._id;
fs.close(fd);
return file;
};
const generateChannelsFile = function(exportOperation) {
if (exportOperation.fullExport) {
const fileName = path.join(exportOperation.exportPath, 'channels.json');
startFile(fileName, '');
const generateChannelsFile = function(type, exportPath, exportOperation) {
if (type !== 'json') {
return;
}
exportOperation.roomList.forEach((roomData) => {
const newRoomData = {
const fileName = path.join(exportPath, 'channels.json');
startFile(fileName,
exportOperation.roomList.map((roomData) =>
JSON.stringify({
roomId: roomData.roomId,
roomName: roomData.roomName,
type: roomData.type,
};
}),
).join('\n'));
};
const messageString = JSON.stringify(newRoomData);
writeToFile(fileName, `${ messageString }\n`);
});
export const exportRoomMessagesToFile = async function(exportPath, assetsPath, exportType, roomList, userData, messagesFilter = {}, usersMap = {}, hideUsers = true) {
createDir(exportPath);
createDir(assetsPath);
const result = {
fileList: [],
};
const limit = settings.get('UserData_MessageLimitPerRequest') > 0 ? settings.get('UserData_MessageLimitPerRequest') : 1000;
for (const exportOpRoomData of roomList) {
const filePath = path.join(exportPath, exportOpRoomData.targetFile);
if (exportOpRoomData.status === 'pending') {
exportOpRoomData.status = 'exporting';
startFile(filePath, exportType === 'html' ? '<meta http-equiv="content-type" content="text/html; charset=utf-8">' : '');
}
const skip = exportOpRoomData.exportedCount;
const {
total,
exported,
uploads,
messages,
// eslint-disable-next-line no-await-in-loop
} = await exportRoomMessages(exportOpRoomData.roomId, exportType, skip, limit, assetsPath, exportOpRoomData, userData, messagesFilter, usersMap, hideUsers);
result.fileList.push(...uploads);
exportOpRoomData.exportedCount += exported;
if (total <= exportOpRoomData.exportedCount) {
exportOpRoomData.status = 'completed';
}
writeToFile(filePath, `${ messages.join('\n') }\n`);
}
exportOperation.status = 'exporting';
return result;
};
const generateUserFile = function(exportOperation) {
if (!exportOperation.userData) {
const generateUserFile = function(exportOperation, userData) {
if (!userData) {
return;
}
const { username, name, statusText, emails, roles, services } = exportOperation.userData;
const { username, name, statusText, emails, roles, services } = userData;
const dataToSave = {
username,
@ -474,12 +428,12 @@ const generateUserFile = function(exportOperation) {
}
};
const generateUserAvatarFile = function(exportOperation) {
if (!exportOperation.userData) {
const generateUserAvatarFile = function(exportOperation, userData) {
if (!userData) {
return;
}
const file = Avatars.findOneByName(exportOperation.userData.username);
const file = Avatars.findOneByName(userData.username);
if (!file) {
return;
}
@ -495,28 +449,48 @@ const continueExportOperation = async function(exportOperation) {
return;
}
const exportType = exportOperation.fullExport ? 'json' : 'html';
if (!exportOperation.roomList) {
loadUserSubscriptions(exportOperation);
exportOperation.roomList = loadUserSubscriptions(exportOperation, exportType, exportOperation.userId);
if (exportOperation.fullExport) {
exportOperation.status = 'exporting-rooms';
} else {
exportOperation.status = 'exporting';
}
}
try {
if (!exportOperation.generatedUserFile) {
generateUserFile(exportOperation);
generateUserFile(exportOperation, exportOperation.userData);
}
if (!exportOperation.generatedAvatar) {
generateUserAvatarFile(exportOperation);
generateUserAvatarFile(exportOperation, exportOperation.userData);
}
if (exportOperation.status === 'exporting-rooms') {
generateChannelsFile(exportOperation);
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') {
exportOperation.roomList.forEach((exportOpRoomData) => {
continueExportingRoom(exportOperation, exportOpRoomData);
});
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';
@ -525,28 +499,34 @@ const continueExportOperation = async function(exportOperation) {
if (exportOperation.status === 'downloading') {
exportOperation.fileList.forEach((attachmentData) => {
copyFile(exportOperation, attachmentData);
copyFile(attachmentData, exportOperation.assetsPath);
});
if (isDownloadFinished(exportOperation)) {
const targetFile = path.join(zipFolder, `${ exportOperation.userId }.zip`);
if (fs.existsSync(targetFile)) {
fs.unlinkSync(targetFile);
}
exportOperation.status = 'compressing';
const targetFile = path.join(zipFolder, `${ exportOperation.userId }.zip`);
if (await fsExists(targetFile)) {
await fsUnlink(targetFile);
}
exportOperation.status = 'compressing';
}
if (exportOperation.status === 'compressing') {
await makeZipFile(exportOperation);
createDir(zipFolder);
exportOperation.generatedFile = path.join(zipFolder, `${ exportOperation.userId }.zip`);
if (!await fsExists(exportOperation.generatedFile)) {
await makeZipFile(exportOperation.exportPath, exportOperation.generatedFile);
}
exportOperation.status = 'uploading';
}
if (exportOperation.status === 'uploading') {
uploadZipFile(exportOperation, () => {
exportOperation.status = 'completed';
ExportOperations.updateOperation(exportOperation);
});
const { _id: fileId } = await uploadZipFile(exportOperation.generatedFile, exportOperation.userId, exportType);
exportOperation.fileId = fileId;
exportOperation.status = 'completed';
ExportOperations.updateOperation(exportOperation);
}
ExportOperations.updateOperation(exportOperation);
@ -578,7 +558,15 @@ async function processDataDownloads() {
await ExportOperations.updateOperation(operation);
if (operation.status === 'completed') {
sendEmail(operation.userId, operation.fileId);
const file = operation.fileId ? UserDataFiles.findOneById(operation.fileId) : UserDataFiles.findLastFileByUser(operation.userId);
if (!file) {
return;
}
const subject = TAPi18n.__('UserDataDownload_EmailSubject');
const body = TAPi18n.__('UserDataDownload_EmailBody', { download_link: file.url });
sendEmail(operation.userData, subject, body);
}
}

@ -0,0 +1,10 @@
import React from 'react';
import ExportMessages from './index';
export default {
title: 'contextualBar/Export Messages',
component: ExportMessages,
};
export const ContextualBar = () => <ExportMessages />;

@ -0,0 +1,257 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Field, TextInput, Select, ButtonGroup, Button, Box, Icon, Callout } from '@rocket.chat/fuselage';
import { css } from '@rocket.chat/css-in-js';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import toastr from 'toastr';
import VerticalBar from '../../components/basic/VerticalBar';
import { useTranslation } from '../../contexts/TranslationContext';
import { useForm } from '../../hooks/useForm';
import { useUserRoom } from '../hooks/useUserRoom';
import { useEndpoint } from '../../contexts/ServerContext';
import { roomTypes, isEmail } from '../../../app/utils';
const clickable = css`
cursor: pointer;
`;
const FileExport = ({ onCancel, rid }) => {
const t = useTranslation();
const { values, handlers } = useForm({
dateFrom: '',
dateTo: '',
format: 'html',
});
const {
dateFrom,
dateTo,
format,
} = values;
const {
handleDateFrom,
handleDateTo,
handleFormat,
} = handlers;
const outputOptions = useMemo(() => [
['html', t('HTML')],
['json', t('JSON')],
], [t]);
const roomsExport = useEndpoint('POST', 'rooms.export');
const handleSubmit = async () => {
try {
await roomsExport({
rid,
type: 'file',
...dateFrom && { dateFrom: new Date(dateFrom) },
...dateTo && { dateTo: new Date(dateTo) },
format,
});
toastr.success(t('Your_email_has_been_queued_for_sending'));
return;
} catch (error) {
toastr.error(t('Error'));
}
};
return (
<>
<Field>
<Field.Label>{t('Date')}</Field.Label>
<Field.Row>
<TextInput type='date' value={dateFrom} onChange={handleDateFrom} />
<TextInput type='date' value={dateTo} onChange={handleDateTo} />
</Field.Row>
</Field>
<Field>
<Field.Label>{t('Output_format')}</Field.Label>
<Field.Row>
<Select value={format} onChange={handleFormat} placeholder={t('Format')} options={outputOptions}/>
</Field.Row>
</Field>
<ButtonGroup stretch mb='x12'>
<Button onClick={onCancel}>
{t('Cancel')}
</Button>
<Button primary onClick={() => handleSubmit()}>
{t('Export')}
</Button>
</ButtonGroup>
</>
);
};
const MailExportForm = ({ onCancel, rid }) => {
const t = useTranslation();
const room = useUserRoom(rid);
const roomName = room && room.t && roomTypes.getRoomName(room.t, room);
const [selectedMessages, setSelected] = useState([]);
const [errorMessage, setErrorMessage] = useState();
const { values, handlers } = useForm({
dateFrom: '',
dateTo: '',
toUsers: '',
additionalEmails: '',
subject: t('Mail_Messages_Subject', roomName),
});
const {
toUsers,
additionalEmails,
subject,
} = values;
const add = useMutableCallback((id) => setSelected(selectedMessages.concat(id)));
const remove = useMutableCallback((id) => setSelected(selectedMessages.filter((message) => message !== id)));
const reset = useMutableCallback(() => {
setSelected([]);
$(`#chat-window-${ rid }.messages-box .message.selected`)
.removeClass('selected');
});
useEffect(() => {
const $root = $(`#chat-window-${ rid }`);
$('.messages-box', $root).addClass('selectable');
const handler = function() {
const { id } = this;
if (this.classList.contains('selected')) {
this.classList.remove('selected');
remove(id);
} else {
this.classList.add('selected');
add(id);
}
};
$('.messages-box .message', $root).on('click', handler);
return () => {
$('.messages-box', $root).removeClass('selectable');
$('.messages-box .message', $root)
.off('click', handler)
.filter('.selected')
.removeClass('selected');
};
}, [rid]);
const {
handleToUsers,
handleAdditionalEmails,
handleSubject,
} = handlers;
const roomsExport = useEndpoint('POST', 'rooms.export');
const handleSubmit = async () => {
if (toUsers.length === 0 && additionalEmails === '') {
setErrorMessage(t('Mail_Message_Missing_to'));
return;
}
if (additionalEmails !== '' && !isEmail(additionalEmails)) {
setErrorMessage(t('Mail_Message_Invalid_emails', additionalEmails));
return;
}
if (selectedMessages.length === 0) {
setErrorMessage(t('Mail_Message_No_messages_selected_select_all'));
return;
}
setErrorMessage(null);
try {
await roomsExport({
rid,
type: 'email',
toUsers: [toUsers],
toEmails: additionalEmails.split(','),
subject,
messages: selectedMessages,
});
toastr.success(t('Your_email_has_been_queued_for_sending'));
return;
} catch (error) {
toastr.error(t('Error'));
}
};
return (
<>
<Callout onClick={reset} title={t('Messages selected')} type={selectedMessages.length > 0 ? 'success' : 'info'}>
<p>{`${ selectedMessages.length } Messages selected`}</p>
{ selectedMessages.length > 0 && <Box is='p' className={clickable} >{t('Click here to clear the selection')}</Box> }
{ selectedMessages.length === 0 && <Box is='p'>{t('Click_the_messages_you_would_like_to_send_by_email')}</Box> }
</Callout>
<Field>
<Field.Label>{t('To_users')}</Field.Label>
<Field.Row>
<TextInput placeholder={t('Username_Placeholder')} value={toUsers} onChange={handleToUsers} addon={<Icon name='at' size='x20'/>} />
</Field.Row>
</Field>
<Field>
<Field.Label>{t('To_additional_emails')}</Field.Label>
<Field.Row>
<TextInput placeholder={t('Email_Placeholder_any')} value={additionalEmails} onChange={handleAdditionalEmails} addon={<Icon name='mail' size='x20'/>} />
</Field.Row>
</Field>
<Field>
<Field.Label>{t('Subject')}</Field.Label>
<Field.Row>
<TextInput value={subject} onChange={handleSubject} addon={<Icon name='edit' size='x20'/>} />
</Field.Row>
</Field>
{ errorMessage && <Callout type={'danger'} title={errorMessage} /> }
<ButtonGroup stretch mb='x12'>
<Button onClick={onCancel}>
{t('Cancel')}
</Button>
<Button primary onClick={() => handleSubmit()}>
{t('Send')}
</Button>
</ButtonGroup>
</>
);
};
export const ExportMessages = function ExportMessages({ rid, tabBar }) {
const t = useTranslation();
const [type, setType] = useState('email');
const exportOptions = useMemo(() => [
['email', t('Send_via_email')],
['file', t('Export_as_file')],
], [t]);
return (
<VerticalBar>
<VerticalBar.Header>
{t('Export_Messages')}
<VerticalBar.Close onClick={() => tabBar.close()} />
</VerticalBar.Header>
<VerticalBar.Content>
<Field>
<Field.Label>{t('Method')}</Field.Label>
<Field.Row>
<Select value={type} onChange={(value) => setType(value)} placeholder={t('Type')} options={exportOptions}/>
</Field.Row>
</Field>
{type && type === 'file' && <FileExport rid={rid} onCancel={() => tabBar.close()} />}
{type && type === 'email' && <MailExportForm rid={rid} onCancel={() => tabBar.close()} />}
</VerticalBar.Content>
</VerticalBar>
);
};
export default ExportMessages;

@ -9,3 +9,7 @@ createTemplateForComponent('DiscussionMessageList', () => import('./Discussions/
createTemplateForComponent('ThreadsList', () => import('./Threads/ContextualBar/List'), {
renderContainerView: () => HTML.DIV({ class: 'contextual-bar' }), // eslint-disable-line new-cap
});
createTemplateForComponent('ExportMessages', () => import('./ExportMessages'), {
renderContainerView: () => HTML.DIV({ class: 'contextual-bar' }), // eslint-disable-line new-cap
});

@ -6,7 +6,6 @@ import '../app/autolinker/client';
import '../app/autotranslate/client';
import '../app/cas/client';
import '../app/channel-settings';
import '../app/channel-settings-mail-messages/client';
import '../app/colors/client';
import '../app/crowd/client';
import '../app/custom-oauth';

@ -20,6 +20,7 @@ import './notifications/updateAvatar';
import './notifications/updateUserState';
import './notifications/UsersNameChanged';
import './routes';
import './startup/contextualBar';
import './startup/emailVerification';
import './startup/i18n';
import './startup/loginViaQuery';

@ -0,0 +1,18 @@
import { Meteor } from 'meteor/meteor';
import { TabBar } from '../../../app/ui-utils/client';
import { hasAllPermission } from '../../../app/authorization/client';
Meteor.startup(() => {
TabBar.addButton({
groups: ['channel', 'group', 'direct'],
id: 'export-messages',
anonymous: true,
i18nTitle: 'Export_Messages',
icon: 'mail',
template: 'ExportMessages',
full: true,
order: 12,
condition: () => hasAllPermission('mail-messages'),
});
});

@ -0,0 +1 @@
import './exportMessages';

29
package-lock.json generated

@ -7867,7 +7867,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-1.0.1.tgz",
"integrity": "sha512-HkGSK7CGAXncr8Qn/0VqNtExEE+PHMWb+qlR1faHMao7ng6P3tAaoWWBMdva0gL5h4zprjIO89GJOLXsMcDm1Q==",
"dev": true,
"requires": {
"@types/node": "*"
}
@ -24141,6 +24140,7 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/needle/-/needle-2.5.0.tgz",
"integrity": "sha512-o/qITSDR0JCyCKEQ1/1bnUXMmznxabbwi/Y4WwJElf+evwJNFNwIDMCCt5IigFVxgeGBJESLohGtIS9gEzo1fA==",
"optional": true,
"requires": {
"debug": "^3.2.6",
"iconv-lite": "^0.4.4",
@ -24491,6 +24491,33 @@
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
"integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==",
"optional": true
},
"node-pre-gyp": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz",
"integrity": "sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==",
"optional": true,
"requires": {
"detect-libc": "^1.0.2",
"mkdirp": "^0.5.1",
"needle": "^2.2.1",
"nopt": "^4.0.1",
"npm-packlist": "^1.1.6",
"npmlog": "^4.0.2",
"rc": "^1.2.7",
"rimraf": "^2.6.1",
"semver": "^5.3.0",
"tar": "^4.4.2"
}
},
"rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"optional": true,
"requires": {
"glob": "^7.1.3"
}
}
}
},

@ -139,6 +139,7 @@
"@rocket.chat/ui-kit": "^0.14.0",
"@slack/client": "^4.12.0",
"@types/fibers": "^3.1.0",
"@types/mkdirp": "^1.0.1",
"@types/underscore.string": "0.0.38",
"@types/use-subscription": "^1.0.0",
"@types/xml-crypto": "^1.4.1",

@ -656,6 +656,7 @@
"Channel_Archived": "Channel with name `#%s` has been archived successfully",
"Channel_created": "Channel `#%s` created.",
"Channel_doesnt_exist": "The channel `#%s` does not exist.",
"Channel_Export": "Channel Export",
"Channel_name": "Channel Name",
"Channel_Name_Placeholder": "Please enter channel name...",
"Channel_to_listen_on": "Channel to listen on",
@ -1557,6 +1558,8 @@
"Experimental_Feature_Alert": "This is an experimental feature! Please be aware that it may change, break, or even be removed in the future without any notice.",
"Expiration": "Expiration",
"Expiration_(Days)": "Expiration (Days)",
"Export_as_file": "Export as file",
"Export_Messages": "Export Messages",
"Export_My_Data": "Export My Data (JSON)",
"expression": "Expression",
"Extended": "Extended",
@ -2532,6 +2535,7 @@
"meteor_status_reconnect_in_plural": "trying again in __count__ seconds...",
"meteor_status_try_now_waiting": "Try now",
"meteor_status_try_now_offline": "Connect again",
"Method": "Method",
"Min_length_is": "Min length is %s",
"Minimum": "Minimum",
"Minimum_balance": "Minimum balance",
@ -2736,6 +2740,7 @@
"others": "others",
"OTR": "OTR",
"OTR_is_only_available_when_both_users_are_online": "OTR is only available when both users are online",
"Output_format": "Output format",
"Outgoing_WebHook": "Outgoing WebHook",
"Outgoing_WebHook_Description": "Get data out of Rocket.Chat in real-time.",
"Override_URL_to_which_files_are_uploaded_This_url_also_used_for_downloads_unless_a_CDN_is_given": "Override URL to which files are uploaded. This url also used for downloads unless a CDN is given",
@ -3207,6 +3212,7 @@
"Send_request_on_offline_messages": "Send Request on Offline Messages",
"Send_request_on_visitor_message": "Send Request on Visitor Messages",
"Send_Test": "Send Test",
"Send_via_email": "Send via email",
"Send_Visitor_navigation_history_as_a_message": "Send Visitor Navigation History as a Message",
"Send_visitor_navigation_history_on_request": "Send Visitor Navigation History on Request",
"Send_welcome_email": "Send welcome email",
@ -3975,4 +3981,4 @@
"Your_server_link": "Your server link",
"Your_temporary_password_is_password": "Your temporary password is <strong>[password]</strong>.",
"Your_workspace_is_ready": "Your workspace is ready to use 🎉"
}
}

@ -11,7 +11,6 @@ import '../app/autotranslate/server';
import '../app/bot-helpers/server';
import '../app/cas/server';
import '../app/channel-settings';
import '../app/channel-settings-mail-messages/server';
import '../app/cloud/server';
import '../app/colors/server';
import '../app/crowd/server';

@ -0,0 +1,180 @@
import path from 'path';
import moment from 'moment';
import { Random } from 'meteor/random';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import mkdirp from 'mkdirp';
import * as Mailer from '../../app/mailer';
import { Messages, Users } from '../../app/models/server';
import { settings } from '../../app/settings/server';
import { Message } from '../../app/ui-utils/server';
import {
exportRoomMessagesToFile,
copyFile,
getRoomData,
makeZipFile,
sendEmail,
uploadZipFile,
} from '../../app/user-data-download/server/cronProcessDownloads';
import { IUser } from '../../definition/IUser';
import { getMomentLocale } from './getMomentLocale';
type ExportEmail = {
rid: string;
toUsers: string[];
toEmails: string[];
subject: string;
messages: string[];
language: string;
}
type ExportFile = {
rid: string;
dateFrom: Date;
dateTo: Date;
format: 'html' | 'json';
}
type ExportInput = {
type: 'email';
data: ExportEmail;
} | {
type: 'file';
data: ExportFile;
};
type ISentViaEmail = {
missing: string[];
};
export const sendViaEmail = (data: ExportEmail, user: IUser): ISentViaEmail => {
const emails = data.toEmails.map((email) => email.trim()).filter(Boolean);
const missing = [...data.toUsers].filter(Boolean);
Users.findUsersByUsernames(data.toUsers, { fields: { username: 1, 'emails.address': 1 } }).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 = getMomentLocale(lang);
if (localeFn) {
Function(localeFn).call({ moment });
localMoment.locale(lang);
}
}
const html = Messages.findByRoomIdAndMessageIds(data.rid, data.messages, {
sort: { ts: 1 },
}).fetch().map(function(message: any) {
const dateTime = moment(message.ts).locale(lang).format('L LT');
return `<p style='margin-bottom: 5px'><b>${ message.u.username }</b> <span style='color: #aaa; font-size: 12px'>${ dateTime }</span><br/>${ Message.parse(message, data.language) }</p>`;
}).join('');
Mailer.send({
to: emails,
from: settings.get('From_Email'),
replyTo: email,
subject: data.subject,
html,
} as any);
return { missing };
};
export const sendFile = async (data: ExportFile, user: IUser): Promise<void> => {
const exportType = data.format;
const baseDir = `/tmp/exportFile-${ Random.id() }`;
const exportPath = baseDir;
const assetsPath = path.join(baseDir, 'assets');
mkdirp.sync(exportPath);
mkdirp.sync(assetsPath);
const roomData = 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();
fullFileList.forEach((attachmentData: any) => {
copyFile(attachmentData, assetsPath);
});
const exportFile = `${ baseDir }-export.zip`;
await makeZipFile(exportPath, exportFile);
const file = await uploadZipFile(exportFile, user._id, exportType);
const subject = TAPi18n.__('Channel_Export');
// eslint-disable-next-line @typescript-eslint/camelcase
const body = TAPi18n.__('UserDataDownload_EmailBody', { download_link: file.url });
sendEmail(user, subject, body);
};
export async function channelExport(data: ExportInput, user: IUser): Promise<ISentViaEmail | void> {
if (data.type === 'email') {
return sendViaEmail(data.data, user);
}
return sendFile(data.data, user);
}

@ -0,0 +1,15 @@
import { Meteor } from 'meteor/meteor';
export function getMomentLocale(locale: string): string | undefined {
const localeLower = locale.toLowerCase();
try {
return Assets.getText(`moment-locales/${ localeLower }.js`);
} catch (error) {
try {
return Assets.getText(`moment-locales/${ String(localeLower.split('-').shift()) }.js`);
} catch (error) {
throw new Meteor.Error('moment-locale-not-found', `Moment locale not found: ${ locale }`);
}
}
}

@ -1,18 +1,16 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { getMomentLocale } from '../lib/getMomentLocale';
Meteor.methods({
loadLocale(locale) {
check(locale, String);
try {
return Assets.getText(`moment-locales/${ locale.toLowerCase() }.js`);
return getMomentLocale(locale);
} catch (error) {
try {
return Assets.getText(`moment-locales/${ locale.split('-').shift().toLowerCase() }.js`);
} catch (error) {
throw new Meteor.Error('moment-locale-not-found', `Moment locale not found: ${ locale }`);
}
throw new Meteor.Error(error.message, `Moment locale not found: ${ locale }`);
}
},
});

Loading…
Cancel
Save