feat: new Endpoints for Bulk User Creation (#29935)

Co-authored-by: Kevin Aleman <kaleman960@gmail.com>
pull/29957/head^2
Pierre Lehnen 2 years ago committed by GitHub
parent 8f5e05cc97
commit 48ac55f4ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      .changeset/tasty-coins-explode.md
  2. 91
      apps/meteor/app/api/server/v1/import.ts
  3. 16
      apps/meteor/app/api/server/v1/users.ts
  4. 90
      apps/meteor/app/authentication/server/startup/index.js
  5. 26
      apps/meteor/app/importer-csv/server/importer.js
  6. 4
      apps/meteor/app/importer-hipchat-enterprise/server/importer.js
  7. 2
      apps/meteor/app/importer-pending-avatars/server/importer.js
  8. 24
      apps/meteor/app/importer-slack-users/server/importer.js
  9. 4
      apps/meteor/app/importer-slack/server/importer.js
  10. 229
      apps/meteor/app/importer/server/classes/ImportDataConverter.ts
  11. 179
      apps/meteor/app/importer/server/classes/ImporterBase.js
  12. 6
      apps/meteor/app/importer/server/definitions/IConversionCallbacks.ts
  13. 4
      apps/meteor/app/importer/server/index.ts
  14. 7
      apps/meteor/app/importer/server/methods/downloadPublicImportFile.ts
  15. 1
      apps/meteor/app/importer/server/methods/getImportFileData.ts
  16. 1
      apps/meteor/app/importer/server/methods/getImportProgress.ts
  17. 8
      apps/meteor/app/importer/server/methods/startImport.ts
  18. 6
      apps/meteor/app/importer/server/methods/uploadImportFile.ts
  19. 34
      apps/meteor/app/importer/server/startup/setImportsToInvalid.js
  20. 23
      apps/meteor/app/lib/server/functions/getFullUserData.ts
  21. 7
      apps/meteor/client/views/admin/import/ImportOperationSummary.js
  22. 16
      apps/meteor/client/views/admin/import/ImportProgressPage.tsx
  23. 19
      apps/meteor/client/views/admin/import/ImportTypes.ts
  24. 1
      apps/meteor/definition/externals/meteor/accounts-base.d.ts
  25. 8
      apps/meteor/ee/app/license/server/license.ts
  26. 13
      apps/meteor/ee/server/startup/seatsCap.ts
  27. 2
      apps/meteor/lib/callbacks.ts
  28. 1
      apps/meteor/server/lib/ldap/Manager.ts
  29. 6
      apps/meteor/server/models/RawImports.ts
  30. 6
      apps/meteor/server/models/raw/ImportData.ts
  31. 55
      apps/meteor/server/models/raw/Imports.ts
  32. 5
      apps/meteor/server/models/raw/Messages.ts
  33. 10
      apps/meteor/server/models/raw/RawImports.ts
  34. 1
      apps/meteor/server/models/raw/Users.js
  35. 1
      apps/meteor/server/models/startup.ts
  36. 25
      apps/meteor/server/modules/notifications/notifications.module.ts
  37. 163
      apps/meteor/server/services/import/service.ts
  38. 2
      apps/meteor/server/services/startup.ts
  39. 29
      ee/packages/ddp-client/src/types/streams.ts
  40. 3
      packages/core-services/src/index.ts
  41. 9
      packages/core-services/src/types/IImportService.ts
  42. 14
      packages/core-typings/src/IMessage/IMessage.ts
  43. 1
      packages/core-typings/src/IUser.ts
  44. 10
      packages/core-typings/src/import/IImport.ts
  45. 10
      packages/core-typings/src/import/IImportMessage.ts
  46. 2
      packages/core-typings/src/import/IImportUser.ts
  47. 9
      packages/core-typings/src/import/IImporterSelection.ts
  48. 9
      packages/core-typings/src/import/IImporterSelectionChannel.ts
  49. 9
      packages/core-typings/src/import/IImporterSelectionUser.ts
  50. 10
      packages/core-typings/src/import/ImportState.ts
  51. 4
      packages/core-typings/src/import/index.ts
  52. 1
      packages/model-typings/src/index.ts
  53. 8
      packages/model-typings/src/models/IImportsModel.ts
  54. 12
      packages/model-typings/src/models/IMessagesModel.ts
  55. 3
      packages/model-typings/src/models/IRawImportsModel.ts
  56. 2
      packages/model-typings/src/models/IUsersModel.ts
  57. 2
      packages/models/src/index.ts
  58. 57
      packages/rest-typings/src/v1/import/ImportAddUsersParamsPOST.ts
  59. 19
      packages/rest-typings/src/v1/import/import.ts
  60. 1
      packages/rest-typings/src/v1/import/index.ts
  61. 16
      packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts

@ -0,0 +1,9 @@
---
'rocketchat-services': minor
'@rocket.chat/core-services': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---
Created new endpoints for creating users in bulk

@ -9,8 +9,10 @@ import {
isDownloadPendingFilesParamsPOST,
isDownloadPendingAvatarsParamsPOST,
isGetCurrentImportOperationParamsGET,
isImportAddUsersParamsPOST,
} from '@rocket.chat/rest-typings';
import { Imports } from '@rocket.chat/models';
import { Import } from '@rocket.chat/core-services';
import { API } from '../api';
import { Importers } from '../../../importer/server';
@ -66,7 +68,7 @@ API.v1.addRoute(
async post() {
const { input } = this.bodyParams;
await executeStartImport({ input });
await executeStartImport({ input }, this.userId);
return API.v1.success();
},
@ -133,8 +135,9 @@ API.v1.addRoute(
throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingFiles');
}
importer.instance = new importer.importer(importer); // eslint-disable-line new-cap
await importer.instance.build();
const operation = await Import.newOperation(this.userId, importer.name, importer.key);
importer.instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
const count = await importer.instance.prepareFileCount();
return API.v1.success({
@ -158,8 +161,8 @@ API.v1.addRoute(
throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingAvatars');
}
importer.instance = new importer.importer(importer); // eslint-disable-line new-cap
await importer.instance.build();
const operation = await Import.newOperation(this.userId, importer.name, importer.key);
importer.instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
const count = await importer.instance.prepareFileCount();
return API.v1.success({
@ -185,3 +188,81 @@ API.v1.addRoute(
},
},
);
API.v1.addRoute(
'import.clear',
{
authRequired: true,
permissionsRequired: ['run-import'],
},
{
async post() {
await Import.clear();
return API.v1.success();
},
},
);
API.v1.addRoute(
'import.new',
{
authRequired: true,
permissionsRequired: ['run-import'],
},
{
async post() {
const operation = await Import.newOperation(this.userId, 'api', 'api');
return API.v1.success({ operation });
},
},
);
API.v1.addRoute(
'import.status',
{
authRequired: true,
permissionsRequired: ['run-import'],
},
{
async get() {
const status = await Import.status();
return API.v1.success(status);
},
},
);
API.v1.addRoute(
'import.addUsers',
{
authRequired: true,
validateParams: isImportAddUsersParamsPOST,
permissionsRequired: ['run-import'],
},
{
async post() {
const { users } = this.bodyParams;
await Import.addUsers(users);
return API.v1.success();
},
},
);
API.v1.addRoute(
'import.run',
{
authRequired: true,
permissionsRequired: ['run-import'],
},
{
async post() {
await Import.run(this.userId);
return API.v1.success();
},
},
);

@ -29,7 +29,7 @@ import {
checkUsernameAvailability,
checkUsernameAvailabilityWithValidation,
} from '../../../lib/server/functions/checkUsernameAvailability';
import { getFullUserDataByIdOrUsername } from '../../../lib/server/functions/getFullUserData';
import { getFullUserDataByIdOrUsernameOrImportId } from '../../../lib/server/functions/getFullUserData';
import { setStatusText } from '../../../lib/server/functions/setStatusText';
import { API } from '../api';
import { findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users';
@ -393,10 +393,16 @@ API.v1.addRoute(
async get() {
const { fields } = await this.parseJsonQuery();
const user = await getFullUserDataByIdOrUsername(this.userId, {
filterId: (this.queryParams as any).userId,
filterUsername: (this.queryParams as any).username,
});
const searchTerms: [string, 'id' | 'username' | 'importId'] | false =
('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) ||
('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) ||
('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']);
if (!searchTerms) {
return API.v1.failure('Invalid search query.');
}
const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms);
if (!user) {
return API.v1.failure('User not found.');

@ -157,8 +157,34 @@ const getLinkedInName = ({ firstName, lastName }) => {
return `${firstName} ${lastName}`;
};
const validateEmailDomain = (user) => {
if (user.type === 'visitor') {
return true;
}
let domainWhiteList = settings.get('Accounts_AllowedDomainsList');
if (_.isEmpty(domainWhiteList?.trim())) {
return true;
}
domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim());
if (user.emails && user.emails.length > 0) {
const email = user.emails[0].address;
const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`));
if (!inWhiteList) {
throw new Meteor.Error('error-invalid-domain');
}
}
return true;
};
const onCreateUserAsync = async function (options, user = {}) {
await callbacks.run('beforeCreateUser', options, user);
if (!options.skipBeforeCreateUserCallback) {
await callbacks.run('beforeCreateUser', options, user);
}
user.status = 'offline';
user.active = user.active !== undefined ? user.active : !settings.get('Accounts_ManuallyApproveNewUsers');
@ -193,7 +219,7 @@ const onCreateUserAsync = async function (options, user = {}) {
}
}
if (!user.active) {
if (!options.skipAdminEmail && !user.active) {
const destinations = [];
const usersInRole = await Roles.findUsersInRole('admin');
await usersInRole.forEach((adminUser) => {
@ -218,10 +244,18 @@ const onCreateUserAsync = async function (options, user = {}) {
await Mailer.send(email);
}
await callbacks.run('onCreateUser', options, user);
if (!options.skipOnCreateUserCallback) {
await callbacks.run('onCreateUser', options, user);
}
if (!options.skipAppsEngineEvent) {
// App IPostUserCreated event hook
await Apps.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() });
}
// App IPostUserCreated event hook
await Apps.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: await safeGetMeteorUser() });
if (!options.skipEmailValidation && !validateEmailDomain(user)) {
throw new Meteor.Error(403, 'User validation failed');
}
return user;
};
@ -276,32 +310,32 @@ const insertUserDocAsync = async function (options, user) {
* create this user admin.
* count this as the completion of setup wizard step 1.
*/
const hasAdmin = await Users.findOneByRolesAndType('admin', 'user', { projection: { _id: 1 } });
if (!roles.includes('admin') && !hasAdmin) {
roles.push('admin');
if (settings.get('Show_Setup_Wizard') === 'pending') {
await Settings.updateValueById('Show_Setup_Wizard', 'in_progress');
if (!options.skipAdminCheck) {
const hasAdmin = await Users.findOneByRolesAndType('admin', 'user', { projection: { _id: 1 } });
if (!roles.includes('admin') && !hasAdmin) {
roles.push('admin');
if (settings.get('Show_Setup_Wizard') === 'pending') {
await Settings.updateValueById('Show_Setup_Wizard', 'in_progress');
}
}
}
await addUserRolesAsync(_id, roles);
// Make user's roles to be present on callback
user = await Users.findOne({
_id,
});
user = await Users.findOneById(_id, { projection: { username: 1, type: 1 } });
if (user.username) {
if (options.joinDefaultChannels !== false && user.joinDefaultChannels !== false) {
if (options.joinDefaultChannels !== false) {
await joinDefaultChannels(_id, options.joinDefaultChannelsSilenced);
}
if (user.type !== 'visitor') {
if (!options.skipAfterCreateUserCallback && user.type !== 'visitor') {
setImmediate(function () {
return callbacks.run('afterCreateUser', user);
});
}
if (settings.get('Accounts_SetDefaultAvatar') === true) {
if (!options.skipDefaultAvatar && settings.get('Accounts_SetDefaultAvatar') === true) {
const avatarSuggestions = await getAvatarSuggestionForUser(user);
for await (const service of Object.keys(avatarSuggestions)) {
const avatarData = avatarSuggestions[service];
@ -409,30 +443,6 @@ Accounts.validateNewUser(function (user) {
return true;
});
Accounts.validateNewUser(function (user) {
if (user.type === 'visitor') {
return true;
}
let domainWhiteList = settings.get('Accounts_AllowedDomainsList');
if (_.isEmpty(domainWhiteList?.trim())) {
return true;
}
domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim());
if (user.emails && user.emails.length > 0) {
const email = user.emails[0].address;
const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`));
if (inWhiteList === false) {
throw new Meteor.Error('error-invalid-domain');
}
}
return true;
});
export const MAX_RESUME_LOGIN_TOKENS = parseInt(process.env.MAX_RESUME_LOGIN_TOKENS) || 50;
Accounts.onLogin(async ({ user }) => {

@ -4,8 +4,8 @@ import { Random } from '@rocket.chat/random';
import { Base, ProgressStep, ImporterWebsocket } from '../../importer/server';
export class CsvImporter extends Base {
constructor(info, importRecord) {
super(info, importRecord);
constructor(info, importRecord, converterOptions = {}) {
super(info, importRecord, converterOptions);
const { parse } = require('csv-parse/lib/sync');
@ -59,13 +59,15 @@ export class CsvImporter extends Base {
// Ignore anything that has `__MACOSX` in it's name, as sadly these things seem to mess everything up
if (entry.entryName.indexOf('__MACOSX') > -1) {
this.logger.debug(`Ignoring the file: ${entry.entryName}`);
return increaseProgressCount();
increaseProgressCount();
continue;
}
// Directories are ignored, since they are "virtual" in a zip file
if (entry.isDirectory) {
this.logger.debug(`Ignoring the directory entry: ${entry.entryName}`);
return increaseProgressCount();
increaseProgressCount();
continue;
}
// Parse the channels
@ -97,7 +99,8 @@ export class CsvImporter extends Base {
}
await super.updateRecord({ 'count.channels': channelsCount });
return increaseProgressCount();
increaseProgressCount();
continue;
}
// Parse the users
@ -114,6 +117,7 @@ export class CsvImporter extends Base {
const name = u[2].trim();
await this.converter.addUser({
type: 'user',
importIds: [username],
emails: [email],
username,
@ -122,7 +126,8 @@ export class CsvImporter extends Base {
}
await super.updateRecord({ 'count.users': usersCount });
return increaseProgressCount();
increaseProgressCount();
continue;
}
// Parse the messages
@ -140,7 +145,8 @@ export class CsvImporter extends Base {
msgs = this.csvParser(entry.getData().toString());
} catch (e) {
this.logger.warn(`The file ${entry.entryName} contains invalid syntax`, e);
return increaseProgressCount();
increaseProgressCount();
continue;
}
let data;
@ -211,7 +217,8 @@ export class CsvImporter extends Base {
}
await super.updateRecord({ 'count.messages': messagesCount, 'messagesstatus': null });
return increaseProgressCount();
increaseProgressCount();
continue;
}
increaseProgressCount();
@ -243,7 +250,8 @@ export class CsvImporter extends Base {
if (usersCount === 0 && channelsCount === 0 && messagesCount === 0) {
this.logger.error('No users, channels, or messages found in the import file.');
await super.updateProgress(ProgressStep.ERROR);
return super.getProgress();
}
return super.getProgress();
}
}

@ -8,8 +8,8 @@ import { Settings } from '@rocket.chat/models';
import { Base, ProgressStep } from '../../importer/server';
export class HipChatEnterpriseImporter extends Base {
constructor(info, importRecord) {
super(info, importRecord);
constructor(info, importRecord, converterOptions = {}) {
super(info, importRecord, converterOptions);
this.Readable = Readable;
this.zlib = require('zlib');

@ -39,7 +39,7 @@ export class PendingAvatarImporter extends Base {
try {
if (!url || !url.startsWith('http')) {
return;
continue;
}
try {

@ -1,11 +1,13 @@
import fs from 'fs';
import { Settings } from '@rocket.chat/models';
import { Base, ProgressStep } from '../../importer/server';
import { RocketChatFile } from '../../file/server';
export class SlackUsersImporter extends Base {
constructor(info, importRecord) {
super(info, importRecord);
constructor(info, importRecord, converterOptions = {}) {
super(info, importRecord, converterOptions);
const { parse } = require('csv-parse/lib/sync');
@ -13,14 +15,19 @@ export class SlackUsersImporter extends Base {
}
async prepareUsingLocalFile(fullFilePath) {
this.logger.debug('start preparing import operation');
await this.converter.clearImportData();
return super.prepareUsingLocalFile(fullFilePath);
}
const file = fs.readFileSync(fullFilePath);
const buffer = Buffer.isBuffer(file) ? file : Buffer.from(file);
async prepare(dataURI, sentContentType, fileName) {
this.logger.debug('start preparing import operation');
await super.prepare(dataURI, sentContentType, fileName, true);
const { contentType } = this.importRecord;
const fileName = this.importRecord.file;
const data = buffer.toString('base64');
const dataURI = `data:${contentType};base64,${data}`;
await this.updateRecord({ file: fileName });
await super.updateProgress(ProgressStep.PREPARING_USERS);
const uriResult = RocketChatFile.dataURIParse(dataURI);
@ -76,6 +83,7 @@ export class SlackUsersImporter extends Base {
await super.updateProgress(ProgressStep.USER_SELECTION);
await super.addCountToTotal(userCount);
await Settings.incrementValueById('Slack_Users_Importer_Count', userCount);
return super.updateRecord({ 'count.users': userCount });
await super.updateRecord({ 'count.users': userCount });
return super.getProgress();
}
}

@ -295,6 +295,8 @@ export class SlackImporter extends Base {
ImporterWebsocket.progressUpdated({ rate: 100 });
await this.updateRecord({ 'count.messages': messagesCount, 'messagesstatus': null });
return this.progress;
}
parseMentions(newMessage) {
@ -400,6 +402,8 @@ export class SlackImporter extends Base {
}
break;
}
return false;
}
makeSlackMessageId(channelId, ts, fileIndex = undefined) {

@ -15,6 +15,8 @@ import type {
IMessage as IDBMessage,
} from '@rocket.chat/core-typings';
import { ImportData, Rooms, Users, Subscriptions } from '@rocket.chat/models';
import { hash as bcryptHash } from 'bcrypt';
import { SHA256 } from '@rocket.chat/sha256';
import type { IConversionCallbacks } from '../definitions/IConversionCallbacks';
import { generateUsernameSuggestion, insertMessage, saveUserIdentity, addUserToDefaultChannels } from '../../../lib/server';
@ -25,6 +27,7 @@ import { saveRoomSettings } from '../../../channel-settings/server/methods/saveR
import { createPrivateGroupMethod } from '../../../lib/server/methods/createPrivateGroup';
import { createChannelMethod } from '../../../lib/server/methods/createChannel';
import { createDirectMessage } from '../../../../server/methods/createDirectMessage';
import { callbacks } from '../../../../lib/callbacks';
type IRoom = Record<string, any>;
type IMessage = Record<string, any>;
@ -53,6 +56,9 @@ export type IConverterOptions = {
flagEmailsAsVerified?: boolean;
skipExistingUsers?: boolean;
skipNewUsers?: boolean;
bindSkippedUsers?: boolean;
skipUserCallbacks?: boolean;
skipDefaultChannels?: boolean;
};
const guessNameFromUsername = (username: string): string =>
@ -80,11 +86,14 @@ export class ImportDataConverter {
return this._options;
}
public aborted = false;
constructor(options?: IConverterOptions) {
this._options = options || {
flagEmailsAsVerified: false,
skipExistingUsers: false,
skipNewUsers: false,
bindSkippedUsers: false,
};
this._userCache = new Map();
this._userDisplayNameCache = new Map();
@ -246,7 +255,6 @@ export class ImportDataConverter {
userData.type = 'user';
}
// #ToDo: #TODO: Move this to the model class
const updateData: Record<string, any> = Object.assign(Object.create(null), {
$set: Object.assign(Object.create(null), {
...(userData.roles && { roles: userData.roles }),
@ -281,30 +289,64 @@ export class ImportDataConverter {
if (userData.importIds.length) {
this.addUserToCache(userData.importIds[0], existingUser._id, existingUser.username || userData.username);
}
// Deleted users are 'inactive' users in Rocket.Chat
if (userData.deleted && existingUser?.active) {
userData._id && (await setUserActiveStatus(userData._id, false, true));
} else if (userData.deleted === false && existingUser?.active === false) {
userData._id && (await setUserActiveStatus(userData._id, true));
}
}
// TODO
async insertUser(userData: IImportUser): Promise<IUser> {
const password = `${Date.now()}${userData.name || ''}${userData.emails.length ? userData.emails[0].toUpperCase() : ''}`;
const userId = userData.emails.length
? await Accounts.createUserAsync({
email: userData.emails[0],
password,
})
: await Accounts.createUserAsync({
username: userData.username,
password,
joinDefaultChannelsSilenced: true,
} as any);
private async hashPassword(password: string): Promise<string> {
return bcryptHash(SHA256(password), Accounts._bcryptRounds());
}
const user = await Users.findOneById(userId, {});
if (!user) {
throw new Error(`User not found: ${userId}`);
}
await this.updateUser(user, userData);
private generateTempPassword(userData: IImportUser): string {
return `${Date.now()}${userData.name || ''}${userData.emails.length ? userData.emails[0].toUpperCase() : ''}`;
}
async insertUser(userData: IImportUser): Promise<IUser['_id']> {
return Accounts.insertUserDoc(
{
joinDefaultChannels: false,
skipEmailValidation: true,
skipAdminCheck: true,
skipAdminEmail: true,
skipOnCreateUserCallback: this._options.skipUserCallbacks,
skipBeforeCreateUserCallback: this._options.skipUserCallbacks,
skipAfterCreateUserCallback: this._options.skipUserCallbacks,
skipDefaultAvatar: true,
skipAppsEngineEvent: !!process.env.IMPORTER_SKIP_APPS_EVENT,
},
{
type: userData.type || 'user',
...(userData.username && { username: userData.username }),
...(userData.emails.length && {
emails: userData.emails.map((email) => ({ address: email, verified: !!this._options.flagEmailsAsVerified })),
}),
await addUserToDefaultChannels(user, true);
return user;
...(userData.statusText && { statusText: userData.statusText }),
...(userData.name && { name: userData.name }),
...(userData.bio && { bio: userData.bio }),
...(userData.avatarUrl && { _pendingAvatarUrl: userData.avatarUrl }),
...(userData.utcOffset !== undefined && { utcOffset: userData.utcOffset }),
...{
services: {
// Add a password service if there's a password string, or if there's no service at all
...((!!userData.password || !userData.services || !Object.keys(userData.services).length) && {
password: { bcrypt: await this.hashPassword(userData.password || this.generateTempPassword(userData)) },
}),
...(userData.services || {}),
},
},
...(userData.services?.ldap && { ldap: true }),
...(userData.importIds?.length && { importIds: userData.importIds }),
...(!!userData.customFields && { customFields: userData.customFields }),
...(userData.deleted !== undefined && { active: !userData.deleted }),
...(userData.roles?.length ? { globalRoles: userData.roles } : {}),
},
);
}
protected async getUsersToImport(): Promise<Array<IImportUserRecord>> {
@ -312,6 +354,18 @@ export class ImportDataConverter {
}
async findExistingUser(data: IImportUser): Promise<IUser | undefined> {
// If we're gonna force-bind importIds, we search for them first to ensure they are unique
if (this._options.bindSkippedUsers) {
// #TODO: Use a single operation for multiple IDs
// (Currently there's no existing use case with multiple IDs being passed to this function)
for await (const importId of data.importIds) {
const importedUser = await Users.findOneByImportId(importId, {});
if (importedUser) {
return importedUser;
}
}
}
if (data.emails.length) {
const emailUser = await Users.findOneByEmailAddress(data.emails[0], {});
@ -328,64 +382,99 @@ export class ImportDataConverter {
public async convertUsers({ beforeImportFn, afterImportFn }: IConversionCallbacks = {}): Promise<void> {
const users = (await this.getUsersToImport()) as IImportUserRecord[];
for await (const { data, _id } of users) {
try {
if (beforeImportFn && !(await beforeImportFn(data, 'user'))) {
await this.skipRecord(_id);
continue;
}
const emails = data.emails.filter(Boolean).map((email) => ({ address: email }));
data.importIds = data.importIds.filter((item) => item);
await callbacks.run('beforeUserImport', { userCount: users.length });
if (!data.emails.length && !data.username) {
throw new Error('importer-user-missing-email-and-username');
}
const insertedIds = new Set<IUser['_id']>();
const updatedIds = new Set<IUser['_id']>();
let skippedCount = 0;
let failedCount = 0;
let existingUser = await this.findExistingUser(data);
if (existingUser && this._options.skipExistingUsers) {
await this.skipRecord(_id);
continue;
}
if (!existingUser && this._options.skipNewUsers) {
await this.skipRecord(_id);
continue;
try {
for await (const { data, _id } of users) {
if (this.aborted) {
return;
}
if (!data.username) {
data.username = await generateUsernameSuggestion({
name: data.name,
emails,
});
}
try {
if (beforeImportFn && !(await beforeImportFn(data, 'user'))) {
await this.skipRecord(_id);
skippedCount++;
continue;
}
const isNewUser = !existingUser;
const emails = data.emails.filter(Boolean).map((email) => ({ address: email }));
data.importIds = data.importIds.filter((item) => item);
if (existingUser) {
await this.updateUser(existingUser, data);
} else {
if (!data.name && data.username) {
data.name = guessNameFromUsername(data.username);
if (!data.emails.length && !data.username) {
throw new Error('importer-user-missing-email-and-username');
}
existingUser = await this.insertUser(data);
}
const existingUser = await this.findExistingUser(data);
if (existingUser && this._options.skipExistingUsers) {
if (this._options.bindSkippedUsers) {
const newImportIds = data.importIds.filter((importId) => !(existingUser as IUser).importIds?.includes(importId));
if (newImportIds.length) {
await Users.addImportIds(existingUser._id, newImportIds);
}
}
// Deleted users are 'inactive' users in Rocket.Chat
// TODO: Check data._id if exists/required or not
if (data.deleted && existingUser?.active) {
data._id && (await setUserActiveStatus(data._id, false, true));
} else if (data.deleted === false && existingUser?.active === false) {
data._id && (await setUserActiveStatus(data._id, true));
}
await this.skipRecord(_id);
skippedCount++;
continue;
}
if (!existingUser && this._options.skipNewUsers) {
await this.skipRecord(_id);
skippedCount++;
continue;
}
if (afterImportFn) {
await afterImportFn(data, 'user', isNewUser);
if (!data.username && !existingUser?.username) {
data.username = await generateUsernameSuggestion({
name: data.name,
emails,
});
}
const isNewUser = !existingUser;
if (existingUser) {
await this.updateUser(existingUser, data);
updatedIds.add(existingUser._id);
} else {
if (!data.name && data.username) {
data.name = guessNameFromUsername(data.username);
}
const userId = await this.insertUser(data);
insertedIds.add(userId);
if (!this._options.skipDefaultChannels) {
const insertedUser = await Users.findOneById(userId, {});
if (!insertedUser) {
throw new Error(`User not found: ${userId}`);
}
await addUserToDefaultChannels(insertedUser, true);
}
}
if (afterImportFn) {
await afterImportFn(data, 'user', isNewUser);
}
} catch (e) {
this._logger.error(e);
await this.saveError(_id, e instanceof Error ? e : new Error(String(e)));
failedCount++;
}
} catch (e) {
this._logger.error(e);
await this.saveError(_id, e instanceof Error ? e : new Error(String(e)));
}
} finally {
await callbacks.run('afterUserImport', {
inserted: [...insertedIds],
updated: [...updatedIds],
skipped: skippedCount,
failed: failedCount,
});
}
}
@ -568,6 +657,10 @@ export class ImportDataConverter {
const messages = await this.getMessagesToImport();
for await (const { data, _id } of messages) {
if (this.aborted) {
return;
}
try {
if (beforeImportFn && !(await beforeImportFn(data, 'message'))) {
await this.skipRecord(_id);
@ -937,6 +1030,10 @@ export class ImportDataConverter {
async convertChannels(startedByUserId: string, { beforeImportFn, afterImportFn }: IConversionCallbacks = {}): Promise<void> {
const channels = await this.getChannelsToImport();
for await (const { data, _id } of channels) {
if (this.aborted) {
return;
}
try {
if (beforeImportFn && !(await beforeImportFn(data, 'channel'))) {
await this.skipRecord(_id);

@ -1,15 +1,9 @@
import http from 'http';
import fs from 'fs';
import https from 'https';
import { Settings, ImportData, Imports, RawImports } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { Settings, ImportData, Imports } from '@rocket.chat/models';
import AdmZip from 'adm-zip';
import getFileType from 'file-type';
import { Progress } from './ImporterProgress';
import { ImporterWebsocket } from './ImporterWebsocket';
import { ProgressStep } from '../../lib/ImporterProgressStep';
import { ProgressStep, ImportPreparingStartedStates } from '../../lib/ImporterProgressStep';
import { ImporterInfo } from '../../lib/ImporterInfo';
import { Logger } from '../../../logger/server';
import { ImportDataConverter } from './ImportDataConverter';
@ -20,25 +14,14 @@ import { Selection, SelectionChannel, SelectionUser } from '..';
* Base class for all of the importers.
*/
export class Base {
/**
* Constructs a new importer, adding an empty collection, AdmZip property, and empty users & channels
*
* @param {string} name The importer's name.
* @param {string} description The i18n string which describes the importer
* @param {string} mimeType The expected file type.
*/
constructor(info, importRecord) {
constructor(info, importRecord, converterOptions = {}) {
if (!(info instanceof ImporterInfo)) {
throw new Error('Information passed in must be a valid ImporterInfo instance.');
}
this.http = http;
this.https = https;
this.AdmZip = AdmZip;
this.getFileType = getFileType;
this.converter = new ImportDataConverter();
this.converter = new ImportDataConverter(converterOptions);
this.prepare = this.prepare.bind(this);
this.startImport = this.startImport.bind(this);
this.getProgress = this.getProgress.bind(this);
this.updateProgress = this.updateProgress.bind(this);
@ -51,37 +34,15 @@ export class Base {
this.logger = new Logger(`${this.info.name} Importer`);
this.converter.setLogger(this.logger);
this.importRecord = importRecord;
this.progress = new Progress(this.info.key, this.info.name);
this.collection = RawImports;
this.importRecordParam = importRecord;
this.users = {};
this.channels = {};
this.messages = {};
this.oldSettings = {};
}
async build() {
const userId = Meteor.userId();
if (this.importRecordParam) {
this.logger.debug('Found existing import operation');
this.importRecord = this.importRecordParam;
this.progress.step = this.importRecord.status;
this.reloadCount();
} else {
this.logger.debug('Starting new import operation');
const importId = (
await Imports.insertOne({
type: this.info.name,
importerKey: this.info.key,
ts: Date.now(),
status: this.progress.step,
valid: true,
user: userId,
})
).insertedId;
this.importRecord = await Imports.findOne(importId);
}
this.logger.debug(`Constructed a new ${this.info.name} Importer.`);
this.progress.step = this.importRecord.status;
this.reloadCount();
}
/**
@ -102,46 +63,8 @@ export class Base {
* @param {string} fullFilePath the full path of the uploaded file
* @returns {Progress} The progress record of the import.
*/
async prepareUsingLocalFile(fullFilePath) {
const file = fs.readFileSync(fullFilePath);
const buffer = Buffer.isBuffer(file) ? file : Buffer.from(file);
const { contentType } = this.importRecord;
const fileName = this.importRecord.file;
const data = buffer.toString('base64');
const dataURI = `data:${contentType};base64,${data}`;
return this.prepare(dataURI, contentType, fileName, true);
}
/**
* Takes the uploaded file and extracts the users, channels, and messages from it.
*
* @param {string} dataURI Base64 string of the uploaded file
* @param {string} sentContentType The sent file type.
* @param {string} fileName The name of the uploaded file.
* @param {boolean} skipTypeCheck Optional property that says to not check the type provided.
* @returns {Progress} The progress record of the import.
*/
async prepare(dataURI, sentContentType, fileName, skipTypeCheck) {
await this.collection.deleteMany({});
if (!skipTypeCheck) {
const fileType = this.getFileType(Buffer.from(dataURI.split(',')[1], 'base64'));
this.logger.debug('Uploaded file information is:', fileType);
this.logger.debug('Expected file type is:', this.info.mimeType);
if (!fileType || fileType.mime !== this.info.mimeType) {
this.logger.warn(`Invalid file uploaded for the ${this.info.name} importer.`);
await this.updateProgress(ProgressStep.ERROR);
throw new Meteor.Error('error-invalid-file-uploaded', `Invalid file uploaded to import ${this.info.name} data from.`, {
step: 'prepare',
});
}
}
await this.updateProgress(ProgressStep.PREPARING_STARTED);
return this.updateRecord({ file: fileName });
async prepareUsingLocalFile() {
return this.updateProgress(ProgressStep.PREPARING_STARTED);
}
/**
@ -153,7 +76,7 @@ export class Base {
* @param {Selection} importSelection The selection data.
* @returns {Progress} The progress record of the import.
*/
async startImport(importSelection) {
async startImport(importSelection, startedByUserId) {
if (!(importSelection instanceof Selection)) {
throw new Error(`Invalid Selection data provided to the ${this.info.name} importer.`);
} else if (importSelection.users === undefined) {
@ -164,12 +87,20 @@ export class Base {
);
}
if (!startedByUserId) {
throw new Error('You must be logged in to do this.');
}
await this.updateProgress(ProgressStep.IMPORTING_STARTED);
this.reloadCount();
const started = Date.now();
const startedByUserId = Meteor.userId();
const beforeImportFn = async (data, type) => {
if (this.importRecord.valid === false) {
this.converter.aborted = true;
throw new Error('The import operation is no longer valid.');
}
switch (type) {
case 'channel': {
const id = data.t === 'd' ? '__directMessages__' : data.importIds[0];
@ -182,6 +113,11 @@ export class Base {
return false;
}
case 'user': {
// #TODO: Replace this workaround in the final version of the API importer
if (importSelection.users.length === 0 && this.info.key === 'api') {
return true;
}
const id = data.importIds[0];
for (const user of importSelection.users) {
if (user.user_id === id) {
@ -197,7 +133,12 @@ export class Base {
};
const afterImportFn = async () => {
return this.addCountCompleted(1);
await this.addCountCompleted(1);
if (this.importRecord.valid === false) {
this.converter.aborted = true;
throw new Error('The import operation is no longer valid.');
}
};
process.nextTick(async () => {
@ -237,14 +178,12 @@ export class Base {
}
async backupSettingValues() {
const allowedDomainList = await Settings.findOneById('Accounts_AllowedDomainsList').value;
const allowUsernameChange = await Settings.findOneById('Accounts_AllowUsernameChange').value;
const maxFileSize = await Settings.findOneById('FileUpload_MaxFileSize').value;
const mediaTypeWhiteList = await Settings.findOneById('FileUpload_MediaTypeWhiteList').value;
const mediaTypeBlackList = await Settings.findOneById('FileUpload_MediaTypeBlackList').value;
this.oldSettings = {
allowedDomainList,
allowUsernameChange,
maxFileSize,
mediaTypeWhiteList,
@ -253,8 +192,7 @@ export class Base {
}
async applySettingValues(settingValues) {
await Settings.updateValueById('Accounts_AllowedDomainsList', settingValues.allowedDomainList ?? '');
await Settings.updateValueById('Accounts_AllowUsernameChange', setTimeout.allowUsernameChange ?? true);
await Settings.updateValueById('Accounts_AllowUsernameChange', settingValues.allowUsernameChange ?? true);
await Settings.updateValueById('FileUpload_MaxFileSize', settingValues.maxFileSize ?? -1);
await Settings.updateValueById('FileUpload_MediaTypeWhiteList', settingValues.mediaTypeWhiteList ?? '*');
await Settings.updateValueById('FileUpload_MediaTypeBlackList', settingValues.mediaTypeBlackList ?? '');
@ -283,7 +221,10 @@ export class Base {
this.logger.debug(`${this.info.name} is now at ${step}.`);
await this.updateRecord({ status: this.progress.step });
this.reportProgress();
// Do not send the default progress report during the preparing stage - the classes are sending their own report in a different format.
if (!ImportPreparingStartedStates.includes(this.progress.step)) {
this.reportProgress();
}
return this.progress;
}
@ -320,9 +261,11 @@ export class Base {
async addCountCompleted(count) {
this.progress.count.completed += count;
// Only update the database every 500 records
const range = [ProgressStep.IMPORTING_USERS, ProgressStep.IMPORTING_CHANNELS].includes(this.progress.step) ? 50 : 500;
// Only update the database every 500 messages (or 50 for users/channels)
// Or the completed is greater than or equal to the total amount
if (this.progress.count.completed % 500 === 0 || this.progress.count.completed >= this.progress.count.total) {
if (this.progress.count.completed % range === 0 || this.progress.count.completed >= this.progress.count.total) {
await this.updateRecord({ 'count.completed': this.progress.count.completed });
this.reportProgress();
} else if (!this._reportProgressHandler) {
@ -342,51 +285,11 @@ export class Base {
reportProgress() {
if (this._reportProgressHandler) {
clearTimeout(this._reportProgressHandler);
this._reportProgressHandler = false;
this._reportProgressHandler = undefined;
}
ImporterWebsocket.progressUpdated(this.progress);
}
/**
* Registers error information on a specific user from the import record
*
* @param {int} the user id
* @param {object} an exception object
*/
async addUserError(userId, error) {
await Imports.updateOne(
{
'_id': this.importRecord._id,
'fileData.users.user_id': userId,
},
{
$set: {
'fileData.users.$.error': error,
'hasErrors': true,
},
},
);
}
async addMessageError(error, msg) {
await Imports.updateOne(
{
_id: this.importRecord._id,
},
{
$push: {
errors: {
error,
msg,
},
},
$set: {
hasErrors: true,
},
},
);
}
/**
* Updates the import record with the given fields being `set`.
*
@ -429,7 +332,7 @@ export class Base {
selectionChannels.push(new SelectionChannel('__directMessages__', t('Direct_Messages'), false, true, true, undefined, true));
}
const results = new Selection(this.name, selectionUsers, selectionChannels, selectionMessages);
const results = new Selection(this.info.name, selectionUsers, selectionChannels, selectionMessages);
return results;
}

@ -1,11 +1,11 @@
import type { IImportUser, IImportMessage, IImportChannel } from '@rocket.chat/core-typings';
import type { IImportUser, IImportMessage, IImportChannel, IImportRecordType } from '@rocket.chat/core-typings';
type ImporterBeforeImportCallback = {
(data: IImportUser | IImportChannel | IImportMessage, type: string): Promise<boolean>;
(data: IImportUser | IImportChannel | IImportMessage, type: IImportRecordType): Promise<boolean>;
};
export type ImporterAfterImportCallback = {
(data: IImportUser | IImportChannel | IImportMessage, type: string, isNewRecord: boolean): Promise<void>;
(data: IImportUser | IImportChannel | IImportMessage, type: IImportRecordType, isNewRecord: boolean): Promise<void>;
};
export interface IConversionCallbacks {

@ -5,8 +5,12 @@ import { SelectionChannel } from './classes/ImporterSelectionChannel';
import { SelectionUser } from './classes/ImporterSelectionUser';
import { ProgressStep } from '../lib/ImporterProgressStep';
import { Importers } from '../lib/Importers';
import { ImporterInfo } from '../lib/ImporterInfo';
import './methods';
import './startup/setImportsToInvalid';
import './startup/store';
// Adding a link to the base class using the 'api' key. This won't be needed in the new importer structure implemented on the parallel PR
Importers.add(new ImporterInfo('api', 'API', ''), Base);
export { Base, Importers, ImporterWebsocket, ProgressStep, Selection, SelectionChannel, SelectionUser };

@ -3,13 +3,14 @@ import https from 'https';
import fs from 'fs';
import { Meteor } from 'meteor/meteor';
import { Import } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
import { RocketChatImportFileInstance } from '../startup/store';
import { ProgressStep } from '../../lib/ImporterProgressStep';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { Importers } from '..';
import { Importers } from '../../lib/Importers';
function downloadHttpFile(fileUrl: string, writeStream: fs.WriteStream): void {
const protocol = fileUrl.startsWith('https') ? https : http;
@ -40,8 +41,8 @@ export const executeDownloadPublicImportFile = async (userId: IUser['_id'], file
}
}
importer.instance = new importer.importer(importer); // eslint-disable-line new-cap
await importer.instance.build();
const operation = await Import.newOperation(userId, importer.name, importer.key);
importer.instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
const oldFileName = fileUrl.substring(fileUrl.lastIndexOf('/') + 1).split('?')[0];
const date = new Date();

@ -25,7 +25,6 @@ export const executeGetImportFileData = async (): Promise<IImportFileData | { wa
}
importer.instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
await importer.instance.build();
const waitingSteps = [
ProgressStep.DOWNLOADING_FILE,

@ -19,7 +19,6 @@ export const executeGetImportProgress = async (): Promise<IImportProgress> => {
}
importer.instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
await importer.instance.build();
return importer.instance.getProgress();
};

@ -2,11 +2,12 @@ import { Meteor } from 'meteor/meteor';
import type { StartImportParamsPOST } from '@rocket.chat/rest-typings';
import { Imports } from '@rocket.chat/models';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
import type { IUser } from '@rocket.chat/core-typings';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { Importers, Selection, SelectionChannel, SelectionUser } from '..';
export const executeStartImport = async ({ input }: StartImportParamsPOST) => {
export const executeStartImport = async ({ input }: StartImportParamsPOST, startedByUserId: IUser['_id']) => {
const operation = await Imports.findLastImport();
if (!operation) {
throw new Meteor.Error('error-operation-not-found', 'Import Operation Not Found', 'startImport');
@ -19,7 +20,6 @@ export const executeStartImport = async ({ input }: StartImportParamsPOST) => {
}
importer.instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
await importer.instance.build();
const usersSelection = input.users.map(
(user) => new SelectionUser(user.user_id, user.username, user.email, user.is_deleted, user.is_bot, user.do_import),
@ -37,7 +37,7 @@ export const executeStartImport = async ({ input }: StartImportParamsPOST) => {
),
);
const selection = new Selection(importer.name, usersSelection, channelsSelection, 0);
return importer.instance.startImport(selection);
return importer.instance.startImport(selection, startedByUserId);
};
declare module '@rocket.chat/ui-contexts' {
@ -59,6 +59,6 @@ Meteor.methods<ServerMethods>({
throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'startImport');
}
return executeStartImport({ input });
return executeStartImport({ input }, userId);
},
});

@ -1,4 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { Import } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
@ -20,8 +21,9 @@ export const executeUploadImportFile = async (
throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'uploadImportFile');
}
importer.instance = new importer.importer(importer); // eslint-disable-line new-cap
await importer.instance.build();
const operation = await Import.newOperation(userId, importer.name, importer.key);
importer.instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
const date = new Date();
const dateStr = `${date.getUTCFullYear()}${date.getUTCMonth()}${date.getUTCDate()}${date.getUTCHours()}${date.getUTCMinutes()}${date.getUTCSeconds()}`;

@ -1,38 +1,16 @@
import { Meteor } from 'meteor/meteor';
import { Imports, RawImports } from '@rocket.chat/models';
import { Imports } from '@rocket.chat/models';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { ProgressStep } from '../../lib/ImporterProgressStep';
async function runDrop(fn) {
try {
await fn();
} catch (e) {
SystemLogger.error('error', e); // TODO: Remove
// ignored
}
}
Meteor.startup(async function () {
const lastOperation = await Imports.findLastImport();
let idToKeep = false;
// If the operation is ready to start, or already started but failed
// And there's still data for it on the temp collection
// Then we can keep the data there to let the user try again
if (lastOperation && [ProgressStep.USER_SELECTION, ProgressStep.ERROR].includes(lastOperation.status)) {
idToKeep = lastOperation._id;
// If the operation is still on "ready to start" state, we don't need to invalidate it.
if (lastOperation && [ProgressStep.USER_SELECTION].includes(lastOperation.status)) {
await Imports.invalidateOperationsExceptId(lastOperation._id);
return;
}
if (idToKeep) {
await Imports.invalidateOperationsExceptId(idToKeep);
// Clean up all the raw import data, except for the last operation
await runDrop(() => RawImports.deleteMany({ import: { $ne: idToKeep } }));
} else {
await Imports.invalidateAllOperations();
// Clean up all the raw import data
await runDrop(() => RawImports.deleteMany({}));
}
await Imports.invalidateAllOperations();
});

@ -33,6 +33,7 @@ const fullFields = {
requirePasswordChange: 1,
requirePasswordChangeReason: 1,
roles: 1,
importIds: 1,
} as const;
let publicCustomFields: Record<string, 0 | 1> = {};
@ -69,18 +70,26 @@ const getFields = (canViewAllInfo: boolean): Record<string, 0 | 1> => ({
...getCustomFields(canViewAllInfo),
});
export async function getFullUserDataByIdOrUsername(
export async function getFullUserDataByIdOrUsernameOrImportId(
userId: string,
{ filterId, filterUsername }: { filterId: string; filterUsername?: undefined } | { filterId?: undefined; filterUsername: string },
searchValue: string,
searchType: 'id' | 'username' | 'importId',
): Promise<IUser | null> {
const caller = await Users.findOneById(userId, { projection: { username: 1 } });
const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1 } });
if (!caller) {
return null;
}
const targetUser = (filterId || filterUsername) as string;
const myself = (filterId && targetUser === userId) || (filterUsername && targetUser === caller.username);
const myself =
(searchType === 'id' && searchValue === userId) ||
(searchType === 'username' && searchValue === caller.username) ||
(searchType === 'importId' && caller.importIds?.includes(searchValue));
const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info'));
// Only search for importId if the user has permission to view the import id
if (searchType === 'importId' && !canViewAllInfo) {
return null;
}
const fields = getFields(canViewAllInfo);
const options = {
@ -90,7 +99,9 @@ export async function getFullUserDataByIdOrUsername(
},
};
const user = await Users.findOneByIdOrUsername(targetUser, options);
const user = await (searchType === 'importId'
? Users.findOneByImportId(searchValue, options)
: Users.findOneByIdOrUsername(searchValue, options));
if (!user) {
return null;
}

@ -19,12 +19,7 @@ function ImportOperationSummary({
file = '',
user,
small,
count: { users = 0, channels = 0, messages = 0, total = 0 } = {
users: null,
channels: null,
messages: null,
total: null,
},
count: { users = 0, channels = 0, messages = 0, total = 0 } = {},
valid,
}) {
const t = useTranslation();

@ -1,3 +1,4 @@
import type { ProgressStep } from '@rocket.chat/core-typings';
import { Box, Margins, Throbber } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useEndpoint, useTranslation, useStream, useRouter } from '@rocket.chat/ui-contexts';
@ -7,7 +8,6 @@ import React, { useEffect } from 'react';
import { ImportingStartedStates } from '../../../../app/importer/lib/ImporterProgressStep';
import { numberFormat } from '../../../../lib/utils/stringUtils';
import Page from '../../../components/Page';
import type { ProgressStep } from './ImportTypes';
import { useErrorHandler } from './useErrorHandler';
const ImportProgressPage = function ImportProgressPage() {
@ -135,8 +135,18 @@ const ImportProgressPage = function ImportProgressPage() {
);
useEffect(() => {
return streamer('progress', ({ count, key, step, ...rest }) => {
handleProgressUpdated({ ...rest, key: key!, step: step!, completed: count!.completed, total: count!.total });
return streamer('progress', (progress) => {
// There shouldn't be any progress update sending only the rate at this point of the process
if ('rate' in progress) {
return;
}
handleProgressUpdated({
key: progress.key,
step: progress.step,
completed: progress.count.completed,
total: progress.count.total,
});
});
}, [handleProgressUpdated, streamer]);

@ -1,19 +0,0 @@
export type ProgressStep =
| 'importer_new'
| 'importer_uploading'
| 'importer_downloading_file'
| 'importer_file_loaded'
| 'importer_preparing_started'
| 'importer_preparing_users'
| 'importer_preparing_channels'
| 'importer_preparing_messages'
| 'importer_user_selection'
| 'importer_importing_started'
| 'importer_importing_users'
| 'importer_importing_channels'
| 'importer_importing_messages'
| 'importer_importing_files'
| 'importer_finishing'
| 'importer_done'
| 'importer_import_failed'
| 'importer_import_cancelled';

@ -7,6 +7,7 @@ declare module 'meteor/accounts-base' {
password?: string;
profile?: Record<string, unknown>;
joinDefaultChannelsSilenced?: boolean;
skipEmailValidation?: boolean;
},
callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void,
): string;

@ -223,12 +223,12 @@ class LicenseClass {
EnterpriseLicenses.emit('invalidate');
}
async canAddNewUser(): Promise<boolean> {
async canAddNewUser(userCount = 1): Promise<boolean> {
if (!maxActiveUsers) {
return true;
}
return maxActiveUsers > (await Users.getActiveLocalUserCount());
return maxActiveUsers > (await Users.getActiveLocalUserCount()) + userCount;
}
async canEnableApp(app: IAppStorageItem): Promise<boolean> {
@ -352,8 +352,8 @@ export function getAppsConfig(): NonNullable<ILicense['apps']> {
return License.getAppsConfig();
}
export async function canAddNewUser(): Promise<boolean> {
return License.canAddNewUser();
export async function canAddNewUser(userCount = 1): Promise<boolean> {
return License.canAddNewUser(userCount);
}
export async function canEnableApp(app: IAppStorageItem): Promise<boolean> {

@ -30,6 +30,17 @@ callbacks.add(
'check-max-user-seats',
);
callbacks.add(
'beforeUserImport',
async ({ userCount }) => {
if (!(await canAddNewUser(userCount))) {
throw new Meteor.Error('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached'));
}
},
callbacks.priority.MEDIUM,
'check-max-user-seats',
);
callbacks.add(
'beforeActivateUser',
async (user: IUser) => {
@ -119,6 +130,8 @@ callbacks.add('afterDeactivateUser', handleMaxSeatsBanners, callbacks.priority.M
callbacks.add('afterActivateUser', handleMaxSeatsBanners, callbacks.priority.MEDIUM, 'handle-max-seats-banners');
callbacks.add('afterUserImport', handleMaxSeatsBanners, callbacks.priority.MEDIUM, 'handle-max-seats-banners');
Meteor.startup(async () => {
await createSeatsLimitBanners();

@ -97,6 +97,8 @@ interface EventLikeCallbackSignatures {
'livechat.afterDepartmentArchived': (department: Pick<ILivechatDepartmentRecord, '_id'>) => void;
'afterSaveUser': ({ user, oldUser }: { user: IUser; oldUser: IUser | null }) => void;
'livechat.afterTagRemoved': (tag: ILivechatTagRecord) => void;
'beforeUserImport': (data: { userCount: number }) => void;
'afterUserImport': (data: { inserted: IUser['_id'][]; updated: IUser['_id']; skipped: number; failed: number }) => void;
}
/**

@ -151,6 +151,7 @@ export class LDAPManager {
return {
flagEmailsAsVerified: settings.get<boolean>('Accounts_Verify_Email_For_External_Accounts') ?? false,
skipExistingUsers: false,
skipUserCallbacks: false,
};
}

@ -1,6 +0,0 @@
import { registerModel } from '@rocket.chat/models';
import { db } from '../database/utils';
import { RawImports } from './raw/RawImports';
registerModel('IRawImportsModel', new RawImports(db));

@ -6,7 +6,7 @@ import type {
RocketChatRecordDeleted,
} from '@rocket.chat/core-typings';
import type { IImportDataModel } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, Filter } from 'mongodb';
import type { Collection, FindCursor, Db, Filter, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
@ -15,6 +15,10 @@ export class ImportDataRaw extends BaseRaw<IImportRecord> implements IImportData
super(db, 'import_data', trash);
}
protected modelIndexes(): IndexDescription[] {
return [{ key: { dataType: 1 } }];
}
getAllUsers(): FindCursor<IImportUserRecord> {
return this.find({ dataType: 'user' }) as FindCursor<IImportUserRecord>;
}

@ -1,14 +1,20 @@
import type { Db, Document, FindCursor, FindOptions, UpdateResult } from 'mongodb';
import type { Db, Document, FindCursor, FindOptions, UpdateResult, IndexDescription } from 'mongodb';
import type { IImportsModel } from '@rocket.chat/model-typings';
import type { IImport } from '@rocket.chat/core-typings';
import { BaseRaw } from './BaseRaw';
import { ensureArray } from '../../../lib/utils/arrayUtils';
export class ImportsModel extends BaseRaw<any> implements IImportsModel {
export class ImportsModel extends BaseRaw<IImport> implements IImportsModel {
constructor(db: Db) {
super(db, 'import');
}
async findLastImport(): Promise<any | undefined> {
protected modelIndexes(): IndexDescription[] {
return [{ key: { ts: -1 } }, { key: { valid: 1 } }];
}
async findLastImport(): Promise<IImport | undefined> {
const imports = await this.find({}, { sort: { ts: -1 }, limit: 1 }).toArray();
if (imports?.length) {
@ -18,6 +24,18 @@ export class ImportsModel extends BaseRaw<any> implements IImportsModel {
return undefined;
}
async hasValidOperationInStatus(allowedStatus: IImport['status'][]): Promise<boolean> {
return Boolean(
await this.findOne(
{
valid: { $ne: false },
status: { $in: allowedStatus },
},
{ projection: { _id: 1 } },
),
);
}
invalidateAllOperations(): Promise<UpdateResult | Document> {
return this.updateMany({ valid: { $ne: false } }, { $set: { valid: false } });
}
@ -26,13 +44,34 @@ export class ImportsModel extends BaseRaw<any> implements IImportsModel {
return this.updateMany({ valid: { $ne: false }, _id: { $ne: id } }, { $set: { valid: false } });
}
invalidateOperationsNotInStatus(status: string | string[]): Promise<UpdateResult | Document> {
const statusList = ([] as string[]).concat(status);
return this.updateMany({ valid: { $ne: false }, status: { $nin: statusList } }, { $set: { valid: false } });
invalidateOperationsNotInStatus(status: IImport['status'] | IImport['status'][]): Promise<UpdateResult | Document> {
return this.updateMany({ valid: { $ne: false }, status: { $nin: ensureArray(status) } }, { $set: { valid: false } });
}
findAllPendingOperations(options: FindOptions<any> = {}): FindCursor<any> {
findAllPendingOperations(options: FindOptions<IImport> = {}): FindCursor<IImport> {
return this.find({ valid: true }, options);
}
async increaseTotalCount(id: string, recordType: 'users' | 'channels' | 'messages', increaseBy = 1): Promise<UpdateResult> {
return this.updateOne(
{ _id: id },
{
$inc: {
'count.total': increaseBy,
[`count.${recordType}`]: increaseBy,
},
},
);
}
async setOperationStatus(id: string, status: IImport['status']): Promise<UpdateResult> {
return this.updateOne(
{ _id: id },
{
$set: {
status,
},
},
);
}
}

@ -6,6 +6,7 @@ import type {
MessageTypesValues,
RocketChatRecordDeleted,
MessageAttachment,
IMessageWithPendingFileImport,
} from '@rocket.chat/core-typings';
import type { FindPaginated, IMessagesModel } from '@rocket.chat/model-typings';
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
@ -1605,7 +1606,7 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
return this.findOne(query, { sort: { ts: 1 } });
}
findAllImportedMessagesWithFilesToDownload(): FindCursor<IMessage> {
findAllImportedMessagesWithFilesToDownload(): FindCursor<IMessageWithPendingFileImport> {
const query = {
'_importFile.downloadUrl': {
$exists: true,
@ -1621,7 +1622,7 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
},
};
return this.find(query);
return this.find<IMessageWithPendingFileImport>(query);
}
countAllImportedMessagesWithFilesToDownload(): Promise<number> {

@ -1,10 +0,0 @@
import type { Db } from 'mongodb';
import type { IRawImportsModel } from '@rocket.chat/model-typings';
import { BaseRaw } from './BaseRaw';
export class RawImports extends BaseRaw<any> implements IRawImportsModel {
constructor(db: Db) {
super(db, 'raw_imports');
}
}

@ -66,6 +66,7 @@ export class UsersRaw extends BaseRaw {
{ key: { language: 1 }, sparse: true },
{ key: { 'active': 1, 'services.email2fa.enabled': 1 }, sparse: true }, // used by statistics
{ key: { 'active': 1, 'services.totp.enabled': 1 }, sparse: true }, // used by statistics
{ key: { importIds: 1 }, sparse: true },
// Used for case insensitive queries
// @deprecated
// Should be converted to unique index later within a migration to prevent errors of duplicated

@ -65,7 +65,6 @@ import './VoipRoom';
import './WebdavAccounts';
import './FederationRoomEvents';
import './Imports';
import './RawImports';
import './AppsTokens';
import './CronHistory';
import './Migrations';

@ -6,6 +6,7 @@ import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat
import { emit, StreamPresence } from '../../../app/notifications/server/lib/Presence';
import { SystemLogger } from '../../lib/logger/system';
import type { Progress } from '../../../app/importer/server/classes/ImporterProgress';
export class NotificationsModule {
public readonly streamLogged: IStreamer<'notify-logged'>;
@ -535,29 +536,7 @@ export class NotificationsModule {
return this.streamPresence.emitWithoutBroadcast(uid, args);
}
progressUpdated(progress: {
rate: number;
count?: { completed: number; total: number };
step?:
| 'importer_new'
| 'importer_uploading'
| 'importer_downloading_file'
| 'importer_file_loaded'
| 'importer_preparing_started'
| 'importer_preparing_users'
| 'importer_preparing_channels'
| 'importer_preparing_messages'
| 'importer_user_selection'
| 'importer_importing_started'
| 'importer_importing_users'
| 'importer_importing_channels'
| 'importer_importing_messages'
| 'importer_importing_files'
| 'importer_finishing'
| 'importer_done'
| 'importer_import_failed'
| 'importer_import_cancelled';
}): void {
progressUpdated(progress: { rate: number } | Progress): void {
this.streamImporters.emit('progress', progress);
}
}

@ -0,0 +1,163 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import { Imports, ImportData } from '@rocket.chat/models';
import type { IImportUser, IImport, ImportStatus } from '@rocket.chat/core-typings';
import type { IImportService } from '@rocket.chat/core-services';
import { ObjectId } from 'mongodb';
import { Importers } from '../../../app/importer/lib/Importers';
import { Selection } from '../../../app/importer/server/classes/ImporterSelection';
export class ImportService extends ServiceClassInternal implements IImportService {
protected name = 'import';
public async clear(): Promise<void> {
await Imports.invalidateAllOperations();
await ImportData.col.deleteMany({});
}
public async newOperation(userId: string, name: string, key: string): Promise<IImport> {
// Make sure there's no other operation running
await this.clear();
const importId = (
await Imports.insertOne({
type: name,
importerKey: key,
ts: new Date(),
status: 'importer_new',
valid: true,
user: userId,
})
).insertedId;
const operation = await Imports.findOneById(importId);
if (!operation) {
throw new Error('failed to create import operation');
}
return operation;
}
private getStateOfOperation(operation: IImport): 'none' | 'new' | 'loading' | 'ready' | 'importing' | 'done' | 'error' | 'canceled' {
if (!operation.valid && operation.status !== 'importer_done') {
return 'error';
}
switch (operation.status) {
case 'importer_new':
return 'new';
case 'importer_uploading':
case 'importer_downloading_file':
case 'importer_file_loaded':
case 'importer_preparing_started':
case 'importer_preparing_users':
case 'importer_preparing_channels':
case 'importer_preparing_messages':
return 'loading';
case 'importer_user_selection':
return 'ready';
case 'importer_importing_started':
case 'importer_importing_users':
case 'importer_importing_channels':
case 'importer_importing_messages':
case 'importer_importing_files':
case 'importer_finishing':
return 'importing';
case 'importer_done':
return 'done';
case 'importer_import_failed':
return 'error';
case 'importer_import_cancelled':
return 'canceled';
}
}
public async status(): Promise<ImportStatus> {
const operation = await Imports.findLastImport();
if (!operation) {
return {
state: 'none',
};
}
const state = this.getStateOfOperation(operation);
return {
state,
operation,
};
}
private assertsValidStateForNewData(operation: IImport | null): asserts operation is IImport {
if (!operation?.valid) {
throw new Error('Import operation not initialized.');
}
const state = this.getStateOfOperation(operation);
switch (state) {
case 'loading':
case 'importing':
throw new Error('The current import operation can not receive new data.');
case 'done':
case 'error':
case 'canceled':
throw new Error('The current import operation is already finished.');
}
}
public async addUsers(users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[]): Promise<void> {
if (!users.length) {
return;
}
const operation = await Imports.findLastImport();
this.assertsValidStateForNewData(operation);
for await (const user of users) {
if (!user.emails?.some((value) => value) || !user.importIds?.some((value) => value)) {
throw new Error('Users are missing required data.');
}
}
await ImportData.col.insertMany(
users.map((data) => ({
_id: new ObjectId().toHexString(),
data,
dataType: 'user',
})),
);
await Imports.increaseTotalCount(operation._id, 'users', users.length);
await Imports.setOperationStatus(operation._id, 'importer_user_selection');
}
public async run(userId: string): Promise<void> {
const operation = await Imports.findLastImport();
if (!operation?.valid) {
throw new Error('error-operation-not-found');
}
if (operation.status !== 'importer_user_selection') {
throw new Error('error-invalid-operation-status');
}
const { importerKey } = operation;
const importer = Importers.get(importerKey);
if (!importer) {
throw new Error('error-importer-not-defined');
}
// eslint-disable-next-line new-cap
const instance = new importer.importer(importer, operation, {
skipUserCallbacks: true,
skipDefaultChannels: true,
// Do not update the data of existing users, but add the importId to them if it's missing
skipExistingUsers: true,
bindSkippedUsers: true,
});
const selection = new Selection(importer.name, [], [], 0);
await instance.startImport(selection, userId);
}
}

@ -27,6 +27,7 @@ import { MessageService } from './messages/service';
import { TranslationService } from './translation/service';
import { SettingsService } from './settings/service';
import { OmnichannelIntegrationService } from './omnichannel-integrations/service';
import { ImportService } from './import/service';
import { Logger } from '../lib/logger/Logger';
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
@ -55,6 +56,7 @@ api.registerService(new MessageService());
api.registerService(new TranslationService());
api.registerService(new SettingsService());
api.registerService(new OmnichannelIntegrationService());
api.registerService(new ImportService());
// if the process is running in micro services mode we don't need to register services that will run separately
if (!isRunningMs()) {

@ -20,6 +20,7 @@ import type {
ICalendarNotification,
IUserStatus,
ILivechatInquiryRecord,
IImportProgress,
} from '@rocket.chat/core-typings';
type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed';
@ -161,33 +162,7 @@ export interface StreamerEvents {
'importers': [
{
key: 'progress';
args: [
{
step?:
| 'importer_new'
| 'importer_uploading'
| 'importer_downloading_file'
| 'importer_file_loaded'
| 'importer_preparing_started'
| 'importer_preparing_users'
| 'importer_preparing_channels'
| 'importer_preparing_messages'
| 'importer_user_selection'
| 'importer_importing_started'
| 'importer_importing_users'
| 'importer_importing_channels'
| 'importer_importing_messages'
| 'importer_importing_files'
| 'importer_finishing'
| 'importer_done'
| 'importer_import_failed'
| 'importer_import_cancelled';
rate: number;
key?: string;
name?: string;
count?: { completed: number; total: number };
},
];
args: [{ rate: number } | IImportProgress];
},
];

@ -44,6 +44,7 @@ import type { IMessageService } from './types/IMessageService';
import type { ISettingsService } from './types/ISettingsService';
import type { IOmnichannelEEService } from './types/IOmnichannelEEService';
import type { IOmnichannelIntegrationService } from './types/IOmnichannelIntegrationService';
import type { IImportService } from './types/IImportService';
export { asyncLocalStorage } from './lib/asyncLocalStorage';
export { MeteorError, isMeteorError } from './MeteorError';
@ -118,6 +119,7 @@ export {
ISettingsService,
IOmnichannelEEService,
IOmnichannelIntegrationService,
IImportService,
};
// TODO think in a way to not have to pass the service name to proxify here as well
@ -152,6 +154,7 @@ export const OmnichannelIntegration = proxifyWithWait<IOmnichannelIntegrationSer
export const Federation = proxifyWithWait<IFederationService>('federation');
export const FederationEE = proxifyWithWait<IFederationServiceEE>('federation-enterprise');
export const OmnichannelEEService = proxifyWithWait<IOmnichannelEEService>('omnichannel-ee');
export const Import = proxifyWithWait<IImportService>('import');
// Calls without wait. Means that the service is optional and the result may be an error
// of service/method not available

@ -0,0 +1,9 @@
import type { IImport, IImportUser, ImportStatus } from '@rocket.chat/core-typings';
export interface IImportService {
clear(): Promise<void>;
newOperation(userId: string, name: string, key: string): Promise<IImport>;
status(): Promise<ImportStatus>;
addUsers(users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[]): Promise<void>;
run(userId: string): Promise<void>;
}

@ -365,3 +365,17 @@ export type IVideoConfMessage = IMessage & {
export const isE2EEMessage = (message: IMessage): message is IE2EEMessage => message.t === 'e2e';
export const isOTRMessage = (message: IMessage): message is IOTRMessage => message.t === 'otr' || message.t === 'otr-ack';
export const isVideoConfMessage = (message: IMessage): message is IVideoConfMessage => message.t === 'videoconf';
export type IMessageWithPendingFileImport = IMessage & {
_importFile: {
downloadUrl: string;
id: string;
size: number;
name: string;
external: boolean;
source: 'slack' | 'hipchat-enterprise';
original: Record<string, any>;
rocketChatUrl?: string;
downloaded?: boolean;
};
};

@ -180,6 +180,7 @@ export interface IUser extends IRocketChatRecord {
};
};
importIds?: string[];
_pendingAvatarUrl?: string;
}
export interface IRegisterUser extends IUser {

@ -1,14 +1,22 @@
import type { IRocketChatRecord } from '../IRocketChatRecord';
import type { IUser } from '../IUser';
import type { ProgressStep } from './IImportProgress';
export interface IImport extends IRocketChatRecord {
type: string;
importerKey: string;
ts: Date;
status: string;
status: ProgressStep;
valid: boolean;
user: IUser['_id'];
_updatedAt: Date;
contentType?: string;
file?: string;
count?: {
total?: number;
completed?: number;
users?: number;
messages?: number;
channels?: number;
};
}

@ -1,4 +1,4 @@
export type IImportedId = 'string';
export type IImportedId = string;
export interface IImportMessageReaction {
name: string;
@ -16,9 +16,9 @@ export interface IImportPendingFile {
}
export interface IImportAttachment extends Record<string, any> {
text: string;
title: string;
fallback: string;
text?: string;
title?: string;
fallback?: string;
}
export interface IImportMessage {
@ -44,7 +44,7 @@ export interface IImportMessage {
editedBy?: IImportedId;
mentions?: Array<IImportedId>;
channels?: Array<string>;
attachments?: IImportAttachment;
attachments?: IImportAttachment[];
bot?: boolean;
emoji?: string;

@ -16,4 +16,6 @@ export interface IImportUser {
services?: Record<string, Record<string, any>>;
customFields?: Record<string, any>;
password?: string;
}

@ -0,0 +1,9 @@
import type { IImporterSelectionChannel } from './IImporterSelectionChannel';
import type { IImporterSelectionUser } from './IImporterSelectionUser';
export interface IImporterSelection {
name: string;
users: IImporterSelectionUser[];
channels: IImporterSelectionChannel[];
message_count: number;
}

@ -0,0 +1,9 @@
export interface IImporterSelectionChannel {
channel_id: string;
name: string | undefined;
is_archived: boolean;
do_import: boolean;
is_private: boolean;
creator: undefined;
is_direct: boolean;
}

@ -0,0 +1,9 @@
export interface IImporterSelectionUser {
user_id: string;
username: string | undefined;
email: string;
is_deleted: boolean;
is_bot: boolean;
do_import: boolean;
is_email_taken: boolean;
}

@ -0,0 +1,10 @@
import type { IImport } from './IImport';
export type ImportState = 'none' | 'new' | 'loading' | 'ready' | 'importing' | 'done' | 'error' | 'canceled';
export type ImportStatus =
| { state: 'none' }
| {
state: ImportState;
operation: IImport;
};

@ -5,3 +5,7 @@ export * from './IImportMessage';
export * from './IImportChannel';
export * from './IImportFileData';
export * from './IImportProgress';
export * from './IImporterSelection';
export * from './IImporterSelectionUser';
export * from './IImporterSelectionChannel';
export * from './ImportState';

@ -73,7 +73,6 @@ export * from './models/IAppLogsModel';
export * from './models/IAppsModel';
export * from './models/IAppsPersistenceModel';
export * from './models/IImportsModel';
export * from './models/IRawImportsModel';
export * from './models/IFederationRoomEventsModel';
export * from './models/IAppsTokensModel';
export * from './models/IAuditLogModel';

@ -1,11 +1,15 @@
import type { UpdateResult, FindOptions, FindCursor, Document } from 'mongodb';
import type { IImport } from '@rocket.chat/core-typings';
import type { IBaseModel } from './IBaseModel';
export interface IImportsModel extends IBaseModel<any> {
export interface IImportsModel extends IBaseModel<IImport> {
findLastImport(): Promise<any | undefined>;
hasValidOperationInStatus(allowedStatus: IImport['status'][]): Promise<boolean>;
invalidateAllOperations(): Promise<UpdateResult | Document>;
invalidateOperationsExceptId(id: string): Promise<UpdateResult | Document>;
invalidateOperationsNotInStatus(status: string | string[]): Promise<UpdateResult | Document>;
findAllPendingOperations(options: FindOptions<any>): FindCursor<any>;
findAllPendingOperations(options: FindOptions<IImport>): FindCursor<IImport>;
increaseTotalCount(id: string, recordType: 'users' | 'channels' | 'messages', increaseBy?: number): Promise<UpdateResult>;
setOperationStatus(id: string, status: IImport['status']): Promise<UpdateResult>;
}

@ -1,4 +1,12 @@
import type { IMessage, IRoom, IUser, ILivechatDepartment, MessageTypesValues, MessageAttachment } from '@rocket.chat/core-typings';
import type {
IMessage,
IRoom,
IUser,
ILivechatDepartment,
MessageTypesValues,
MessageAttachment,
IMessageWithPendingFileImport,
} from '@rocket.chat/core-typings';
import type {
AggregationCursor,
CountDocumentsOptions,
@ -240,7 +248,7 @@ export interface IMessagesModel extends IBaseModel<IMessage> {
setAsReadById(_id: string): Promise<UpdateResult>;
countThreads(): Promise<number>;
addThreadFollowerByThreadId(tmid: string, userId: string): Promise<UpdateResult>;
findAllImportedMessagesWithFilesToDownload(): FindCursor<IMessage>;
findAllImportedMessagesWithFilesToDownload(): FindCursor<IMessageWithPendingFileImport>;
countAllImportedMessagesWithFilesToDownload(): Promise<number>;
findAgentLastMessageByVisitorLastMessageTs(roomId: string, visitorLastMessageTs: Date): Promise<IMessage | null>;
removeThreadFollowerByThreadId(tmid: string, userId: string): Promise<UpdateResult>;

@ -1,3 +0,0 @@
import type { IBaseModel } from './IBaseModel';
export type IRawImportsModel = IBaseModel<any>;

@ -326,7 +326,7 @@ export interface IUsersModel extends IBaseModel<IUser> {
getSAMLByIdAndSAMLProvider(userId: string, samlProvider: string): Promise<IUser | null>;
findBySAMLNameIdOrIdpSession(samlNameId: string, idpSession: string): FindCursor<IUser>;
findBySAMLInResponseTo(inResponseTo: string): FindCursor<IUser>;
addImportIds(userId: string, importIds: Array<{ service: string; id: string }>): Promise<UpdateResult>;
addImportIds(userId: string, importIds: string | string[]): Promise<UpdateResult>;
updateInviteToken(userId: string, token: string): Promise<UpdateResult>;
updateLastLoginById(userId: string): Promise<UpdateResult>;
addPasswordToHistory(userId: string, password: string, passwordHistoryAmount: number): Promise<UpdateResult>;

@ -72,7 +72,6 @@ import type {
IAppsPersistenceModel,
IAppLogsModel,
IImportsModel,
IRawImportsModel,
IFederationRoomEventsModel,
IAppsTokensModel,
IAuditLogModel,
@ -143,7 +142,6 @@ export const PushToken = proxify<IPushTokenModel>('IPushTokenModel');
export const Permissions = proxify<IPermissionsModel>('IPermissionsModel');
export const ReadReceipts = proxify<IReadReceiptsModel>('IReadReceiptsModel');
export const MessageReads = proxify<IMessageReadsModel>('IMessageReadsModel');
export const RawImports = proxify<IRawImportsModel>('IRawImportsModel');
export const Reports = proxify<IReportsModel>('IReportsModel');
export const Roles = proxify<IRolesModel>('IRolesModel');
export const Rooms = proxify<IRoomsModel>('IRoomsModel');

@ -0,0 +1,57 @@
import type { IImportUser } from '@rocket.chat/core-typings';
import Ajv from 'ajv';
const ajv = new Ajv({
coerceTypes: true,
});
export type ImportAddUsersParamsPOST = {
users: [Omit<IImportUser, '_id' | 'services' | 'customFields'>];
};
const ImportAddUsersParamsPostSchema = {
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
properties: {
username: { type: 'string', nullable: true },
emails: {
type: 'array',
items: {
type: 'string',
},
},
importIds: {
type: 'array',
items: {
type: 'string',
},
},
name: { type: 'string', nullable: true },
utcOffset: { type: 'number', nullable: true },
avatarUrl: { type: 'string', nullable: true },
deleted: { type: 'boolean', nullable: true },
statusText: { type: 'string', nullable: true },
roles: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
type: { type: 'string', nullable: true },
bio: { type: 'string', nullable: true },
password: { type: 'string', nullable: true },
},
required: ['emails', 'importIds'],
},
},
},
additionalProperties: false,
required: ['users'],
};
export const isImportAddUsersParamsPOST = ajv.compile<ImportAddUsersParamsPOST>(ImportAddUsersParamsPostSchema);

@ -1,4 +1,4 @@
import type { IImport, IImportFileData, IImportProgress } from '@rocket.chat/core-typings';
import type { IImport, IImporterSelection, IImportProgress, ImportStatus, IImportUser } from '@rocket.chat/core-typings';
import type { DownloadPublicImportFileParamsPOST } from './DownloadPublicImportFileParamsPOST';
import type { StartImportParamsPOST } from './StartImportParamsPOST';
@ -15,7 +15,7 @@ export type ImportEndpoints = {
POST: (params: StartImportParamsPOST) => void;
};
'/v1/getImportFileData': {
GET: () => IImportFileData | { waiting: true };
GET: () => IImporterSelection | { waiting: true };
};
'/v1/getImportProgress': {
GET: () => IImportProgress;
@ -32,4 +32,19 @@ export type ImportEndpoints = {
'/v1/getCurrentImportOperation': {
GET: () => { operation: IImport };
};
'/v1/import.clear': {
POST: () => void;
};
'/v1/import.new': {
POST: () => { operation: IImport };
};
'/v1/import.status': {
GET: () => ImportStatus;
};
'/v1/import.addUsers': {
POST: (users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[]) => void;
};
'/v1/import.run': {
POST: () => void;
};
};

@ -8,3 +8,4 @@ export * from './GetImportProgressParamsGET';
export * from './GetLatestImportOperationsParamsGET';
export * from './StartImportParamsPOST';
export * from './UploadImportFileParamsPOST';
export * from './ImportAddUsersParamsPOST';

@ -4,7 +4,7 @@ const ajv = new Ajv({
coerceTypes: true,
});
export type UsersInfoParamsGet = ({ userId: string } | { username: string }) & {
export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & {
fields?: string;
};
@ -38,6 +38,20 @@ const UsersInfoParamsGetSchema = {
required: ['username'],
additionalProperties: false,
},
{
type: 'object',
properties: {
importId: {
type: 'string',
},
fields: {
type: 'string',
nullable: true,
},
},
required: ['importId'],
additionalProperties: false,
},
],
};

Loading…
Cancel
Save