|
|
|
|
@ -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); |
|
|
|
|
|