snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import type { IImportContact, IImportContactRecord } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
import { RecordConverter } from './RecordConverter';
import { createContact } from '../../../omnichannel/contacts/createContact';
import { getAllowedCustomFields } from '../../../omnichannel/contacts/getAllowedCustomFields';
import { validateCustomFields } from '../../../omnichannel/contacts/validateCustomFields';
export class ContactConverter extends RecordConverter<IImportContactRecord> {
protected async convertCustomFields(customFields: IImportContact['customFields']): Promise<IImportContact['customFields']> {
if (!customFields) {
return;
}
const allowedCustomFields = await getAllowedCustomFields();
return validateCustomFields(allowedCustomFields, customFields, { ignoreAdditionalFields: true });
}
protected override async convertRecord(record: IImportContactRecord): Promise<boolean> {
const { data } = record;
await createContact({
name: data.name || (await this.generateNewContactName()),
emails: data.emails,
phones: data.phones,
customFields: await this.convertCustomFields(data.customFields),
contactManager: await this._cache.getIdOfUsername(data.contactManager),
unknown: false,
importIds: data.importIds,
});
return true;
}
protected async generateNewContactName(): Promise<string> {
return LivechatVisitors.getNextVisitorUsername();
}
protected override getDataType(): 'contact' {
return 'contact';
}
}

View File

@@ -0,0 +1,224 @@
import type { IImportUser, IUser } from '@rocket.chat/core-typings';
import { Rooms, Users } from '@rocket.chat/models';
export type UserIdentification = {
_id: string;
username: string | undefined;
};
export type MentionedChannel = {
_id: string;
name: string;
};
export class ConverterCache {
private _userCache = new Map<string, UserIdentification>();
// display name uses a different cache because it's only used on mentions so we don't need to load it every time we load an user
private _userDisplayNameCache = new Map<string, string>();
private _userNameToIdCache = new Map<string, string | undefined>();
private _roomCache = new Map<string, string>();
private _roomNameCache = new Map<string, string>();
addUser(importId: string, _id: string, username: string | undefined): UserIdentification {
const cache = {
_id,
username,
};
this._userCache.set(importId, cache);
if (username) {
this._userNameToIdCache.set(username, _id);
}
return cache;
}
addUserDisplayName(importId: string, name: string): string {
this._userDisplayNameCache.set(importId, name);
return name;
}
addRoom(importId: string, rid: string): string {
this._roomCache.set(importId, rid);
return rid;
}
addRoomName(importId: string, name: string): string {
this._roomNameCache.set(importId, name);
return name;
}
addUserData(userData: IImportUser): void {
if (!userData._id) {
return;
}
if (!userData.importIds.length) {
return;
}
this.addUser(userData.importIds[0], userData._id, userData.username);
}
addUsernameToId(username: string, id: string): void {
this._userNameToIdCache.set(username, id);
}
async findImportedRoomId(importId: string): Promise<string | null> {
if (this._roomCache.has(importId)) {
return this._roomCache.get(importId) as string;
}
const options = {
projection: {
_id: 1,
},
};
const room = await Rooms.findOneByImportId(importId, options);
if (room) {
return this.addRoom(importId, room._id);
}
return null;
}
async findImportedRoomName(importId: string): Promise<string | undefined> {
if (this._roomNameCache.has(importId)) {
return this._roomNameCache.get(importId) as string;
}
const options = {
projection: {
_id: 1,
name: 1,
},
};
const room = await Rooms.findOneByImportId(importId, options);
if (room) {
if (!this._roomCache.has(importId)) {
this.addRoom(importId, room._id);
}
if (room?.name) {
return this.addRoomName(importId, room.name);
}
}
}
async findImportedUser(importId: string): Promise<UserIdentification | null> {
if (importId === 'rocket.cat') {
return {
_id: 'rocket.cat',
username: 'rocket.cat',
};
}
const options = {
projection: {
_id: 1,
username: 1,
},
};
if (this._userCache.has(importId)) {
return this._userCache.get(importId) as UserIdentification;
}
const user = await Users.findOneByImportId(importId, options);
if (user) {
return this.addUser(importId, user._id, user.username);
}
return null;
}
async findImportedUserId(_id: string): Promise<string | undefined> {
const data = await this.findImportedUser(_id);
return data?._id;
}
async findImportedUsername(_id: string): Promise<string | undefined> {
const data = await this.findImportedUser(_id);
return data?.username;
}
async findImportedUserDisplayName(importId: string): Promise<string | undefined> {
const options = {
projection: {
_id: 1,
name: 1,
username: 1,
},
};
if (this._userDisplayNameCache.has(importId)) {
return this._userDisplayNameCache.get(importId);
}
const user =
importId === 'rocket.cat' ? await Users.findOneById('rocket.cat', options) : await Users.findOneByImportId(importId, options);
if (user) {
if (!this._userCache.has(importId)) {
this.addUser(importId, user._id, user.username);
}
if (!user.name) {
return;
}
return this.addUserDisplayName(importId, user.name);
}
}
async convertImportedIdsToUsernames(importedIds: Array<string>, idToRemove: string | undefined = undefined): Promise<Array<string>> {
return (
await Promise.all(
importedIds.map(async (user) => {
if (user === 'rocket.cat') {
return user;
}
if (this._userCache.has(user)) {
const cache = this._userCache.get(user);
if (cache) {
return cache.username;
}
}
const obj = await Users.findOneByImportId(user, { projection: { _id: 1, username: 1 } });
if (obj) {
this.addUser(user, obj._id, obj.username);
if (idToRemove && obj._id === idToRemove) {
return false;
}
return obj.username;
}
return false;
}),
)
).filter((user) => user) as string[];
}
async getIdOfUsername(username: string | undefined): Promise<string | undefined> {
if (!username) {
return;
}
if (this._userNameToIdCache.has(username)) {
return this._userNameToIdCache.get(username);
}
const user = await Users.findOneByUsername<Pick<IUser, '_id'>>(username, { projection: { _id: 1 } });
if (user) {
this.addUsernameToId(username, user._id);
}
return user?._id;
}
}

View File

@@ -0,0 +1,263 @@
import type { IImportMessageRecord, IMessage as IDBMessage, IImportMessage, IImportMessageReaction } from '@rocket.chat/core-typings';
import { Rooms } from '@rocket.chat/models';
import { removeEmpty } from '@rocket.chat/tools';
import limax from 'limax';
import type { UserIdentification, MentionedChannel } from './ConverterCache';
import { RecordConverter } from './RecordConverter';
import { insertMessage } from '../../../messages/insertMessage';
import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks';
export type MessageConversionCallbacks = IConversionCallbacks & { afterImportAllMessagesFn?: (roomIds: string[]) => Promise<void> };
type MessageObject = Record<string, any>;
type MentionedUser = {
_id: string;
username: string;
name?: string;
};
type IMessageReaction = {
name: string;
usernames: string[];
};
type IMessageReactions = Record<string, IMessageReaction>;
export class MessageConverter extends RecordConverter<IImportMessageRecord> {
private rids: string[] = [];
override async convertData({ afterImportAllMessagesFn, ...callbacks }: MessageConversionCallbacks = {}): Promise<void> {
this.rids = [];
await super.convertData(callbacks);
await this.resetLastMessages();
if (afterImportAllMessagesFn) {
await afterImportAllMessagesFn(this.rids);
}
}
protected async resetLastMessages(): Promise<void> {
for (const rid of this.rids) {
try {
await Rooms.resetLastMessageById(rid, null);
} catch (err) {
this._logger.error({ msg: 'Failed to update last message of room', roomId: rid, err });
}
}
}
protected async insertMessage(data: IImportMessage): Promise<void> {
if (!data.ts || isNaN(data.ts as unknown as number)) {
throw new Error('importer-message-invalid-timestamp');
}
const creator = await this._cache.findImportedUser(data.u._id);
if (!creator) {
this._logger.warn({ msg: 'Imported user not found', userId: data.u._id });
throw new Error('importer-message-unknown-user');
}
const rid = await this._cache.findImportedRoomId(data.rid);
if (!rid) {
throw new Error('importer-message-unknown-room');
}
if (!this.rids.includes(rid)) {
this.rids.push(rid);
}
const msgObj = await this.buildMessageObject(data, rid, creator);
try {
await insertMessage(creator, msgObj as unknown as IDBMessage, rid, true);
} catch (err) {
this._logger.error({ msg: 'Failed to import message', timestamp: msgObj.ts, roomId: rid, err });
}
}
protected override async convertRecord(record: IImportMessageRecord): Promise<boolean> {
await this.insertMessage(record.data);
return true;
}
protected async buildMessageObject(data: IImportMessage, rid: string, creator: UserIdentification): Promise<MessageObject> {
// Convert the mentions and channels first because these conversions can also modify the msg in the message object
const mentions = data.mentions && (await this.convertMessageMentions(data));
const channels = data.channels && (await this.convertMessageChannels(data));
return removeEmpty({
rid,
u: {
_id: creator._id,
username: creator.username,
},
msg: data.msg,
ts: data.ts,
t: data.t || undefined,
groupable: data.groupable,
tmid: data.tmid,
tlm: data.tlm,
tcount: data.tcount,
replies: data.replies && (await this.convertMessageReplies(data.replies)),
editedAt: data.editedAt,
editedBy: data.editedBy && ((await this._cache.findImportedUser(data.editedBy)) || undefined),
mentions,
channels,
_importFile: data._importFile,
_hidden: data._hidden,
url: data.url,
attachments: data.attachments,
bot: data.bot,
emoji: data.emoji,
alias: data.alias,
...(data._id ? { _id: data._id } : {}),
...(data.reactions ? { reactions: await this.convertMessageReactions(data.reactions) } : {}),
});
}
protected async convertMessageChannels(message: IImportMessage): Promise<MentionedChannel[] | undefined> {
const { channels } = message;
if (!channels) {
return;
}
const result: MentionedChannel[] = [];
for (const importId of channels) {
const { name, _id } = (await this.getMentionedChannelData(importId)) || {};
if (!_id || !name) {
this._logger.warn({ msg: 'Mentioned room not found', importId });
continue;
}
message.msg = message.msg.replace(new RegExp(`\#${importId}`, 'gi'), `#${name}`);
result.push({
_id,
name,
});
}
return result;
}
protected async convertMessageMentions(message: IImportMessage): Promise<MentionedUser[] | undefined> {
const { mentions } = message;
if (!mentions) {
return undefined;
}
const result: MentionedUser[] = [];
for (const importId of mentions) {
if (importId === ('all' as 'string') || importId === 'here') {
result.push({
_id: importId,
username: importId,
});
continue;
}
// Loading the name will also store the remaining data on the cache if it's missing, so this won't run two queries
const name = await this._cache.findImportedUserDisplayName(importId);
const data = await this._cache.findImportedUser(importId);
if (!data) {
this._logger.warn({ msg: 'Mentioned user not found', importId });
continue;
}
if (!data.username) {
this._logger.debug({ msg: 'Mentioned user has no username', importId });
throw new Error('importer-message-mentioned-username-not-found');
}
message.msg = message.msg.replace(new RegExp(`\@${importId}`, 'gi'), `@${data.username}`);
result.push({
_id: data._id,
username: data.username as 'string',
name,
});
}
return result;
}
protected async convertMessageReactions(
importedReactions: Record<string, IImportMessageReaction>,
): Promise<undefined | IMessageReactions> {
const reactions: IMessageReactions = {};
for (const name of Object.keys(importedReactions)) {
if (!importedReactions.hasOwnProperty(name)) {
continue;
}
const { users } = importedReactions[name];
if (!users.length) {
continue;
}
const reaction: IMessageReaction = {
name,
usernames: [],
};
for (const importId of users) {
const username = await this._cache.findImportedUsername(importId);
if (username && !reaction.usernames.includes(username)) {
reaction.usernames.push(username);
}
}
if (reaction.usernames.length) {
reactions[name] = reaction;
}
}
if (Object.keys(reactions).length > 0) {
return reactions;
}
}
protected async convertMessageReplies(replies: string[]): Promise<string[]> {
const result: string[] = [];
for (const importId of replies) {
const userId = await this._cache.findImportedUserId(importId);
if (userId && !result.includes(userId)) {
result.push(userId);
}
}
return result;
}
protected async getMentionedChannelData(importId: string): Promise<MentionedChannel | undefined> {
// loading the name will also store the id on the cache if it's missing, so this won't run two queries
const name = await this._cache.findImportedRoomName(importId);
const _id = await this._cache.findImportedRoomId(importId);
if (name && _id) {
return {
name,
_id,
};
}
// If the importId was not found, check if we have a room with that name
const roomName = limax(importId.trim(), { maintainCase: true });
const room = await Rooms.findOneByNonValidatedName(roomName, { projection: { name: 1 } });
if (room?.name) {
this._cache.addRoom(importId, room._id);
this._cache.addRoomName(importId, room.name);
return {
name: room.name,
_id: room._id,
};
}
}
protected override getDataType(): 'message' {
return 'message';
}
}

View File

@@ -0,0 +1,252 @@
import type { IImportRecord } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { ImportData } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { type FindCursor, ObjectId } from 'mongodb';
import { ConverterCache } from './ConverterCache';
import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks';
export type RecordConverterOptions = {
workInMemory?: boolean;
deleteDbData?: boolean;
};
export class RecordConverter<R extends IImportRecord, T extends RecordConverterOptions = RecordConverterOptions> {
protected _logger: Logger;
protected _cache: ConverterCache;
protected _converterOptions: RecordConverterOptions;
protected _options: Omit<T, keyof RecordConverterOptions>;
protected _records: R[];
protected skippedCount = 0;
protected failedCount = 0;
protected newCount = 0;
public aborted = false;
constructor(options?: T, logger?: Logger, cache?: ConverterCache) {
const { workInMemory = false, deleteDbData = false, ...customOptions } = options || ({} as T);
this._converterOptions = {
workInMemory,
deleteDbData,
};
this._options = customOptions;
this._logger = logger || new Logger(`Data Importer - ${this.constructor.name}`);
this._cache = cache || new ConverterCache();
this._records = [];
}
private skipMemoryRecord(_id: string): void {
const record = this.getMemoryRecordById(_id);
if (!record) {
return;
}
record.skipped = true;
}
private async skipDatabaseRecord(_id: string): Promise<void> {
await ImportData.updateOne(
{
_id,
},
{
$set: {
skipped: true,
},
},
);
}
protected async skipRecord(_id: string): Promise<void> {
this.skippedCount++;
this.skipMemoryRecord(_id);
if (!this._converterOptions.workInMemory) {
return this.skipDatabaseRecord(_id);
}
}
private saveErrorToMemory(importId: string, error: Error): void {
const record = this.getMemoryRecordById(importId);
if (!record) {
return;
}
if (!record.errors) {
record.errors = [];
}
record.errors.push({
message: error.message,
stack: error.stack,
});
}
private async saveErrorToDatabase(importId: string, error: Error): Promise<void> {
await ImportData.updateOne(
{
_id: importId,
},
{
$push: {
errors: {
message: error.message,
stack: error.stack,
},
},
},
);
}
protected async saveError(importId: string, error: Error): Promise<void> {
this._logger.error({
msg: 'Import record conversion failed',
importId,
err: error,
});
this.saveErrorToMemory(importId, error);
if (!this._converterOptions.workInMemory) {
return this.saveErrorToDatabase(importId, error);
}
}
public async clearImportData(): Promise<void> {
this._records = [];
// On regular import operations this data will be deleted by the importer class with one single operation for all dataTypes (aka with no filter)
if (!this._converterOptions.workInMemory && this._converterOptions.deleteDbData) {
await ImportData.col.deleteMany({ dataType: this.getDataType() });
}
}
public async clearSuccessfullyImportedData(): Promise<void> {
this._records = this._records.filter((record) => !record.errors?.length);
// On regular import operations this data will be deleted by the importer class with one single operation for all dataTypes (aka with no filter)
if (!this._converterOptions.workInMemory && this._converterOptions.deleteDbData) {
await ImportData.col.deleteMany({ dataType: this.getDataType(), error: { $exists: false } });
}
}
private getMemoryRecordById(id: string): R | undefined {
for (const record of this._records) {
if (record._id === id) {
return record;
}
}
return undefined;
}
protected getDataType(): R['dataType'] {
throw new Error('Unspecified type');
}
protected async addObjectToDatabase(data: R['data'], options: R['options'] = {}): Promise<void> {
await ImportData.col.insertOne({
_id: new ObjectId().toHexString(),
data,
dataType: this.getDataType(),
options,
_updatedAt: new Date(),
});
}
public addObjectToMemory(data: R['data'], options: R['options'] = {}): void {
this._records.push({
_id: Random.id(),
data,
dataType: this.getDataType(),
options,
} as R);
}
public async addObject(data: R['data'], options: R['options'] = {}): Promise<void> {
if (this._converterOptions.workInMemory) {
return this.addObjectToMemory(data, options);
}
return this.addObjectToDatabase(data, options);
}
protected getDatabaseDataToImport(): Promise<R[]> {
return (ImportData.find({ dataType: this.getDataType() }) as FindCursor<R>).toArray();
}
protected async getDataToImport(): Promise<R[]> {
if (this._converterOptions.workInMemory) {
return this._records;
}
const dbRecords = await this.getDatabaseDataToImport();
if (this._records.length) {
return [...this._records, ...dbRecords];
}
return dbRecords;
}
protected async iterateRecords({
beforeImportFn,
afterImportFn,
onErrorFn,
processRecord,
afterBatchFn,
}: IConversionCallbacks & { processRecord?: (record: R) => Promise<boolean | undefined> } = {}): Promise<void> {
const records = await this.getDataToImport();
this.skippedCount = 0;
this.failedCount = 0;
this.newCount = 0;
for (const record of records) {
const { _id } = record;
if (this.aborted) {
return;
}
try {
if (beforeImportFn && !(await beforeImportFn(record))) {
await this.skipRecord(_id);
continue;
}
const isNew = await (processRecord || this.convertRecord).call(this, record);
if (typeof isNew === 'boolean') {
this.newCount++;
if (afterImportFn) {
await afterImportFn(record, isNew);
}
}
} catch (e) {
this.failedCount++;
await this.saveError(_id, e instanceof Error ? e : new Error(String(e)));
if (onErrorFn) {
await onErrorFn();
}
}
}
if (afterBatchFn) {
await afterBatchFn(this.newCount, this.failedCount);
}
}
async convertData(callbacks: IConversionCallbacks = {}): Promise<void> {
return this.iterateRecords(callbacks);
}
protected async convertRecord(_record: R): Promise<boolean | undefined> {
return undefined;
}
}

View File

@@ -0,0 +1,201 @@
import type { IImportChannel, IImportChannelRecord, IRoom } from '@rocket.chat/core-typings';
import { Subscriptions, Rooms, Users } from '@rocket.chat/models';
import { removeEmpty } from '@rocket.chat/tools';
import limax from 'limax';
import { RecordConverter } from './RecordConverter';
import { createDirectMessage } from '../../../../meteor-methods/messages/createDirectMessage';
import { createChannelMethod } from '../../../../meteor-methods/rooms/createChannel';
import { createPrivateGroupMethod } from '../../../../meteor-methods/rooms/createPrivateGroup';
import { saveRoomSettings } from '../../../../meteor-methods/rooms/saveRoomSettings';
import { notifyOnSubscriptionChangedByRoomId } from '../../../notifyListener';
import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks';
export class RoomConverter extends RecordConverter<IImportChannelRecord> {
public startedByUserId: string;
async convertChannels(startedByUserId: string, callbacks: IConversionCallbacks = {}): Promise<void> {
this.startedByUserId = startedByUserId;
return this.convertData(callbacks);
}
protected override async convertRecord(record: IImportChannelRecord): Promise<boolean> {
const { data } = record;
if (!data.name && data.t !== 'd') {
throw new Error('importer-channel-missing-name');
}
data.importIds = data.importIds.filter((item) => item);
data.users = [...new Set(data.users)];
if (!data.importIds.length) {
throw new Error('importer-channel-missing-import-id');
}
const existingRoom = await this.findExistingRoom(data);
await this.insertOrUpdateRoom(existingRoom, data, this.startedByUserId);
return !existingRoom;
}
async insertOrUpdateRoom(existingRoom: IRoom | null, data: IImportChannel, startedByUserId: string): Promise<void> {
if (existingRoom) {
await this.updateRoom(existingRoom, data, startedByUserId);
} else {
await this.insertRoom(data, startedByUserId);
}
if (data.archived && data._id) {
await this.archiveRoomById(data._id);
}
}
async findExistingRoom(data: IImportChannel): Promise<IRoom | null> {
if (data._id && data._id.toUpperCase() === 'GENERAL') {
const room = await Rooms.findOneById('GENERAL', {});
// Prevent the importer from trying to create a new general
if (!room) {
throw new Error('importer-channel-general-not-found');
}
return room;
}
if (data.t === 'd') {
const users = await this._cache.convertImportedIdsToUsernames(data.users);
if (users.length !== data.users.length) {
throw new Error('importer-channel-missing-users');
}
return Rooms.findDirectRoomContainingAllUsernames(users, {});
}
if (!data.name) {
return null;
}
// Imported room names always allow special chars
const roomName = limax(data.name.trim(), { maintainCase: true });
return Rooms.findOneByNonValidatedName(roomName, {});
}
async updateRoom(room: IRoom, roomData: IImportChannel, startedByUserId: string): Promise<void> {
roomData._id = room._id;
if ((roomData._id as string).toUpperCase() === 'GENERAL' && roomData.name !== room.name) {
await saveRoomSettings(startedByUserId, 'GENERAL', 'roomName', roomData.name);
}
await this.updateRoomId(room._id, roomData);
}
async insertRoom(roomData: IImportChannel, startedByUserId: string): Promise<void> {
// Find the rocketchatId of the user who created this channel
const creatorId = await this.getRoomCreatorId(roomData, startedByUserId);
const members = await this._cache.convertImportedIdsToUsernames(roomData.users, roomData.t !== 'd' ? creatorId : undefined);
if (roomData.t === 'd') {
if (members.length < roomData.users.length) {
this._logger.warn({
msg: 'One or more imported users not found',
users: roomData.users,
});
throw new Error('importer-channel-missing-users');
}
}
// Create the channel
try {
let roomInfo;
if (roomData.t === 'd') {
roomInfo = await createDirectMessage(members, startedByUserId, true);
} else {
if (!roomData.name) {
return;
}
if (roomData.t === 'p') {
const user = await Users.findOneById(creatorId);
if (!user) {
throw new Error('importer-channel-invalid-creator');
}
roomInfo = await createPrivateGroupMethod(user, roomData.name, members, false, {}, {});
} else {
roomInfo = await createChannelMethod(creatorId, roomData.name, members, false, {}, {});
}
}
roomData._id = roomInfo.rid;
} catch (e) {
this._logger.warn({ msg: 'Failed to create new room', name: roomData.name, members, err: e });
throw e;
}
await this.updateRoomId(roomData._id as 'string', roomData);
}
async archiveRoomById(rid: string) {
const responses = await Promise.all([Rooms.archiveById(rid), Subscriptions.archiveByRoomId(rid)]);
if (responses[1]?.modifiedCount) {
void notifyOnSubscriptionChangedByRoomId(rid);
}
}
async updateRoomId(_id: string, roomData: IImportChannel): Promise<void> {
const set = {
ts: roomData.ts,
topic: roomData.topic,
description: roomData.description,
};
const roomUpdate: { $set?: Record<string, any>; $addToSet?: Record<string, any> } = {};
if (Object.keys(set).length > 0) {
roomUpdate.$set = removeEmpty(set);
}
if (roomData.importIds.length) {
roomUpdate.$addToSet = {
importIds: {
$each: roomData.importIds,
},
};
}
if (roomUpdate.$set || roomUpdate.$addToSet) {
await Rooms.updateOne({ _id: roomData._id }, roomUpdate);
}
}
async getRoomCreatorId(roomData: IImportChannel, startedByUserId: string): Promise<string> {
if (roomData.u) {
const creatorId = await this._cache.findImportedUserId(roomData.u._id);
if (creatorId) {
return creatorId;
}
if (roomData.t !== 'd') {
return startedByUserId;
}
throw new Error('importer-channel-invalid-creator');
}
if (roomData.t === 'd') {
for await (const member of roomData.users) {
const userId = await this._cache.findImportedUserId(member);
if (userId) {
return userId;
}
}
}
throw new Error('importer-channel-invalid-creator');
}
protected override getDataType(): 'channel' {
return 'channel';
}
}

View File

@@ -0,0 +1,430 @@
import type { IImportUser, IImportUserRecord, IUser, IUserEmail } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { SHA256 } from '@rocket.chat/sha256';
import { hash as bcryptHash } from 'bcrypt';
import { Accounts } from 'meteor/accounts-base';
import { RecordConverter, type RecordConverterOptions } from './RecordConverter';
import { generateTempPassword } from './generateTempPassword';
import { callbacks as systemCallbacks } from '../../../callbacks';
import { notifyOnUserChange } from '../../../notifyListener';
import { addUserToDefaultChannels } from '../../../rooms/addUserToDefaultChannels';
import { generateUsernameSuggestion } from '../../../users/getUsernameSuggestion';
import { saveUserIdentity } from '../../../users/saveUserIdentity';
import { setUserActiveStatus } from '../../../users/setUserActiveStatus';
import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks';
export type UserConverterOptions = {
flagEmailsAsVerified?: boolean;
skipExistingUsers?: boolean;
skipNewUsers?: boolean;
skipUserCallbacks?: boolean;
skipDefaultChannels?: boolean;
quickUserInsertion?: boolean;
enableEmail2fa?: boolean;
syncVoipExtension?: boolean;
};
export class UserConverter extends RecordConverter<IImportUserRecord, UserConverterOptions & RecordConverterOptions> {
private insertedIds = new Set<IUser['_id']>();
private updatedIds = new Set<IUser['_id']>();
protected override async convertRecord(record: IImportUserRecord): Promise<boolean | undefined> {
const { data, _id } = record;
data.importIds = data.importIds.filter((item) => item);
if (!data.emails.length && !data.username) {
throw new Error('importer-user-missing-email-and-username');
}
const existingUser = await this.findExistingUser(data);
if (existingUser && this._options.skipExistingUsers) {
await this.skipRecord(_id);
return;
}
if (!existingUser && this._options.skipNewUsers) {
await this.skipRecord(_id);
return;
}
await this.insertOrUpdateUser(existingUser, data);
return !existingUser;
}
override async convertData(userCallbacks: IConversionCallbacks = {}): Promise<void> {
this.insertedIds.clear();
this.updatedIds.clear();
if (this._options.quickUserInsertion) {
await this.batchConversion(userCallbacks);
} else {
await super.convertData(userCallbacks);
}
await systemCallbacks.run('afterUserImport', {
inserted: [...this.insertedIds],
updated: [...this.updatedIds],
skipped: this.skippedCount,
failed: this.failedCount,
});
}
public async batchConversion({ afterBatchFn, ...callbacks }: IConversionCallbacks = {}): Promise<void> {
const batchToInsert = new Set<IImportUser>();
await this.iterateRecords({
...callbacks,
processRecord: async (record: IImportUserRecord) => {
const { data } = record;
data.importIds = data.importIds.filter((item) => item);
if (!data.emails.length && !data.username) {
throw new Error('importer-user-missing-email-and-username');
}
batchToInsert.add(data);
if (batchToInsert.size >= 50) {
const usersToInsert = await this.buildUserBatch([...batchToInsert]);
batchToInsert.clear();
const newIds = await this.insertUserBatch(usersToInsert, { afterBatchFn });
newIds.forEach((id) => this.insertedIds.add(id));
}
return undefined;
},
});
if (batchToInsert.size > 0) {
const usersToInsert = await this.buildUserBatch([...batchToInsert]);
const newIds = await this.insertUserBatch(usersToInsert, { afterBatchFn });
newIds.forEach((id) => this.insertedIds.add(id));
}
}
private async insertUserBatch(users: IUser[], { afterBatchFn }: IConversionCallbacks): Promise<string[]> {
let newIds: string[] | null = null;
try {
newIds = Object.values((await Users.insertMany(users, { ordered: false })).insertedIds);
if (afterBatchFn) {
await afterBatchFn(newIds.length, 0);
}
} catch (e: any) {
newIds = (e.result?.result?.insertedIds || []) as string[];
const errorCount = users.length - (e.result?.result?.nInserted || 0);
if (afterBatchFn) {
await afterBatchFn(Math.min(newIds.length, users.length - errorCount), errorCount);
}
}
return newIds;
}
async findExistingUser(data: IImportUser): Promise<IUser | null | undefined> {
if (data.emails.length) {
const emailUser = await Users.findOneByEmailAddress(data.emails[0], {});
if (emailUser) {
return emailUser;
}
}
// If we couldn't find one by their email address, try to find an existing user by their username
if (data.username) {
return Users.findOneByUsernameIgnoringCase<IUser>(data.username, {});
}
}
addUserImportId(updateData: Record<string, any>, userData: IImportUser): void {
if (userData.importIds?.length) {
updateData.$addToSet = {
importIds: {
$each: userData.importIds,
},
};
}
}
addUserEmails(updateData: Record<string, any>, userData: IImportUser, existingEmails: Array<IUserEmail>): void {
if (!userData.emails?.length) {
return;
}
const verifyEmails = Boolean(this._options.flagEmailsAsVerified);
const newEmailList: Array<IUserEmail> = [];
for (const email of userData.emails) {
const verified = verifyEmails || existingEmails.find((ee) => ee.address === email)?.verified || false;
newEmailList.push({
address: email,
verified,
});
}
updateData.$set.emails = newEmailList;
}
addUserServices(updateData: Record<string, any>, userData: IImportUser): void {
if (!userData.services) {
return;
}
for (const serviceKey in userData.services) {
if (!userData.services[serviceKey]) {
continue;
}
const service = userData.services[serviceKey];
for (const key in service) {
if (!service[key]) {
continue;
}
updateData.$set[`services.${serviceKey}.${key}`] = service[key];
}
}
}
addCustomFields(updateData: Record<string, any>, userData: IImportUser): void {
if (!userData.customFields) {
return;
}
const subset = (source: Record<string, any>, currentPath: string): void => {
for (const key in source) {
if (!source.hasOwnProperty(key) || source[key] === undefined) {
continue;
}
const keyPath = `${currentPath}.${key}`;
if (typeof source[key] === 'object' && !Array.isArray(source[key])) {
subset(source[key], keyPath);
continue;
}
updateData.$set = {
...updateData.$set,
...{ [keyPath]: source[key] },
};
}
};
subset(userData.customFields, 'customFields');
}
async insertOrUpdateUser(existingUser: IUser | null | undefined, data: IImportUser): Promise<void> {
if (!data.username && !existingUser?.username) {
const emails = data.emails.filter(Boolean).map((email) => ({ address: email }));
data.username = await generateUsernameSuggestion({
name: data.name,
emails,
});
}
if (existingUser) {
await this.updateUser(existingUser, data);
this.updatedIds.add(existingUser._id);
} else {
if (!data.name && data.username) {
data.name = this.guessNameFromUsername(data.username);
}
const userId = await this.insertUser(data);
data._id = userId;
this.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);
}
}
}
async updateUser(existingUser: IUser, userData: IImportUser): Promise<void> {
const { _id } = existingUser;
if (!_id) {
return;
}
if (Boolean(userData.federated) !== Boolean(existingUser.federated)) {
throw new Error("Local and Federated users can't be converted to each other.");
}
userData._id = _id;
if (!userData.roles && !existingUser.roles) {
userData.roles = ['user'];
}
if (!userData.type && !existingUser.type) {
userData.type = 'user';
}
const updateData: Record<string, any> = Object.assign(Object.create(null), {
$set: Object.assign(Object.create(null), {
...(userData.roles && { roles: userData.roles }),
...(userData.type && { type: userData.type }),
...(userData.statusText && { statusText: userData.statusText }),
...(userData.bio && { bio: userData.bio }),
...(userData.services?.ldap && { ldap: true }),
...(userData.avatarUrl && { _pendingAvatarUrl: userData.avatarUrl }),
...(this._options.syncVoipExtension && userData.voipExtension && { freeSwitchExtension: userData.voipExtension }),
}),
...(this._options.syncVoipExtension &&
!userData.voipExtension && {
$unset: {
freeSwitchExtension: 1,
},
}),
});
this.addCustomFields(updateData, userData);
this.addUserServices(updateData, userData);
this.addUserImportId(updateData, userData);
if (!userData.federated) {
this.addUserEmails(updateData, userData, existingUser.emails || []);
}
if (Object.keys(updateData.$set).length === 0) {
delete updateData.$set;
}
if (Object.keys(updateData).length > 0) {
await Users.updateOne({ _id }, updateData);
}
if (userData.utcOffset) {
await Users.setUtcOffset(_id, userData.utcOffset);
}
const localUsername = userData.federated ? undefined : userData.username;
if (userData.name || localUsername) {
await saveUserIdentity({ _id, name: userData.name, username: localUsername } as Parameters<typeof saveUserIdentity>[0]);
}
if (userData.importIds.length) {
this._cache.addUser(userData.importIds[0], existingUser._id, existingUser.username || userData.username);
}
// Deleted users are 'inactive' users in Rocket.Chat
if (userData.deleted && existingUser?.active) {
await setUserActiveStatus(_id, false, true);
} else if (userData.deleted === false && existingUser?.active === false) {
await setUserActiveStatus(_id, true);
}
void notifyOnUserChange({ clientAction: 'updated', id: _id, diff: updateData.$set });
}
async hashPassword(password: string): Promise<string> {
return bcryptHash(SHA256(password), Accounts._bcryptRounds());
}
generateTempPassword(userData: IImportUser): string {
return generateTempPassword(userData);
}
async buildNewUserObject(userData: IImportUser): Promise<Partial<IUser>> {
return {
type: userData.type || 'user',
...(userData.username && { username: userData.username }),
...(userData.emails.length && {
emails: userData.emails.map((email) => ({ address: email, verified: !!this._options.flagEmailsAsVerified })),
}),
...(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 }),
...(this._options.syncVoipExtension && userData.voipExtension && { freeSwitchExtension: userData.voipExtension }),
...(userData.federated !== undefined && { federated: userData.federated }),
};
}
private async buildUserBatch(usersData: IImportUser[]): Promise<IUser[]> {
return Promise.all(
usersData.map(async (userData) => {
const user = await this.buildNewUserObject(userData);
return {
createdAt: new Date(),
_id: Random.id(),
status: 'offline',
...user,
roles: userData.roles?.length ? userData.roles : ['user'],
active: !userData.deleted,
services: {
...user.services,
...(this._options.enableEmail2fa
? {
email2fa: {
enabled: true,
changedAt: new Date(),
},
}
: {}),
},
} as IUser;
}),
);
}
async insertUser(userData: IImportUser): Promise<IUser['_id']> {
const user = await this.buildNewUserObject(userData);
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,
},
{
...user,
...(userData.roles?.length ? { globalRoles: userData.roles } : {}),
},
);
}
protected guessNameFromUsername(username: string): string {
return username
.replace(/\W/g, ' ')
.replace(/\s(.)/g, (u) => u.toUpperCase())
.replace(/^(.)/, (u) => u.toLowerCase())
.replace(/^\w/, (u) => u.toUpperCase());
}
protected override getDataType(): 'user' {
return 'user';
}
}

View File

@@ -0,0 +1,5 @@
import type { IImportUser } from '@rocket.chat/core-typings';
export function generateTempPassword(userData: IImportUser): string {
return `${Date.now()}${userData.name || ''}${userData.emails.length ? userData.emails[0].toUpperCase() : ''}`;
}