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,40 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { addOAuthService } from '../../lib/oauth/addOAuthService';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addOAuthService(name: string): void;
}
}
export const addOAuthServiceMethod = async (userId: string, name: string): Promise<void> => {
if ((await hasPermissionAsync(userId, 'add-oauth-service')) !== true) {
throw new Meteor.Error('error-action-not-allowed', 'Adding OAuth Services is not allowed', {
method: 'addOAuthService',
action: 'Adding_OAuth_Services',
});
}
await addOAuthService(name);
};
Meteor.methods<ServerMethods>({
async addOAuthService(name) {
methodDeprecationLogger.method('addOAuthService', '9.0.0', '/v1/settings.addCustomOAuth');
check(name, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthService' });
}
return addOAuthServiceMethod(userId, name);
},
});

View File

@@ -0,0 +1,23 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { addPermissionToRoleMethod } from '../../lib/authorization/permissionRole';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'authorization:addPermissionToRole'(permissionId: string, role: string): void;
}
}
Meteor.methods<ServerMethods>({
async 'authorization:addPermissionToRole'(permissionId, role) {
methodDeprecationLogger.method('authorization:addPermissionToRole', '9.0.0', '/v1/permissions.addRole');
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'authorization:addPermissionToRole' });
}
await addPermissionToRoleMethod(uid, permissionId, role);
},
});

View File

@@ -0,0 +1,17 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { addSamlService } from '../../lib/saml/lib/settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addSamlService(name: string): void;
}
}
Meteor.methods<ServerMethods>({
addSamlService(name) {
addSamlService(name);
},
});

View File

@@ -0,0 +1,73 @@
import { api } from '@rocket.chat/core-services';
import type { IRole, IUser } from '@rocket.chat/core-typings';
import { Roles, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { addUserRolesAsync } from '../../lib/roles/addUserRoles';
import { settings } from '../../settings';
export const addUserToRole = async (userId: string, roleId: string, username: IUser['username'], scope?: string): Promise<boolean> => {
if (!(await hasPermissionAsync(userId, 'access-permissions'))) {
throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', {
method: 'authorization:addUserToRole',
action: 'Accessing_permissions',
});
}
if (!roleId || typeof roleId.valueOf() !== 'string' || !username || typeof username.valueOf() !== 'string') {
throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', {
method: 'authorization:addUserToRole',
});
}
const role = await Roles.findOneById<Pick<IRole, '_id'>>(roleId, { projection: { _id: 1 } });
if (!role) {
throw new Meteor.Error('error-invalid-role', 'Invalid Role', {
method: 'authorization:addUserToRole',
});
}
if (role._id === 'admin' && !(await hasPermissionAsync(userId, 'assign-admin-role'))) {
throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', {
method: 'authorization:addUserToRole',
action: 'Assign_admin',
});
}
const user = await Users.findOneByUsernameIgnoringCase(username, {
projection: {
_id: 1,
},
});
if (!user?._id) {
throw new Meteor.Error('error-user-not-found', 'User not found', {
method: 'authorization:addUserToRole',
});
}
// verify if user can be added to given scope
if (scope && !(await Roles.canAddUserToRole(user._id, role._id, scope))) {
throw new Meteor.Error('error-invalid-user', 'User is not part of given room', {
method: 'authorization:addUserToRole',
});
}
const add = await addUserRolesAsync(user._id, [role._id], scope);
if (settings.get('UI_DisplayRoles')) {
void api.broadcast('user.roleUpdate', {
type: 'added',
_id: role._id,
u: {
_id: user._id,
username,
},
scope,
});
}
return add;
};

View File

@@ -0,0 +1,28 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { runAfterVerifyEmail } from '../../lib/users/runAfterVerifyEmail';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
afterVerifyEmail(): void;
}
}
Meteor.methods<ServerMethods>({
async afterVerifyEmail() {
methodDeprecationLogger.method('afterVerifyEmail', '9.0.0', '/v1/users.verifyEmail');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'afterVerifyEmail',
});
}
await runAfterVerifyEmail(userId);
},
});

View File

@@ -0,0 +1,19 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { codesRemainingTotp } from '../../lib/2fa/functions/totp';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'2fa:checkCodesRemaining': () => { remaining: number };
}
}
Meteor.methods<ServerMethods>({
async '2fa:checkCodesRemaining'() {
methodDeprecationLogger.method('2fa:checkCodesRemaining', '9.0.0', '/v1/users.totpCodesRemaining');
return codesRemainingTotp(Meteor.userId());
},
});

View File

@@ -0,0 +1,13 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../settings';
Meteor.methods<ServerMethods>({
checkRegistrationSecretURL(hash) {
check(hash, String);
return hash === settings.get('Accounts_RegistrationForm_SecretURL');
},
});

View File

@@ -0,0 +1,32 @@
import { Authorization, MeteorError, User } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { Accounts } from 'meteor/accounts-base';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
createToken(userId: string): { userId: string; authToken: string };
}
}
const { CREATE_TOKENS_FOR_USERS_SECRET } = process.env;
export async function generateAccessToken(userId: string, secret: string, caller: IUser) {
if (secret !== CREATE_TOKENS_FOR_USERS_SECRET) {
throw new MeteorError('error-not-authorized', 'Not authorized');
}
if (caller._id !== userId && !(await Authorization.hasPermission(caller, 'user-generate-access-token'))) {
throw new MeteorError('error-not-authorized', 'Not authorized');
}
const token = Accounts._generateStampedLoginToken();
await Accounts._insertLoginToken(userId, token);
await User.ensureLoginTokensLimit(userId);
return {
userId,
authToken: token.token,
};
}

View File

@@ -0,0 +1,81 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { Meteor } from 'meteor/meteor';
import { CROWD } from '../../lib/auth-providers/crowd/crowd';
import { logger } from '../../lib/auth-providers/crowd/logger';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
crowd_test_connection(): { message: TranslationKey; params: string[] };
crowd_sync_users(): { message: string; params: string[] };
}
}
Meteor.methods<ServerMethods>({
async crowd_test_connection() {
const user = await Meteor.userAsync();
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'crowd_test_connection',
});
}
if (!(await hasPermissionAsync(user, 'test-admin-options'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'crowd_test_connection',
});
}
if (settings.get('CROWD_Enable') !== true) {
throw new Meteor.Error('crowd_disabled');
}
try {
const crowd = new CROWD();
await crowd.checkConnection();
return {
message: 'Crowd_Connection_successful' as const,
params: [],
};
} catch (err) {
logger.error({
msg: 'Invalid crowd connection details, check the url and application username/password and make sure this server is allowed to speak to crowd',
err,
});
throw new Meteor.Error('Invalid connection details', '', { method: 'crowd_test_connection' });
}
},
async crowd_sync_users() {
const user = await Meteor.userAsync();
if (settings.get('CROWD_Enable') !== true) {
throw new Meteor.Error('crowd_disabled');
}
if (!user || !(await hasPermissionAsync(user, 'sync-auth-services-users'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'crowd_sync_users',
});
}
try {
const crowd = new CROWD();
const startTime = Date.now();
await crowd.sync();
const stopTime = Date.now();
const actual = Math.ceil((stopTime - startTime) / 1000);
return {
message: `User data synced in ${actual} seconds`,
params: [],
};
} catch (err) {
logger.error({ msg: 'Error syncing user data. ', err });
throw new Meteor.Error('Error syncing user data', '', { method: 'crowd_sync_users' });
}
},
});

View File

@@ -0,0 +1,43 @@
import type { IOAuthApps } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { OAuthAccessTokens, OAuthApps, OAuthAuthCodes } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
deleteOAuthApp(applicationId: IOAuthApps['_id']): boolean;
}
}
export const deleteOAuthApp = async (userId: string, applicationId: IOAuthApps['_id']): Promise<boolean> => {
if (!(await hasPermissionAsync(userId, 'manage-oauth-apps'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'deleteOAuthApp' });
}
const application = await OAuthApps.findOneAndDeleteById(applicationId, { projection: { clientId: 1 } });
if (!application) {
throw new Meteor.Error('error-application-not-found', 'Application not found', {
method: 'deleteOAuthApp',
});
}
await OAuthAccessTokens.deleteMany({ clientId: application.clientId });
await OAuthAuthCodes.deleteMany({ clientId: application.clientId });
return true;
};
Meteor.methods<ServerMethods>({
async deleteOAuthApp(applicationId) {
methodDeprecationLogger.method('deleteOAuthApp', '9.0.0', '/v1/oauth-apps.delete');
if (!this.userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'deleteOAuthApp' });
}
return deleteOAuthApp(this.userId, applicationId);
},
});

View File

@@ -0,0 +1,19 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { disableTotp } from '../../lib/2fa/functions/totp';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'2fa:disable': (code: string) => Promise<boolean>;
}
}
Meteor.methods<ServerMethods>({
async '2fa:disable'(code) {
methodDeprecationLogger.method('2fa:disable', '9.0.0', '/v1/users.disableTotp');
return disableTotp(Meteor.userId(), code);
},
});

View File

@@ -0,0 +1,19 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { enableTotp } from '../../lib/2fa/functions/totp';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'2fa:enable': () => Promise<{ secret: string; url: string }>;
}
}
Meteor.methods<ServerMethods>({
async '2fa:enable'() {
methodDeprecationLogger.method('2fa:enable', '9.0.0', '/v1/users.enableTotp');
return enableTotp(Meteor.userId());
},
});

View File

@@ -0,0 +1,29 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { afterLogoutCleanUpCallback } from '../../lib/callbacks/afterLogoutCleanUpCallback';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
logoutCleanUp(user: IUser): Promise<void>;
}
}
Meteor.methods<ServerMethods>({
async logoutCleanUp(user) {
methodDeprecationLogger.method('logoutCleanUp', '9.0.0', '/v1/users.logout');
check(user, Object);
setImmediate(() => {
void afterLogoutCleanUpCallback.run(user);
});
// App IPostUserLogout event hook
await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedOut, user);
},
});

View File

@@ -0,0 +1,36 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { refreshLoginServices } from '../../lib/refreshLoginServices';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
refreshOAuthService(): Promise<void>;
}
}
Meteor.methods<ServerMethods>({
async refreshOAuthService() {
methodDeprecationLogger.method('refreshOAuthService', '9.0.0', '/v1/settings.refreshOAuthServices');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'refreshOAuthService',
});
}
if ((await hasPermissionAsync(userId, 'add-oauth-service')) !== true) {
throw new Meteor.Error('error-action-not-allowed', 'Refresh OAuth Services is not allowed', {
method: 'refreshOAuthService',
action: 'Refreshing_OAuth_Services',
});
}
await refreshLoginServices();
},
});

View File

@@ -0,0 +1,19 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { regenerateTotpCodes } from '../../lib/2fa/functions/totp';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'2fa:regenerateCodes': (userToken: string) => { codes: string[] } | undefined;
}
}
Meteor.methods<ServerMethods>({
async '2fa:regenerateCodes'(userToken) {
methodDeprecationLogger.method('2fa:regenerateCodes', '9.0.0', '/v1/users.regenerateTotpCodes');
return regenerateTotpCodes(Meteor.userId(), userToken);
},
});

View File

@@ -0,0 +1,86 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Settings } from '@rocket.chat/models';
import { capitalize } from '@rocket.chat/tools';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { notifyOnSettingChangedById } from '../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
removeOAuthService(name: string): Promise<void>;
}
}
export const removeCustomOAuthSettings = async (name: string): Promise<void> => {
const normalized = capitalize(name.toLowerCase().replace(/[^a-z0-9_]/g, ''));
if (!normalized) {
throw new Meteor.Error('error-invalid-name', 'Invalid OAuth service name', { method: 'removeOAuthService' });
}
const settingsIds = [
`Accounts_OAuth_Custom-${normalized}`,
`Accounts_OAuth_Custom-${normalized}-url`,
`Accounts_OAuth_Custom-${normalized}-token_path`,
`Accounts_OAuth_Custom-${normalized}-identity_path`,
`Accounts_OAuth_Custom-${normalized}-authorize_path`,
`Accounts_OAuth_Custom-${normalized}-scope`,
`Accounts_OAuth_Custom-${normalized}-access_token_param`,
`Accounts_OAuth_Custom-${normalized}-token_sent_via`,
`Accounts_OAuth_Custom-${normalized}-identity_token_sent_via`,
`Accounts_OAuth_Custom-${normalized}-id`,
`Accounts_OAuth_Custom-${normalized}-secret`,
`Accounts_OAuth_Custom-${normalized}-button_label_text`,
`Accounts_OAuth_Custom-${normalized}-button_label_color`,
`Accounts_OAuth_Custom-${normalized}-button_color`,
`Accounts_OAuth_Custom-${normalized}-login_style`,
`Accounts_OAuth_Custom-${normalized}-key_field`,
`Accounts_OAuth_Custom-${normalized}-username_field`,
`Accounts_OAuth_Custom-${normalized}-email_field`,
`Accounts_OAuth_Custom-${normalized}-name_field`,
`Accounts_OAuth_Custom-${normalized}-avatar_field`,
`Accounts_OAuth_Custom-${normalized}-roles_claim`,
`Accounts_OAuth_Custom-${normalized}-merge_roles`,
`Accounts_OAuth_Custom-${normalized}-roles_to_sync`,
`Accounts_OAuth_Custom-${normalized}-merge_users`,
`Accounts_OAuth_Custom-${normalized}-show_button`,
`Accounts_OAuth_Custom-${normalized}-groups_claim`,
`Accounts_OAuth_Custom-${normalized}-channels_admin`,
`Accounts_OAuth_Custom-${normalized}-map_channels`,
`Accounts_OAuth_Custom-${normalized}-groups_channel_map`,
`Accounts_OAuth_Custom-${normalized}-merge_users_distinct_services`,
];
const promises = settingsIds.map((id) => Settings.removeById(id));
(await Promise.all(promises)).forEach((value, index) => {
if (value?.deletedCount) {
void notifyOnSettingChangedById(settingsIds[index], 'removed');
}
});
};
Meteor.methods<ServerMethods>({
async removeOAuthService(name) {
methodDeprecationLogger.method('removeOAuthService', '9.0.0', '/v1/settings.removeCustomOAuth');
check(name, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'removeOAuthService',
});
}
if ((await hasPermissionAsync(userId, 'add-oauth-service')) !== true) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'removeOAuthService' });
}
await removeCustomOAuthSettings(name);
},
});

View File

@@ -0,0 +1,23 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { removeRoleFromPermissionMethod } from '../../lib/authorization/permissionRole';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'authorization:removeRoleFromPermission'(permissionId: string, role: string): void;
}
}
Meteor.methods<ServerMethods>({
async 'authorization:removeRoleFromPermission'(permissionId, role) {
methodDeprecationLogger.method('authorization:removeRoleFromPermission', '9.0.0', '/v1/permissions.removeRole');
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'authorization:removeRoleFromPermission' });
}
await removeRoleFromPermissionMethod(uid, permissionId, role);
},
});

View File

@@ -0,0 +1,77 @@
import { api } from '@rocket.chat/core-services';
import type { IRole, IUser } from '@rocket.chat/core-typings';
import { Roles, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { removeUserFromRolesAsync } from '../../lib/roles/removeUserFromRoles';
import { settings } from '../../settings';
export const removeUserFromRole = async (userId: string, roleId: string, username: IUser['username'], scope?: string): Promise<boolean> => {
if (!(await hasPermissionAsync(userId, 'access-permissions'))) {
throw new Meteor.Error('error-action-not-allowed', 'Access permissions is not allowed', {
method: 'authorization:removeUserFromRole',
action: 'Accessing_permissions',
});
}
if (!roleId || typeof roleId.valueOf() !== 'string' || !username || typeof username.valueOf() !== 'string') {
throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', {
method: 'authorization:removeUserFromRole',
});
}
const role = await Roles.findOneById<Pick<IRole, '_id'>>(roleId, { projection: { _id: 1 } });
if (!role) {
throw new Meteor.Error('error-invalid-role', 'Invalid Role', {
method: 'authorization:removeUserFromRole',
});
}
const user = await Users.findOneByUsernameIgnoringCase(username, {
projection: {
_id: 1,
roles: 1,
},
});
if (!user?._id) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'authorization:removeUserFromRole',
});
}
// prevent removing last user from admin role
if (role._id === 'admin') {
const adminCount = await Users.countDocuments({
roles: {
$in: ['admin'],
},
});
const userIsAdmin = user.roles?.indexOf('admin') > -1;
if (adminCount === 1 && userIsAdmin) {
throw new Meteor.Error('error-action-not-allowed', 'Leaving the app without admins is not allowed', {
method: 'removeUserFromRole',
action: 'Remove_last_admin',
});
}
}
const remove = await removeUserFromRolesAsync(user._id, [role._id], scope);
const event = {
type: 'removed',
_id: role._id,
u: {
_id: user._id,
username,
},
scope,
} as const;
if (settings.get('UI_DisplayRoles')) {
void api.broadcast('user.roleUpdate', event);
}
void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: userId });
return remove;
};

View File

@@ -0,0 +1,78 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import type { IServiceProviderOptions } from '../../lib/saml/definition/IServiceProviderOptions';
import { SAMLServiceProvider } from '../../lib/saml/lib/ServiceProvider';
import { SAMLUtils } from '../../lib/saml/lib/Utils';
/**
* Fetch SAML provider configs for given 'provider'.
*/
function getSamlServiceProviderOptions(provider: string): IServiceProviderOptions {
if (!provider) {
throw new Meteor.Error('no-saml-provider', 'SAML internal error', {
method: 'getSamlServiceProviderOptions',
});
}
const providers = SAMLUtils.serviceProviders;
const samlProvider = function (element: IServiceProviderOptions): boolean {
return element.provider === provider;
};
return providers.filter(samlProvider)[0];
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
samlLogout(provider: string): string | undefined;
}
}
Meteor.methods<ServerMethods>({
async samlLogout(provider: string) {
const userId = Meteor.userId();
// Make sure the user is logged in before we initiate SAML Logout
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'samlLogout' });
}
const providerConfig = getSamlServiceProviderOptions(provider);
SAMLUtils.log({ msg: 'Logout request', providerConfig });
// This query should respect upcoming array of SAML logins
const user = await Users.getSAMLByIdAndSAMLProvider(userId, provider);
if (!user?.services?.saml) {
return;
}
const { nameID, idpSession } = user.services.saml;
SAMLUtils.log({ msg: `NameID for user ${Meteor.userId()} found`, nameID });
const _saml = new SAMLServiceProvider(providerConfig);
if (!nameID || !idpSession) {
SAMLUtils.log({ msg: 'No NameID or idpSession found for user', userId });
return;
}
const request = _saml.generateLogoutRequest({
nameID: nameID || idpSession,
sessionIndex: idpSession,
});
SAMLUtils.log({ request, msg: '----Logout Request---' });
// request.request: actual XML SAML Request
// request.id: comminucation id which will be mentioned in the ResponseTo field of SAMLResponse
await Users.setSamlInResponseTo(userId, request.id);
const result = await _saml.requestToUrl(request.request, 'logout');
SAMLUtils.log({ msg: 'SAML Logout Request URL generated', result });
return result;
},
});

View File

@@ -0,0 +1,31 @@
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
export const sendConfirmationEmail = async (to: string): Promise<boolean> => {
check(to, String);
const email = to.trim();
const user = await Users.findOneByEmailAddress(email, { projection: { emails: 1 } });
if (!user) {
return false;
}
// Do not re-send a verification mail to an address that is already verified.
const target = user.emails?.find((e) => e.address.toLowerCase() === email.toLowerCase() && !e.verified);
if (!target) {
return false;
}
try {
Accounts.sendVerificationEmail(user._id, target.address);
return true;
} catch (error: any) {
throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, {
method: 'registerUser',
message: error.message,
});
}
};

View File

@@ -0,0 +1,59 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
import { Meteor } from 'meteor/meteor';
import { SystemLogger } from '../../lib/logger/system';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
sendForgotPasswordEmail(to: string): boolean | undefined;
}
}
export const sendForgotPasswordEmail = async (to: string): Promise<boolean | undefined> => {
const email = to.trim().toLowerCase();
const user = await Users.findOneByEmailAddress(email, { projection: { _id: 1, services: 1 } });
if (!user) {
return true;
}
if (user.services && !user.services.password) {
if (!settings.get('Accounts_AllowPasswordChangeForOAuthUsers')) {
return false;
}
}
try {
Accounts.sendResetPasswordEmail(user._id, email);
return true;
} catch (err) {
SystemLogger.error({ err });
}
};
Meteor.methods<ServerMethods>({
async sendForgotPasswordEmail(to) {
check(to, String);
return sendForgotPasswordEmail(to);
},
});
DDPRateLimiter.addRule(
{
type: 'method',
name: 'sendForgotPasswordEmail',
clientAddress() {
return true;
},
},
10,
60000,
);

View File

@@ -0,0 +1,82 @@
import type { IOAuthApps } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { OAuthApps, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { parseUriList } from '../../lib/auth/oauth2-server/parseUriList';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
updateOAuthApp(
applicationId: IOAuthApps['_id'],
application: Pick<IOAuthApps, 'name' | 'redirectUri' | 'active'>,
): Promise<IOAuthApps | null>;
}
}
export const updateOAuthApp = async (
userId: string,
applicationId: IOAuthApps['_id'],
application: Pick<IOAuthApps, 'name' | 'redirectUri' | 'active'>,
): Promise<IOAuthApps | null> => {
if (!(await hasPermissionAsync(userId, 'manage-oauth-apps'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'updateOAuthApp' });
}
if (!application.name || typeof application.name.valueOf() !== 'string' || application.name.trim() === '') {
throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'updateOAuthApp' });
}
if (!application.redirectUri || typeof application.redirectUri.valueOf() !== 'string' || application.redirectUri.trim() === '') {
throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', {
method: 'updateOAuthApp',
});
}
if (typeof application.active !== 'boolean') {
throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', {
method: 'updateOAuthApp',
});
}
const redirectUri = parseUriList(application.redirectUri);
if (redirectUri.length === 0) {
throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', {
method: 'updateOAuthApp',
});
}
const updatedApplication = await OAuthApps.updateById(applicationId, {
name: application.name,
active: application.active,
redirectUri,
_updatedBy: await Users.findOneById(userId, {
projection: {
username: 1,
},
}),
});
if (updatedApplication == null) {
throw new Meteor.Error('error-application-not-found', 'Application not found', {
method: 'updateOAuthApp',
});
}
return updatedApplication;
};
Meteor.methods<ServerMethods>({
async updateOAuthApp(applicationId, application) {
methodDeprecationLogger.method('updateOAuthApp', '9.0.0', '/v1/oauth-apps.update');
if (!this.userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOAuthApp' });
}
return updateOAuthApp(this.userId, applicationId, application);
},
});

View File

@@ -0,0 +1,20 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { validateTotpTempToken } from '../../lib/2fa/functions/totp';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'2fa:validateTempToken': (userToken: string) => { codes: string[] } | undefined;
}
}
Meteor.methods<ServerMethods>({
async '2fa:validateTempToken'(userToken) {
methodDeprecationLogger.method('2fa:validateTempToken', '9.0.0', '/v1/users.validateTotp');
const { 'x-auth-token': xAuthToken } = this.connection?.httpHeaders ?? {};
return validateTotpTempToken(Meteor.userId(), userToken, xAuthToken);
},
});

View File

@@ -0,0 +1,131 @@
import fs from 'node:fs';
import type { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { Import } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';
import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { Importers } from '../../lib/import';
import { RocketChatImportFileInstance } from '../../lib/import/startup/store';
import { SystemLogger } from '../../lib/logger/system';
import { settings } from '../../settings';
async function getHttpFileStream(fileUrl: string): Promise<Readable> {
const response = await fetch(fileUrl, {
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
const body = response.body as Readable;
if (!response.ok) {
body.resume();
throw new Error(`Unexpected response status ${response.status}`);
}
return body;
}
function copyLocalFile(filePath: fs.PathLike, writeStream: fs.WriteStream): void {
const readStream = fs.createReadStream(filePath);
readStream.pipe(writeStream);
}
export const executeDownloadPublicImportFile = async (userId: IUser['_id'], fileUrl: string, importerKey: string): Promise<void> => {
const importer = Importers.get(importerKey);
const isUrl = fileUrl.startsWith('http');
if (!importer) {
throw new Meteor.Error(
'error-importer-not-defined',
`The importer (${importerKey}) has no import class defined.`,
'downloadImportFile',
);
}
// Check if it's a valid url or path before creating a new import record
if (!isUrl && !fs.existsSync(fileUrl)) {
throw new Meteor.Error('error-import-file-missing', fileUrl, 'downloadPublicImportFile');
}
const operation = await Import.newOperation(userId, importer.name, importer.key);
const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
const oldFileName = fileUrl.substring(fileUrl.lastIndexOf('/') + 1).split('?')[0];
const date = new Date();
const dateStr = `${date.getUTCFullYear()}${date.getUTCMonth()}${date.getUTCDate()}${date.getUTCHours()}${date.getUTCMinutes()}${date.getUTCSeconds()}`;
const newFileName = `${dateStr}_${userId}_${oldFileName}`;
// Store the file name on the imports collection
await instance.startFileUpload(newFileName);
await instance.updateProgress(ProgressStep.DOWNLOADING_FILE);
const writeStream = RocketChatImportFileInstance.createWriteStream(newFileName);
let errorProgressUpdate: Promise<unknown> | undefined;
const markImportAsFailed = (): Promise<unknown> => {
errorProgressUpdate ??= instance.updateProgress(ProgressStep.ERROR).catch((error) => {
SystemLogger.error({ msg: 'Failed to update import progress to ERROR', err: error });
});
return errorProgressUpdate;
};
writeStream.on('error', () => {
void markImportAsFailed();
});
let readStream: Readable | undefined;
if (isUrl) {
try {
readStream = await getHttpFileStream(fileUrl);
} catch (error) {
writeStream.destroy();
await markImportAsFailed();
throw error;
}
}
writeStream.on('finish', () => {
void instance.updateProgress(ProgressStep.FILE_LOADED);
});
if (readStream) {
void pipeline(readStream, writeStream).catch(() => markImportAsFailed());
return;
}
// If the url is actually a folder path on the current machine, skip moving it to the file store
if (fs.statSync(fileUrl).isDirectory()) {
await instance.updateRecord({ file: fileUrl });
await instance.updateProgress(ProgressStep.FILE_LOADED);
return;
}
copyLocalFile(fileUrl, writeStream);
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
downloadPublicImportFile(fileUrl: string, importerKey: string): void;
}
}
Meteor.methods<ServerMethods>({
async downloadPublicImportFile(fileUrl: string, importerKey: string) {
methodDeprecationLogger.method('downloadPublicImportFile', '9.0.0', '/v1/downloadPublicImportFile');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', 'downloadPublicImportFile');
}
if (!(await hasPermissionAsync(userId, 'run-import'))) {
throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'downloadPublicImportFile');
}
await executeDownloadPublicImportFile(userId, fileUrl, importerKey);
},
});

View File

@@ -0,0 +1,88 @@
import fs from 'node:fs';
import path from 'node:path';
import type { IImportProgress, IImporterSelection } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Imports } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { Importers } from '../../lib/import';
import { RocketChatImportFileInstance } from '../../lib/import/startup/store';
export const executeGetImportFileData = async (): Promise<IImporterSelection | { waiting: true }> => {
const operation = await Imports.findLastImport();
if (!operation) {
throw new Meteor.Error('error-operation-not-found', 'Import Operation Not Found', 'getImportFileData');
}
const { importerKey } = operation;
const importer = Importers.get(importerKey);
if (!importer) {
throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'getImportFileData');
}
const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
const waitingSteps: IImportProgress['step'][] = [
ProgressStep.DOWNLOADING_FILE,
ProgressStep.PREPARING_CHANNELS,
ProgressStep.PREPARING_MESSAGES,
ProgressStep.PREPARING_USERS,
ProgressStep.PREPARING_CONTACTS,
ProgressStep.PREPARING_STARTED,
];
if (waitingSteps.indexOf(instance.progress.step) >= 0) {
if (instance.importRecord?.valid) {
return { waiting: true };
}
throw new Meteor.Error('error-import-operation-invalid', 'Invalid Import Operation', 'getImportFileData');
}
const readySteps: IImportProgress['step'][] = [
ProgressStep.USER_SELECTION,
ProgressStep.DONE,
ProgressStep.CANCELLED,
ProgressStep.ERROR,
];
if (readySteps.indexOf(instance.progress.step) >= 0) {
return instance.buildSelection();
}
const fileName = instance.importRecord.file;
if (fileName) {
const fullFilePath = fs.existsSync(fileName) ? fileName : path.join(RocketChatImportFileInstance.absolutePath, fileName);
await instance.prepareUsingLocalFile(fullFilePath);
}
return instance.buildSelection();
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getImportFileData(): IImporterSelection | { waiting: true };
}
}
Meteor.methods<ServerMethods>({
async getImportFileData() {
methodDeprecationLogger.method('getImportFileData', '9.0.0', '/v1/getImportFileData');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', 'getImportFileData');
}
if (!(await hasPermissionAsync(userId, 'run-import'))) {
throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'getImportFileData');
}
return executeGetImportFileData();
},
});

View File

@@ -0,0 +1,48 @@
import type { IImportProgress } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Imports } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { Importers } from '../../lib/import';
export const executeGetImportProgress = async (): Promise<IImportProgress> => {
const operation = await Imports.findLastImport();
if (!operation) {
throw new Meteor.Error('error-operation-not-found', 'Import Operation Not Found', 'getImportProgress');
}
const { importerKey } = operation;
const importer = Importers.get(importerKey);
if (!importer) {
throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'getImportProgress');
}
const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
return instance.getProgress();
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getImportProgress(): IImportProgress;
}
}
Meteor.methods<ServerMethods>({
async getImportProgress() {
methodDeprecationLogger.method('getImportProgress', '9.0.0', '/v1/getImportProgress');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', 'getImportProgress');
}
if (!(await hasPermissionAsync(userId, 'run-import'))) {
throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'setupImporter');
}
return executeGetImportProgress();
},
});

View File

@@ -0,0 +1,43 @@
import type { IImport } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Imports } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
export const executeGetLatestImportOperations = async () => {
const data = Imports.find(
{},
{
sort: { _updatedAt: -1 },
limit: 20,
},
);
return data.toArray();
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getLatestImportOperations(): IImport[];
}
}
Meteor.methods<ServerMethods>({
async getLatestImportOperations() {
methodDeprecationLogger.method('getLatestImportOperations', '9.0.0', '/v1/getLatestImportOperations');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', 'getLatestImportOperations');
}
if (!(await hasPermissionAsync(userId, 'view-import-operations'))) {
throw new Meteor.Error('not_authorized', 'User not authorized', 'getLatestImportOperations');
}
return executeGetLatestImportOperations();
},
});

View File

@@ -0,0 +1,54 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Imports } from '@rocket.chat/models';
import { isStartImportParamsPOST, type StartImportParamsPOST } from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { Importers } from '../../lib/import';
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');
}
const { importerKey } = operation;
const importer = Importers.get(importerKey);
if (!importer) {
throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'startImport');
}
const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap
await instance.startImport(input, startedByUserId);
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
startImport(params: StartImportParamsPOST): void;
}
}
Meteor.methods<ServerMethods>({
async startImport({ input }: StartImportParamsPOST) {
methodDeprecationLogger.method('startImport', '9.0.0', '/v1/startImport');
if (!input || typeof input !== 'object' || !isStartImportParamsPOST({ input })) {
throw new Meteor.Error(`Invalid Selection data provided to the importer.`);
}
const userId = Meteor.userId();
// Takes name and object with users / channels selected to import
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', 'startImport');
}
if (!(await hasPermissionAsync(userId, 'run-import'))) {
throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'startImport');
}
return executeStartImport({ input }, userId);
},
});

View File

@@ -0,0 +1,81 @@
import { Import } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { Importers } from '../../lib/import';
import { RocketChatImportFileInstance } from '../../lib/import/startup/store';
import { RocketChatFile } from '../../lib/media/file';
export const executeUploadImportFile = async (
userId: IUser['_id'],
binaryContent: string,
contentType: string,
fileName: string,
importerKey: string,
): Promise<void> => {
const importer = Importers.get(importerKey);
if (!importer) {
throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'uploadImportFile');
}
const operation = await Import.newOperation(userId, importer.name, importer.key);
const 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()}`;
const newFileName = `${dateStr}_${userId}_${fileName}`;
// Store the file name and content type on the imports collection
await instance.startFileUpload(newFileName, contentType);
// Save the file on the File Store
const file = Buffer.from(binaryContent, 'base64');
const readStream = RocketChatFile.bufferToStream(file);
const writeStream = RocketChatImportFileInstance.createWriteStream(newFileName, contentType);
await new Promise<void>((resolve, reject) => {
try {
writeStream.on('end', () => {
resolve();
});
writeStream.on('error', (e: Error) => {
reject(e);
});
readStream.pipe(writeStream);
} catch (error) {
reject(error);
}
});
await instance.updateProgress(ProgressStep.FILE_LOADED);
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
uploadImportFile(binaryContent: string, contentType: string, fileName: string, importerKey: string): void;
}
}
Meteor.methods<ServerMethods>({
async uploadImportFile(binaryContent, contentType, fileName, importerKey) {
methodDeprecationLogger.method('uploadImportFile', '9.0.0', '/v1/uploadImportFile');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', 'uploadImportFile');
}
if (!(await hasPermissionAsync(userId, 'run-import'))) {
throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'uploadImportFile');
}
await executeUploadImportFile(userId, binaryContent, contentType, fileName, importerKey);
},
});

View File

@@ -0,0 +1,148 @@
import '../../imports/personal-access-tokens/server/api/methods';
import './auth/addOAuthService';
import './auth/addPermissionToRole';
import './auth/addSamlService';
import './auth/addUserToRole';
import './auth/afterVerifyEmail';
import './auth/checkCodesRemaining';
import './auth/checkRegistrationSecretURL';
import './auth/crowd';
import './auth/disable';
import './auth/enable';
import './auth/logoutCleanUp';
import './auth/refreshOAuthService';
import './auth/regenerateCodes';
import './auth/removeOAuthService';
import './auth/removeRoleFromPermission';
import './auth/samlLogout';
import './auth/sendConfirmationEmail';
import './auth/sendForgotPasswordEmail';
import './auth/validateTempToken';
import './import/downloadPublicImportFile';
import './import/getImportFileData';
import './import/getImportProgress';
import './import/getLatestImportOperations';
import './import/startImport';
import './import/uploadImportFile';
import './integrations/clearIntegrationHistory';
import './integrations/incoming/addIncomingIntegration';
import './integrations/incoming/deleteIncomingIntegration';
import './integrations/incoming/updateIncomingIntegration';
import './integrations/outgoing/addOutgoingIntegration';
import './integrations/outgoing/deleteOutgoingIntegration';
import './integrations/outgoing/replayOutgoingIntegration';
import './integrations/outgoing/updateOutgoingIntegration';
import './media/deleteCustomSound';
import './media/deleteEmojiCustom';
import './media/getS3FileUrl';
import './media/insertOrUpdateEmoji';
import './media/insertOrUpdateSound';
import './media/listCustomSounds';
import './media/uploadCustomSound';
import './media/uploadEmojiCustom';
import './messages/createDirectMessage';
import './messages/createDiscussion';
import './messages/deleteFileMessage';
import './messages/executeSlashCommandPreview';
import './messages/followMessage';
import './messages/getChannelHistory';
import './messages/getMessages';
import './messages/getSingleMessage';
import './messages/getSlashCommandPreviews';
import './messages/getUserMentionsByChannel';
import './messages/getThreadMessages';
import './messages/getThreadsList';
import './messages/loadHistory';
import './messages/loadMissedMessages';
import './messages/loadNextMessages';
import './messages/loadSurroundingMessages';
import './messages/messageSearch';
import './messages/readMessages';
import './messages/readThreads';
import './messages/sendFileMessage';
import './messages/sendMessage';
import './messages/unfollowMessage';
import './messages/updateMessage';
import './omnichannel/sendFileLivechatMessage';
import './omnichannel/sendMessageLivechat';
import './platform/OEmbedCacheCleanup';
import './platform/banner_dismiss';
import './platform/checkFederationConfiguration';
import './platform/cloud';
import './platform/fetchMyKeys';
import './platform/getProviderUiMetadata';
import './platform/getStatistics';
import './platform/getSupportedLanguages';
import './platform/getUsersOfRoomWithoutKey';
import './platform/loadLocale';
import './platform/push';
import './platform/requestDataDownload';
import './platform/requestSubscriptionKeys';
import './platform/resetOwnE2EKey';
import './platform/restartServer';
import './platform/search';
import './platform/saveSettings';
import './platform/setRoomKeyID';
import './platform/setUserPublicAndPrivateKeys';
import './platform/translateMessage';
import './platform/updateGroupKey';
import './rooms/addAllUserToRoom';
import './rooms/addRoomLeader';
import './rooms/addRoomModerator';
import './rooms/addRoomOwner';
import './rooms/addUserToRoom';
import './rooms/addUsersToRoom';
import './rooms/archiveRoom';
import './rooms/browseChannels';
import './rooms/channelsList';
import './rooms/cleanRoomHistory';
import './rooms/createChannel';
import './rooms/createPrivateGroup';
import './rooms/getRoomById';
import './rooms/getRoomIdByNameOrId';
import './rooms/getRoomJoinCode';
import './rooms/getRoomNameById';
import './rooms/getRoomRoles';
import './rooms/getTotalChannels';
import './rooms/hideRoom';
import './rooms/joinDefaultChannels';
import './rooms/joinRoom';
import './rooms/leaveRoom';
import './rooms/muteUserInRoom';
import './rooms/openRoom';
import './rooms/removeRoomLeader';
import './rooms/removeRoomModerator';
import './rooms/removeRoomOwner';
import './rooms/removeUserFromRoom';
import './rooms/saveRoomSettings';
import './rooms/toggleFavorite';
import './rooms/unarchiveRoom';
import './rooms/unmuteUserInRoom';
import './settings/getSetupWizardParameters';
import './settings/saveSetting';
import './settings/saveSettings';
import './settings/sendSMTPTestEmail';
import './users/blockUser';
import './users/deleteCustomUserStatus';
import './users/deleteUser';
import './users/deleteUserOwnAccount';
import './users/getUsernameSuggestion';
import './users/getUsersOfRoom';
import './users/getUserStatusText';
import './users/ignoreUser';
import './users/insertOrUpdateUserStatus';
import './users/listCustomUserStatus';
import './users/registerUser';
import './users/resetAvatar';
import './users/saveNotificationSettings';
import './users/saveUserPreferences';
import './users/saveUserProfile';
import './users/setAvatarFromService';
import './users/setEmail';
import './users/setRealName';
import './users/setUserActiveStatus';
import './users/setUserStatus';
import './users/unblockUser';
import './users/userPresence';
import './users/userSetUtcOffset';

View File

@@ -0,0 +1,23 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { clearIntegrationHistoryMethod } from '../../lib/integrations/functions/clearIntegrationHistory';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
clearIntegrationHistory(integrationId: string): Promise<boolean>;
}
}
Meteor.methods<ServerMethods>({
async clearIntegrationHistory(integrationId) {
methodDeprecationLogger.method('clearIntegrationHistory', '9.0.0', '/v1/integrations.clearHistory');
if (!this.userId) {
throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' });
}
await clearIntegrationHistoryMethod(this.userId, integrationId);
return true;
},
});

View File

@@ -0,0 +1,197 @@
import type { INewIncomingIntegration, IIncomingIntegration } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { removeEmpty } from '@rocket.chat/tools';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import { compileIntegrationScript } from '../../../lib/integrations/lib/compileIntegrationScript';
import { validateScriptEngine, isScriptEngineFrozen } from '../../../lib/integrations/lib/validateScriptEngine';
import { notifyOnIntegrationChanged } from '../../../lib/notifyListener';
const validChannelChars = ['@', '#'];
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addIncomingIntegration(integration: INewIncomingIntegration): Promise<IIncomingIntegration>;
}
}
export const addIncomingIntegration = async (userId: string, integration: INewIncomingIntegration): Promise<IIncomingIntegration> => {
check(
integration,
Match.ObjectIncluding({
type: String,
name: String,
enabled: Boolean,
username: String,
channel: String,
alias: Match.Maybe(String),
emoji: Match.Maybe(String),
scriptEnabled: Boolean,
scriptEngine: Match.Maybe(String),
overrideDestinationChannelEnabled: Match.Maybe(Boolean),
script: Match.Maybe(String),
avatar: Match.Maybe(String),
}),
);
if (
!userId ||
(!(await hasPermissionAsync(userId, 'manage-incoming-integrations')) &&
!(await hasPermissionAsync(userId, 'manage-own-incoming-integrations')))
) {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'addIncomingIntegration',
});
}
if (!integration.channel || typeof integration.channel.valueOf() !== 'string') {
throw new Meteor.Error('error-invalid-channel', 'Invalid channel', {
method: 'addIncomingIntegration',
});
}
if (integration.channel.trim() === '') {
throw new Meteor.Error('error-invalid-channel', 'Invalid channel', {
method: 'addIncomingIntegration',
});
}
const channels = integration.channel.split(',').map((channel) => channel.trim());
for (const channel of channels) {
if (!validChannelChars.includes(channel[0])) {
throw new Meteor.Error('error-invalid-channel-start-with-chars', 'Invalid channel. Start with @ or #', {
method: 'updateIncomingIntegration',
});
}
}
if (!integration.username || typeof integration.username.valueOf() !== 'string' || integration.username.trim() === '') {
throw new Meteor.Error('error-invalid-username', 'Invalid username', {
method: 'addIncomingIntegration',
});
}
if (integration.script?.trim()) {
validateScriptEngine(integration.scriptEngine ?? 'isolated-vm');
}
const user = await Users.findOneByUsername(integration.username, { projection: { _id: 1 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addIncomingIntegration',
});
}
if (!(await hasPermissionAsync(user._id, 'message-impersonate'))) {
throw new Meteor.Error(
'error-user-lacks-message-impersonate-permission',
"User selected for the incoming integration lacks the 'message-impersonate' permission.",
{
method: 'addIncomingIntegration',
},
);
}
// Default to transpiling with Babel for backwards compatibility; integrations
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
const skipTranspile = integration.skipTranspile === true;
const integrationData: IIncomingIntegration = {
...integration,
scriptEngine: integration.scriptEngine ?? 'isolated-vm',
skipTranspile,
type: 'webhook-incoming',
channel: channels,
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false,
token: Random.id(48),
userId: user._id,
_createdAt: new Date(),
_createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }),
};
if (
!isScriptEngineFrozen(integrationData.scriptEngine) &&
integration.scriptEnabled === true &&
integration.script &&
integration.script.trim() !== ''
) {
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
if (error) {
integrationData.scriptCompiled = undefined;
integrationData.scriptError = error;
} else {
integrationData.scriptCompiled = script;
delete integrationData.scriptError;
}
}
for (let channel of channels) {
let record;
const channelType = channel[0];
channel = channel.substr(1);
switch (channelType) {
case '#':
record = await Rooms.findOne({
$or: [{ _id: channel }, { name: channel }],
});
break;
case '@':
record = await Users.findOne({
$or: [{ _id: channel }, { username: channel }],
});
break;
}
if (!record) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'addIncomingIntegration',
});
}
if (
!(await hasAllPermissionAsync(userId, ['manage-incoming-integrations', 'manage-own-incoming-integrations'])) &&
!(await Subscriptions.findOneByRoomIdAndUserId(record._id, userId, { projection: { _id: 1 } }))
) {
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
method: 'addIncomingIntegration',
});
}
}
const strippedIntegrationData = removeEmpty(integrationData);
const { insertedId } = await Integrations.insertOne(strippedIntegrationData);
const integrationStored = await Integrations.findOne({ _id: insertedId });
if (!integrationStored) {
throw new Error('Error inserting integration');
}
void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted');
return integrationStored as IIncomingIntegration;
};
Meteor.methods<ServerMethods>({
async addIncomingIntegration(integration: INewIncomingIntegration): Promise<IIncomingIntegration> {
methodDeprecationLogger.method('addIncomingIntegration', '9.0.0', '/v1/integrations.create');
const { userId } = this;
if (!userId) {
throw new Meteor.Error('invalid-user', 'Invalid User', {
method: 'addIncomingIntegration',
});
}
return addIncomingIntegration(userId, integration);
},
});

View File

@@ -0,0 +1,55 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import { notifyOnIntegrationChanged } from '../../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
deleteIncomingIntegration(integrationId: string): Promise<boolean>;
}
}
export const deleteIncomingIntegration = async (integrationId: string, userId: string): Promise<void> => {
const canManageAllIntegrations = !!userId && (await hasPermissionAsync(userId, 'manage-incoming-integrations'));
const canManageOwnIntegrations =
!canManageAllIntegrations && !!userId && (await hasPermissionAsync(userId, 'manage-own-incoming-integrations'));
if (!canManageAllIntegrations && !canManageOwnIntegrations) {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'deleteIncomingIntegration',
});
}
const integration = await Integrations.removeByIdAndCreatedByIfExists({
_id: integrationId,
...(canManageOwnIntegrations && { createdBy: userId }),
});
if (!integration) {
throw new Meteor.Error('error-invalid-integration', 'Invalid integration', {
method: 'deleteIncomingIntegration',
});
}
void notifyOnIntegrationChanged(integration, 'removed');
};
Meteor.methods<ServerMethods>({
async deleteIncomingIntegration(integrationId) {
methodDeprecationLogger.method('deleteIncomingIntegration', '9.0.0', '/v1/integrations.remove');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'deleteIncomingIntegration',
});
}
await deleteIncomingIntegration(integrationId, userId);
return true;
},
});

View File

@@ -0,0 +1,211 @@
import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import { hasAllPermissionAsync, hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import { compileIntegrationScript } from '../../../lib/integrations/lib/compileIntegrationScript';
import { isScriptEngineFrozen, validateScriptEngine } from '../../../lib/integrations/lib/validateScriptEngine';
import { notifyOnIntegrationChanged } from '../../../lib/notifyListener';
const validChannelChars = ['@', '#'];
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
updateIncomingIntegration(
integrationId: string,
integration: INewIncomingIntegration | IUpdateIncomingIntegration,
): IIntegration | null;
}
}
function validateChannels(channelString: string | undefined): string[] {
if (!channelString || typeof channelString.valueOf() !== 'string' || channelString.trim() === '') {
throw new Meteor.Error('error-invalid-channel', 'Invalid channel', {
method: 'updateIncomingIntegration',
});
}
const channels = channelString.split(',').map((channel) => channel.trim());
for (const channel of channels) {
if (!validChannelChars.includes(channel[0])) {
throw new Meteor.Error('error-invalid-channel-start-with-chars', 'Invalid channel. Start with @ or #', {
method: 'updateIncomingIntegration',
});
}
}
return channels;
}
export const updateIncomingIntegration = async (
userId: string,
integrationId: string,
integration: INewIncomingIntegration | IUpdateIncomingIntegration,
): Promise<IIntegration | null> => {
const channels = validateChannels(integration.channel);
let currentIntegration;
if (await hasPermissionAsync(userId, 'manage-incoming-integrations')) {
currentIntegration = await Integrations.findOneById(integrationId);
} else if (await hasPermissionAsync(userId, 'manage-own-incoming-integrations')) {
currentIntegration = await Integrations.findOne({
'_id': integrationId,
'_createdBy._id': userId,
});
} else {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'updateIncomingIntegration',
});
}
if (!currentIntegration) {
throw new Meteor.Error('error-invalid-integration', 'Invalid integration', {
method: 'updateIncomingIntegration',
});
}
const oldScriptEngine = currentIntegration.scriptEngine;
const scriptEngine = integration.scriptEngine ?? oldScriptEngine ?? 'isolated-vm';
if (
integration.script?.trim() &&
(scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim())
) {
wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => {
throw new Meteor.Error(e.message);
});
}
const isFrozen = isScriptEngineFrozen(scriptEngine);
// Default to transpiling with Babel for backwards compatibility; integrations
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
const skipTranspile = integration.skipTranspile === true;
if (!isFrozen && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') {
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
if (error) {
await Integrations.updateOne(
{ _id: integrationId },
{
$set: { scriptError: error, skipTranspile },
$unset: { scriptCompiled: 1 as const },
},
);
} else {
await Integrations.updateOne(
{ _id: integrationId },
{
$set: { scriptCompiled: script, skipTranspile },
$unset: { scriptError: 1 as const },
},
);
}
}
for (let channel of channels) {
const channelType = channel[0];
channel = channel.slice(1);
let record;
switch (channelType) {
case '#':
record = await Rooms.findOne({
$or: [{ _id: channel }, { name: channel }],
});
break;
case '@':
record = await Users.findOne({
$or: [{ _id: channel }, { username: channel }],
});
break;
}
if (!record) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'updateIncomingIntegration',
});
}
if (
!(await hasAllPermissionAsync(userId, ['manage-incoming-integrations', 'manage-own-incoming-integrations'])) &&
!(await Subscriptions.findOneByRoomIdAndUserId(record._id, userId, { projection: { _id: 1 } }))
) {
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
method: 'updateIncomingIntegration',
});
}
}
const username = 'username' in integration ? integration.username : currentIntegration.username;
const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } });
if (!user) {
throw new Meteor.Error('error-invalid-post-as-user', 'Invalid Post As User', {
method: 'updateIncomingIntegration',
});
}
if (!(await hasPermissionAsync(user._id, 'message-impersonate'))) {
throw new Meteor.Error(
'error-user-lacks-message-impersonate-permission',
"User selected for the incoming integration lacks the 'message-impersonate' permission.",
{
method: 'updateIncomingIntegration',
},
);
}
const updatedIntegration = await Integrations.findOneAndUpdate(
{ _id: integrationId },
{
$set: {
enabled: integration.enabled,
name: integration.name,
...(typeof integration.avatar !== 'undefined' && { avatar: integration.avatar }),
...(typeof integration.emoji !== 'undefined' && { emoji: integration.emoji }),
...(typeof integration.alias !== 'undefined' && { alias: integration.alias }),
...(channels && { channel: channels }),
...('username' in integration && { username: user.username, userId: user._id }),
...(isFrozen
? {}
: {
...(typeof integration.script !== 'undefined' && { script: integration.script }),
scriptEnabled: integration.scriptEnabled,
...(scriptEngine && { scriptEngine }),
skipTranspile,
}),
...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && {
overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled,
}),
_updatedAt: new Date(),
_updatedBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }),
},
},
{ returnDocument: 'after' },
);
if (updatedIntegration) {
void notifyOnIntegrationChanged(updatedIntegration);
}
return updatedIntegration;
};
Meteor.methods<ServerMethods>({
async updateIncomingIntegration(integrationId, integration) {
methodDeprecationLogger.method('updateIncomingIntegration', '9.0.0', '/v1/integrations.update');
if (!this.userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'updateOutgoingIntegration',
});
}
return updateIncomingIntegration(this.userId, integrationId, integration);
},
});

View File

@@ -0,0 +1,87 @@
import type { INewOutgoingIntegration, IOutgoingIntegration } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations } from '@rocket.chat/models';
import { removeEmpty } from '@rocket.chat/tools';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import { validateOutgoingIntegration } from '../../../lib/integrations/lib/validateOutgoingIntegration';
import { validateScriptEngine } from '../../../lib/integrations/lib/validateScriptEngine';
import { notifyOnIntegrationChanged } from '../../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addOutgoingIntegration(integration: INewOutgoingIntegration): Promise<IOutgoingIntegration>;
}
}
export const addOutgoingIntegration = async (userId: string, integration: INewOutgoingIntegration): Promise<IOutgoingIntegration> => {
check(
integration,
Match.ObjectIncluding({
type: String,
name: String,
enabled: Boolean,
username: String,
channel: String,
alias: Match.Maybe(String),
emoji: Match.Maybe(String),
scriptEnabled: Boolean,
script: Match.Maybe(String),
scriptEngine: Match.Maybe(String),
urls: Match.Maybe([String]),
event: Match.Maybe(String),
triggerWords: Match.Maybe([String]),
avatar: Match.Maybe(String),
token: Match.Maybe(String),
impersonateUser: Match.Maybe(Boolean),
retryCount: Match.Maybe(Number),
retryDelay: Match.Maybe(String),
retryFailedCalls: Match.Maybe(Boolean),
runOnEdits: Match.Maybe(Boolean),
targetRoom: Match.Maybe(String),
triggerWordAnywhere: Match.Maybe(Boolean),
}),
);
if (
!userId ||
(!(await hasPermissionAsync(userId, 'manage-outgoing-integrations')) &&
!(await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')))
) {
throw new Meteor.Error('not_authorized');
}
if (integration.script?.trim()) {
validateScriptEngine(integration.scriptEngine ?? 'isolated-vm');
}
const integrationData = await validateOutgoingIntegration(integration, userId);
const { insertedId } = await Integrations.insertOne(removeEmpty(integrationData));
const integrationStored = await Integrations.findOne({ _id: insertedId });
if (!integrationStored) {
throw new Error('Error inserting integration');
}
void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted');
return integrationStored as IOutgoingIntegration;
};
Meteor.methods<ServerMethods>({
async addOutgoingIntegration(integration: INewOutgoingIntegration): Promise<IOutgoingIntegration> {
methodDeprecationLogger.method('addOutgoingIntegration', '9.0.0', '/v1/integrations.create');
const { userId } = this;
if (!userId) {
throw new Meteor.Error('Invalid User');
}
return addOutgoingIntegration(userId, integration);
},
});

View File

@@ -0,0 +1,62 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, IntegrationHistory } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import { notifyOnIntegrationChanged } from '../../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
deleteOutgoingIntegration(integrationId: string): Promise<boolean>;
}
}
export const deleteOutgoingIntegration = async (integrationId: string, userId: string): Promise<void> => {
if (!userId) {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'deleteOutgoingIntegration',
});
}
const canManageAllIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations');
const canManageOwnIntegrations = !canManageAllIntegrations && (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations'));
if (!canManageAllIntegrations && !canManageOwnIntegrations) {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'deleteOutgoingIntegration',
});
}
const integration = await Integrations.removeByIdAndCreatedByIfExists({
_id: integrationId,
...(canManageOwnIntegrations && { createdBy: userId }),
});
if (!integration) {
throw new Meteor.Error('error-invalid-integration', 'Invalid integration', {
method: 'deleteOutgoingIntegration',
});
}
// Don't sending to IntegrationHistory listener since it don't waits for 'removed' events.
await IntegrationHistory.removeByIntegrationId(integrationId);
void notifyOnIntegrationChanged(integration, 'removed');
};
Meteor.methods<ServerMethods>({
async deleteOutgoingIntegration(integrationId) {
methodDeprecationLogger.method('deleteOutgoingIntegration', '9.0.0', '/v1/integrations.remove');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'deleteOutgoingIntegration',
});
}
await deleteOutgoingIntegration(integrationId, userId);
return true;
},
});

View File

@@ -0,0 +1,23 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import { replayOutgoingIntegrationMethod } from '../../../lib/integrations/functions/clearIntegrationHistory';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
replayOutgoingIntegration(params: { integrationId: string; historyId: string }): Promise<boolean>;
}
}
Meteor.methods<ServerMethods>({
async replayOutgoingIntegration({ integrationId, historyId }) {
methodDeprecationLogger.method('replayOutgoingIntegration', '9.0.0', '/v1/integrations.replayOutgoing');
if (!this.userId) {
throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'replayOutgoingIntegration' });
}
await replayOutgoingIntegrationMethod(this.userId, { integrationId, historyId });
return true;
},
});

View File

@@ -0,0 +1,132 @@
import type { IIntegration, INewOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Integrations, Users } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import { validateOutgoingIntegration } from '../../../lib/integrations/lib/validateOutgoingIntegration';
import { isScriptEngineFrozen, validateScriptEngine } from '../../../lib/integrations/lib/validateScriptEngine';
import { notifyOnIntegrationChanged } from '../../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
updateOutgoingIntegration(
integrationId: string,
integration: INewOutgoingIntegration | IUpdateOutgoingIntegration,
): IIntegration | null;
}
}
export const updateOutgoingIntegration = async (
userId: string,
integrationId: string,
_integration: INewOutgoingIntegration | IUpdateOutgoingIntegration,
): Promise<IIntegration | null> => {
const integration = await validateOutgoingIntegration(_integration, userId);
if (!integration.token || integration.token.trim() === '') {
throw new Meteor.Error('error-invalid-token', 'Invalid token', {
method: 'updateOutgoingIntegration',
});
}
let currentIntegration: IIntegration | null;
if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) {
currentIntegration = await Integrations.findOneById(integrationId);
} else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) {
currentIntegration = await Integrations.findOne({
'_id': integrationId,
'_createdBy._id': userId,
});
} else {
throw new Meteor.Error('not_authorized', 'Unauthorized', {
method: 'updateOutgoingIntegration',
});
}
if (!currentIntegration) {
throw new Meteor.Error('invalid_integration', '[methods] updateOutgoingIntegration -> integration not found');
}
const oldScriptEngine = currentIntegration.scriptEngine;
const scriptEngine = integration.scriptEngine ?? oldScriptEngine ?? 'isolated-vm';
if (
integration.script?.trim() &&
(scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim())
) {
wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => {
throw new Meteor.Error(e.message);
});
}
const isFrozen = isScriptEngineFrozen(scriptEngine);
const updatedIntegration = await Integrations.findOneAndUpdate(
{ _id: integrationId },
{
$set: {
event: integration.event,
enabled: integration.enabled,
name: integration.name,
avatar: integration.avatar,
emoji: integration.emoji,
alias: integration.alias,
channel: typeof integration.channel === 'string' ? [integration.channel] : integration.channel,
targetRoom: integration.targetRoom,
impersonateUser: integration.impersonateUser,
username: integration.username,
userId: integration.userId,
urls: integration.urls,
token: integration.token,
...(isFrozen
? {}
: {
script: integration.script,
scriptEnabled: integration.scriptEnabled,
scriptEngine,
skipTranspile: integration.skipTranspile,
...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }),
}),
triggerWords: integration.triggerWords,
retryFailedCalls: integration.retryFailedCalls,
retryCount: integration.retryCount,
retryDelay: integration.retryDelay,
triggerWordAnywhere: integration.triggerWordAnywhere,
runOnEdits: integration.runOnEdits,
_updatedAt: new Date(),
_updatedBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }),
},
...(isFrozen
? {}
: {
$unset: {
...(integration.scriptCompiled ? { scriptError: 1 as const } : { scriptCompiled: 1 as const }),
},
}),
},
{ returnDocument: 'after' },
);
if (updatedIntegration) {
await notifyOnIntegrationChanged(updatedIntegration);
}
return updatedIntegration;
};
Meteor.methods<ServerMethods>({
async updateOutgoingIntegration(integrationId, _integration) {
methodDeprecationLogger.method('updateOutgoingIntegration', '9.0.0', '/v1/integrations.update');
if (!this.userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'updateOutgoingIntegration',
});
}
return updateOutgoingIntegration(this.userId, integrationId, _integration);
},
});

View File

@@ -0,0 +1,27 @@
import type { ICustomSound } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { deleteCustomSound } from '../../lib/media/custom-sounds/lib/deleteCustomSound';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
deleteCustomSound(_id: ICustomSound['_id']): Promise<boolean>;
}
}
Meteor.methods<ServerMethods>({
async deleteCustomSound(_id) {
methodDeprecationLogger.method('deleteCustomSound', '9.0.0', '/v1/custom-sounds.delete');
if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) {
throw new Meteor.Error('not_authorized');
}
check(_id, String);
await deleteCustomSound(_id);
return true;
},
});

View File

@@ -0,0 +1,46 @@
import { api } from '@rocket.chat/core-services';
import type { ICustomEmojiDescriptor } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { EmojiCustom } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { RocketChatFileEmojiCustomInstance } from '../../lib/media/emoji-custom/startup/emoji-custom';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
deleteEmojiCustom(emojiID: ICustomEmojiDescriptor['_id']): boolean;
}
}
export const deleteEmojiCustom = async (userId: string, emojiID: ICustomEmojiDescriptor['_id']): Promise<boolean> => {
if (!(await hasPermissionAsync(userId, 'manage-emoji'))) {
throw new Meteor.Error('not_authorized');
}
const emoji = await EmojiCustom.findOneById(emojiID);
if (emoji == null) {
throw new Meteor.Error('Custom_Emoji_Error_Invalid_Emoji', 'Invalid emoji', {
method: 'deleteEmojiCustom',
});
}
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emoji.name}.${emoji.extension}`));
await EmojiCustom.removeById(emojiID);
void api.broadcast('emoji.deleteCustom', emoji);
return true;
};
Meteor.methods<ServerMethods>({
async deleteEmojiCustom(emojiID) {
methodDeprecationLogger.method('deleteEmojiCustom', '9.0.0', '/v1/emoji-custom.delete');
if (!this.userId) {
throw new Meteor.Error('not_authorized');
}
return deleteEmojiCustom(this.userId, emojiID);
},
});

View File

@@ -0,0 +1,35 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Uploads } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { settings } from '../../settings';
import { UploadFS } from '../../ufs';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getS3FileUrl(fileId: string): string;
}
}
Meteor.methods<ServerMethods>({
async getS3FileUrl(fileId) {
check(fileId, String);
const uid = Meteor.userId();
if (settings.get<boolean>('FileUpload_ProtectFiles') && !uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' });
}
const file = await Uploads.findOneById(fileId);
if (!file?.rid) {
throw new Meteor.Error('error-not-allowed', 'Not allowed');
}
const room = await Rooms.findOneById(file.rid);
if (uid && room && !(await canAccessRoomAsync(room, { _id: uid }))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed');
}
return UploadFS.getStore('AmazonS3:Uploads').getRedirectURL(file);
},
});

View File

@@ -0,0 +1,33 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { insertOrUpdateEmoji } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
insertOrUpdateEmoji(emojiData: {
_id?: string;
name: string;
aliases: string;
extension: string;
previousName?: string;
previousExtension?: string;
newFile?: boolean;
}): string | boolean;
}
}
Meteor.methods<ServerMethods>({
async insertOrUpdateEmoji(emojiData) {
methodDeprecationLogger.method('insertOrUpdateEmoji', '9.0.0', ['/v1/emoji-custom.create', '/v1/emoji-custom.update']);
const emoji = await insertOrUpdateEmoji(this.userId, emojiData);
if (!emojiData._id) {
return emoji._id;
}
return !!emoji;
},
});

View File

@@ -0,0 +1,40 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { insertOrUpdateSound } from '../../lib/media/custom-sounds/lib/insertOrUpdateSound';
export type ICustomSoundData = {
_id?: string;
name: string;
extension: string;
previousName?: string;
previousSound?: {
extension?: string;
};
previousExtension?: string;
newFile?: boolean;
random?: number;
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
insertOrUpdateSound(soundData: ICustomSoundData): Promise<string>;
}
}
Meteor.methods<ServerMethods>({
async insertOrUpdateSound(soundData) {
methodDeprecationLogger.method('insertOrUpdateSound', '9.0.0', ['/v1/custom-sounds.create', '/v1/custom-sounds.update']);
if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) {
throw new Meteor.Error('not_authorized');
}
if (soundData._id) {
check(soundData._id, String);
}
return insertOrUpdateSound(soundData);
},
});

View File

@@ -0,0 +1,20 @@
import type { ICustomSound } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { CustomSounds } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
listCustomSounds(): ICustomSound[];
}
}
Meteor.methods<ServerMethods>({
async listCustomSounds() {
methodDeprecationLogger.method('listCustomSounds', '9.0.0', '/v1/custom-sounds.list');
return CustomSounds.find({}).toArray();
},
});

View File

@@ -0,0 +1,26 @@
import type { RequiredField } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import type { ICustomSoundData } from './insertOrUpdateSound';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { uploadCustomSound } from '../../lib/media/custom-sounds/lib/uploadCustomSound';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
uploadCustomSound(binaryContent: string, contentType: string, soundData: RequiredField<ICustomSoundData, '_id'>): void;
}
}
Meteor.methods<ServerMethods>({
async uploadCustomSound(binaryContent, contentType, soundData) {
methodDeprecationLogger.method('uploadCustomSound', '9.0.0', ['/v1/custom-sounds.create', '/v1/custom-sounds.update']);
if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) {
throw new Meteor.Error('not_authorized');
}
const buffer = Buffer.from(binaryContent, 'binary');
await uploadCustomSound(buffer, contentType, soundData);
},
});

View File

@@ -0,0 +1,27 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { uploadEmojiCustom } from '../../lib/media/emoji-custom/lib/uploadEmojiCustom';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
uploadEmojiCustom(
binaryContent: string,
contentType: string,
emojiData: {
name: string;
aliases?: string;
extension: string;
},
): void;
}
}
Meteor.methods<ServerMethods>({
async uploadEmojiCustom(binaryContent, contentType, emojiData) {
methodDeprecationLogger.method('uploadEmojiCustom', '9.0.0', '/v1/emoji-custom.create');
await uploadEmojiCustom(this.userId, binaryContent, contentType, emojiData);
},
});

View File

@@ -0,0 +1,117 @@
import type { ICreateRoomParams } from '@rocket.chat/core-services';
import type { ICreatedRoom, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Users } from '@rocket.chat/models';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { callbacks } from '../../lib/callbacks';
import { createRoom } from '../../lib/rooms/createRoom';
import { settings } from '../../settings';
export async function createDirectMessage(
usernames: IUser['username'][],
userId: IUser['_id'] | null,
excludeSelf = false,
): Promise<Omit<ICreatedRoom, '_id' | 'inserted'>> {
check(usernames, [String]);
check(userId, String);
check(excludeSelf, Match.Optional(Boolean));
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
const me = await Users.findOneById(userId, { projection: { username: 1, name: 1 } });
if (!me?.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
if (settings.get('Message_AllowDirectMessagesToYourself') === false && usernames.length === 1 && me.username === usernames[0]) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
const users = await Promise.all(usernames.filter((username) => username !== me.username));
const options: Exclude<ICreateRoomParams['options'], undefined> = { creator: me._id };
const roomUsers = excludeSelf ? users : [me, ...users];
// allow self-DMs
if (roomUsers.length === 1 && roomUsers[0] !== undefined && typeof roomUsers[0] !== 'string' && roomUsers[0]._id !== me._id) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDirectMessage',
});
}
if (!(await hasPermissionAsync(userId, 'create-d'))) {
// If the user can't create DMs but can access already existing ones
if ((await hasPermissionAsync(userId, 'view-d-room')) && !Object.keys(roomUsers).some((user) => typeof user === 'string')) {
// Check if the direct room already exists, then return it
const uids = (roomUsers as IUser[]).map(({ _id }) => _id).sort();
// No projection: the full room is spread into the ICreatedRoom-shaped return below.
const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uids);
if (room) {
return {
...room,
t: 'd',
rid: room._id,
};
}
}
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'createDirectMessage',
});
}
if (excludeSelf && (await hasPermissionAsync(userId, 'view-room-administration'))) {
options.subscriptionExtra = { open: true };
}
const extraData = {};
try {
await callbacks.run('federation.beforeCreateDirectMessage', roomUsers, extraData);
} catch (error) {
throw new Meteor.Error((error as any)?.message);
}
const {
_id: rid,
inserted,
...room
} = await createRoom<'d'>('d', undefined, undefined, roomUsers as IUser[], false, undefined, extraData, options);
return {
// @ts-expect-error - room type is already defined in the `createRoom` return type
t: 'd',
// @ts-expect-error - room id is not defined in the `createRoom` return type
rid,
...room,
};
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
createDirectMessage(...usernames: Exclude<IUser['username'], undefined>[]): Omit<ICreatedRoom, '_id' | 'inserted'>;
}
}
Meteor.methods<ServerMethods>({
async createDirectMessage(...usernames) {
return createDirectMessage(usernames, Meteor.userId());
},
});
RateLimiter.limitMethod('createDirectMessage', 10, 60000, {
async userId(userId: IUser['_id']) {
return !(await hasPermissionAsync(userId, 'send-many-messages'));
},
});

View File

@@ -0,0 +1,277 @@
import { Message } from '@rocket.chat/core-services';
import type { IMessage, IRoom, IUser, MessageAttachmentDefault } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { afterSaveMessageAsync } from '../../hooks/messages/afterSaveMessage';
import { canSendMessageAsync } from '../../lib/authorization/canSendMessage';
import { hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { i18n } from '../../lib/i18n';
import { attachMessage } from '../../lib/messages/attachMessage';
import { sendMessage } from '../../lib/messages/sendMessage';
import { addUserToRoom } from '../../lib/rooms/addUserToRoom';
import { createRoom } from '../../lib/rooms/createRoom';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { settings } from '../../settings';
const getParentRoom = async (rid: IRoom['_id']) => {
const room = await Rooms.findOne(rid);
return room && (room.prid ? Rooms.findOne(room.prid, { projection: { _id: 1 } }) : room);
};
async function createDiscussionMessage(
rid: IRoom['_id'],
user: IUser,
drid: IRoom['_id'],
msg: IMessage['msg'],
messageEmbedded?: MessageAttachmentDefault,
): Promise<IMessage> {
return Message.saveSystemMessage('discussion-created', rid, msg, user, {
drid,
...(messageEmbedded && { attachments: [messageEmbedded] }),
});
}
async function mentionMessage(
rid: IRoom['_id'],
{ _id, username, name }: Pick<IUser, '_id' | 'username' | 'name'>,
messageEmbedded?: MessageAttachmentDefault,
) {
if (!username) {
return null;
}
await Messages.insertOne({
rid,
msg: '',
u: { _id, username, name },
ts: new Date(),
_updatedAt: new Date(),
...(messageEmbedded && { attachments: [messageEmbedded] }),
});
}
type CreateDiscussionProperties = {
prid: IRoom['_id'];
pmid?: IMessage['_id'];
t_name: string;
reply?: string;
users: Array<Exclude<IUser['username'], undefined>>;
user: IUser;
encrypted?: boolean;
topic?: string;
};
const create = async ({
prid,
pmid,
t_name: discussionName,
reply,
users,
user,
encrypted,
topic,
}: CreateDiscussionProperties): Promise<IRoom & { rid: string }> => {
// if you set both, prid and pmid, and the rooms dont match... should throw an error)
let message: null | IMessage = null;
if (pmid) {
message = await Messages.findOneById(pmid);
if (!message) {
throw new Meteor.Error('error-invalid-message', 'Invalid message', {
method: 'DiscussionCreation',
});
}
if (prid) {
const parentRoom = await getParentRoom(message.rid);
if (prid !== parentRoom?._id) {
throw new Meteor.Error('error-invalid-arguments', 'Root message room ID does not match parent room ID ', {
method: 'DiscussionCreation',
});
}
} else {
prid = message.rid;
}
}
if (!prid) {
throw new Meteor.Error('error-invalid-arguments', 'Missing parent room ID', { method: 'DiscussionCreation' });
}
let parentRoom;
try {
parentRoom = await canSendMessageAsync(prid, { uid: user._id, username: user.username, type: user.type });
} catch (error) {
throw new Meteor.Error((error as Error).message);
}
if (parentRoom.prid) {
throw new Meteor.Error('error-nested-discussion', 'Cannot create nested discussions', {
method: 'DiscussionCreation',
});
}
if (typeof encrypted !== 'boolean') {
encrypted = Boolean(parentRoom.encrypted);
}
if (encrypted && reply) {
throw new Meteor.Error('error-invalid-arguments', 'Encrypted discussions must not receive an initial reply.', {
method: 'DiscussionCreation',
});
}
if (pmid) {
// No projection: the full room is spread into the returned discussion below.
const discussionAlreadyExists = await Rooms.findOne({
prid,
pmid,
});
if (discussionAlreadyExists) {
// do not allow multiple discussions to the same message'\
await addUserToRoom(discussionAlreadyExists._id, user);
return { ...discussionAlreadyExists, rid: discussionAlreadyExists._id };
}
}
const name = Random.id();
// auto invite the replied message owner
const invitedUsers = message ? [message.u.username, ...users] : users;
const type = await roomCoordinator.getRoomDirectives(parentRoom.t).getDiscussionType(parentRoom);
const description = parentRoom.encrypted ? '' : message?.msg;
const discussionTopic = topic || parentRoom.name;
if (!type) {
throw new Meteor.Error('error-invalid-type', 'Cannot define discussion room type', {
method: 'DiscussionCreation',
});
}
if (
type === 'p' &&
!encrypted &&
settings.get<boolean>('E2E_Enable') &&
settings.get<boolean>('E2E_Force_Encryption_For_Private_Rooms')
) {
throw new Meteor.Error(
'error-encrypted-private-rooms-enforced-discussion',
'Workspace policy requires all private rooms to be encrypted. To create this discussion, make the parent channel public or enable encryption on it.',
{ method: 'DiscussionCreation' },
);
}
const discussion = await createRoom(
type,
name,
user,
[...new Set(invitedUsers)].filter(Boolean),
false,
false,
{
fname: discussionName,
description, // TODO discussions remove
topic: discussionTopic,
prid,
encrypted,
},
{
creator: user._id,
},
);
let discussionMsg;
if (message) {
if (parentRoom.encrypted) {
message.msg = i18n.t('Encrypted_message');
}
await mentionMessage(discussion._id, user, attachMessage(message, parentRoom));
discussionMsg = await createDiscussionMessage(message.rid, user, discussion._id, discussionName, attachMessage(message, parentRoom));
} else {
discussionMsg = await createDiscussionMessage(prid, user, discussion._id, discussionName);
}
if (reply) {
await sendMessage(user, { msg: reply }, discussion);
}
if (discussionMsg) {
afterSaveMessageAsync(discussionMsg, parentRoom, user);
}
return discussion;
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
createDiscussion: typeof create;
}
}
export const createDiscussion = async (
userId: string,
{ prid, pmid, t_name: discussionName, reply, users, encrypted, topic }: Omit<CreateDiscussionProperties, 'user'>,
): Promise<
IRoom & {
rid: string;
}
> => {
if (!settings.get('Discussion_enabled')) {
throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' });
}
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'DiscussionCreation',
});
}
if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user'], prid))) {
throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' });
}
const user = await Users.findOneById(userId, { projection: { services: 0 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'createDiscussion',
});
}
return create({ prid, pmid, t_name: discussionName, reply, users, user, encrypted, topic });
};
Meteor.methods<ServerMethods>({
/**
* Create discussion by room or message
* @constructor
* @param {string} prid - Parent Room Id - The room id, optional if you send pmid.
* @param {string} pmid - Parent Message Id - Create the discussion by a message, optional.
* @param {string} reply - The reply, optional
* @param {string} t_name - discussion name
* @param {string[]} users - users to be added
* @param {boolean} encrypted - if the discussion's e2e encryption should be enabled.
*/
async createDiscussion({ prid, pmid, t_name: discussionName, reply, users, encrypted }: CreateDiscussionProperties) {
methodDeprecationLogger.method('createDiscussion', '9.0.0', '/v1/rooms.createDiscussion');
check(prid, Match.Maybe(String));
check(pmid, Match.Maybe(String));
check(reply, Match.Maybe(String));
check(discussionName, String);
check(users, [String]);
check(encrypted, Match.Maybe(Boolean));
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'DiscussionCreation',
});
}
return createDiscussion(uid, { prid, pmid, t_name: discussionName, reply, users, encrypted });
},
});

View File

@@ -0,0 +1,58 @@
import { Upload } from '@rocket.chat/core-services';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Users, Uploads } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { DeleteResult } from 'mongodb';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { FileUpload } from '../../lib/media/file-upload';
import { deleteMessageValidatingPermission } from '../../lib/messages/deleteMessage';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
deleteFileMessage(fileID: string): Promise<void | DeleteResult>;
}
}
Meteor.methods<ServerMethods>({
async deleteFileMessage(fileID) {
methodDeprecationLogger.method('deleteFileMessage', '9.0.0', '/v1/chat.delete');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'deleteFileMessage',
});
}
check(fileID, String);
const msg = await Messages.getMessageByFileId(fileID);
if (msg) {
return deleteMessageValidatingPermission(msg, userId);
}
const user = await Users.findOneById(userId, { projection: { username: 1 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'deleteFileMessage',
});
}
const file = await Uploads.findOneById(fileID, { projection: { userId: 1, rid: 1, expiresAt: 1, uploadedAt: 1 } });
if (!file) {
throw new Meteor.Error('error-invalid-file', 'Invalid file', {
method: 'deleteFileMessage',
});
}
if (!(await Upload.canDeleteFile(user, file, null))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'deleteFileMessage',
});
}
return FileUpload.getStore('Uploads').deleteById(fileID);
},
});

View File

@@ -0,0 +1,65 @@
import type { IMessage, RequiredField, SlashCommandPreviewItem } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { slashCommands } from '../../lib/utils/slashCommand';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
executeSlashCommandPreview(
command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
triggerId?: string;
},
preview: SlashCommandPreviewItem,
): void;
}
}
export const executeSlashCommandPreview = async (
command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
triggerId?: string;
},
preview: SlashCommandPreviewItem,
userId: string,
): Promise<void> => {
if (!command?.cmd || !slashCommands.commands[command.cmd]) {
throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', {
method: 'executeSlashCommandPreview',
});
}
const theCmd = slashCommands.commands[command.cmd];
if (!theCmd.providesPreview) {
throw new Meteor.Error('error-invalid-command', 'Command Does Not Provide Previews', {
method: 'executeSlashCommandPreview',
});
}
if (!preview) {
throw new Meteor.Error('error-invalid-command-preview', 'Invalid Preview Provided', {
method: 'executeSlashCommandPreview',
});
}
return slashCommands.executePreview(command.cmd, command.params, command.msg, preview, userId, command.triggerId);
};
Meteor.methods<ServerMethods>({
executeSlashCommandPreview(command, preview) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getSlashCommandPreview',
});
}
return executeSlashCommandPreview(command, preview, userId);
},
});

View File

@@ -0,0 +1,70 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { follow } from '../../lib/messaging/threads/functions';
import { notifyOnMessageChange } from '../../lib/notifyListener';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
followMessage(message: { mid: IMessage['_id'] }): false | undefined;
}
}
export const followMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise<false | undefined> => {
if (mid && !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' });
}
const message = await Messages.findOneById(mid);
if (!message) {
throw new Meteor.Error('error-invalid-message', 'Invalid message', {
method: 'followMessage',
});
}
if (!(await canAccessRoomIdAsync(message.rid, user._id))) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' });
}
const id = message.tmid || message._id;
const followResult = await follow({ tmid: id, uid: user._id });
void notifyOnMessageChange({
id,
});
const isFollowed = true;
await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed);
return followResult;
};
Meteor.methods<ServerMethods>({
async followMessage({ mid }) {
methodDeprecationLogger.method('followMessage', '9.0.0', '/v1/chat.followMessage');
check(mid, String);
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'followMessage' });
}
return followMessage(user, { mid });
},
});
RateLimiter.limitMethod('followMessage', 5, 5000, {
userId() {
return true;
},
});

View File

@@ -0,0 +1,173 @@
import { Authorization } from '@rocket.chat/core-services';
import type { IMessage, MessageTypesValues } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { getHiddenSystemMessages } from '../../lib/messaging/getHiddenSystemMessages';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
import { settings } from '../../settings/cached';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getChannelHistory(params: {
rid: string;
latest?: Date;
oldest?: Date;
inclusive?: boolean;
offset?: number;
count?: number;
unreads?: boolean;
showThreadMessages?: boolean;
}): boolean | IMessage[] | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number };
}
}
export const getChannelHistory = async ({
rid,
fromUserId,
latest,
oldest,
inclusive,
offset = 0,
count = 20,
unreads,
showThreadMessages = true,
}: {
rid: string;
fromUserId: string;
latest?: Date;
oldest?: Date;
inclusive?: boolean;
offset?: number;
count?: number;
unreads?: boolean;
showThreadMessages?: boolean;
}): Promise<false | IMessage[] | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number }> => {
check(rid, String);
if (!fromUserId) {
return false;
}
const room = await Rooms.findOneById(rid);
if (!room) {
return false;
}
// Make sure they can access the room
if (!(await Authorization.canReadRoom(room, { _id: fromUserId }))) {
return false;
}
// Ensure latest is always defined.
if (latest === undefined) {
latest = new Date();
}
// Verify oldest is a date if it exists
if (oldest !== undefined && {}.toString.call(oldest) !== '[object Date]') {
throw new Meteor.Error('error-invalid-date', 'Invalid date', { method: 'getChannelHistory' });
}
const hiddenSystemMessages = settings.get<MessageTypesValues[]>('Hide_System_Messages');
const hiddenMessageTypes = getHiddenSystemMessages(room, hiddenSystemMessages);
const options: Record<string, unknown> = {
sort: {
ts: -1,
},
skip: offset,
limit: count,
};
const records =
oldest === undefined
? await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes(
rid,
latest,
hiddenMessageTypes,
options,
showThreadMessages,
inclusive,
).toArray()
: await Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
rid,
oldest,
latest,
hiddenMessageTypes,
options,
showThreadMessages,
inclusive,
).toArray();
const messages = await normalizeMessagesForUser(records, fromUserId);
if (unreads) {
let unreadNotLoaded = 0;
let firstUnread = undefined;
if (oldest !== undefined) {
const firstMsg = messages[messages.length - 1];
if (firstMsg !== undefined && firstMsg.ts > oldest) {
const unreadMessages = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
rid,
oldest,
firstMsg.ts,
hiddenMessageTypes,
{
limit: 1,
sort: {
ts: 1,
},
},
showThreadMessages,
);
const totalCursor = await Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes(
rid,
oldest,
firstMsg.ts,
hiddenMessageTypes,
showThreadMessages,
);
firstUnread = (await unreadMessages.toArray())[0];
unreadNotLoaded = totalCursor;
}
}
return {
messages: messages || [],
firstUnread,
unreadNotLoaded,
};
}
return {
messages: messages || [],
};
};
Meteor.methods<ServerMethods>({
async getChannelHistory({ rid, latest, oldest, inclusive, offset = 0, count = 20, unreads, showThreadMessages = true }) {
methodDeprecationLogger.method('getChannelHistory', '9.0.0', '/v1/channels.history');
check(rid, String);
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getChannelHistory' });
}
const fromUserId = Meteor.userId();
if (!fromUserId) {
return false;
}
return getChannelHistory({ rid, fromUserId, latest, oldest, inclusive, offset, count, unreads, showThreadMessages });
},
});

View File

@@ -0,0 +1,45 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdsAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getMessages(messages: IMessage['_id'][]): Promise<IMessage[]>;
}
}
Meteor.methods<ServerMethods>({
async getMessages(messages) {
methodDeprecationLogger.method('getMessages', '9.0.0', '/v1/chat.getMessages');
check(messages, [String]);
const user = await Meteor.userAsync();
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getMessages' });
}
const msgs = await Messages.findVisibleByIds(messages).toArray();
if (!msgs.length) {
return msgs;
}
if (
!(await canAccessRoomIdsAsync(
msgs.map((m) => m.rid),
user,
))
) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getMessages' });
}
return msgs;
},
});

View File

@@ -0,0 +1,42 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getSingleMessage(mid: IMessage['_id']): Promise<IMessage | null>;
}
}
export const getSingleMessage = async (userId: string, mid: IMessage['_id']): Promise<IMessage | null> => {
const msg = await Messages.findOneById(mid);
if (!msg?.rid) {
return null;
}
if (!(await canAccessRoomIdAsync(msg.rid, userId))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getSingleMessage' });
}
return msg;
};
Meteor.methods<ServerMethods>({
async getSingleMessage(mid) {
check(mid, String);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getSingleMessage' });
}
return getSingleMessage(uid, mid);
},
});

View File

@@ -0,0 +1,51 @@
import type { IMessage, RequiredField, SlashCommandPreviews } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { slashCommands } from '../../lib/utils/slashCommand';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getSlashCommandPreviews(command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
}): SlashCommandPreviews | undefined;
}
}
export const getSlashCommandPreviews = async (command: {
cmd: string;
params: string;
msg: RequiredField<Partial<IMessage>, 'rid'>;
userId: string;
}): Promise<SlashCommandPreviews | undefined> => {
if (!command?.cmd || !slashCommands.commands[command.cmd]) {
throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', {
method: 'executeSlashCommandPreview',
});
}
const theCmd = slashCommands.commands[command.cmd];
if (!theCmd.providesPreview) {
throw new Meteor.Error('error-invalid-command', 'Command Does Not Provide Previews', {
method: 'executeSlashCommandPreview',
});
}
return slashCommands.getPreviews(command.cmd, command.params, command.msg, command.userId);
};
Meteor.methods<ServerMethods>({
async getSlashCommandPreviews(command) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getSlashCommandPreview',
});
}
return getSlashCommandPreviews({ ...command, userId });
},
});

View File

@@ -0,0 +1,68 @@
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { callbacks } from '../../lib/callbacks';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { readThread } from '../../lib/messaging/threads/functions';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getThreadMessages(params: { tmid: IMessage['_id']; limit?: number; skip?: number }): Promise<IMessage[]>;
}
}
const MAX_LIMIT = 100;
Meteor.methods<ServerMethods>({
async getThreadMessages({ tmid, limit, skip }) {
methodDeprecationLogger.method('getThreadMessages', '9.0.0', '/v1/chat.getThreadMessages');
if ((limit ?? 0) > MAX_LIMIT) {
throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, {
method: 'getThreadMessages',
});
}
if (!Meteor.userId() || !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', {
method: 'getThreadMessages',
});
}
if (typeof tmid !== 'string') {
throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'getThreadMessages' });
}
const thread = await Messages.findOneById(tmid);
if (!thread) {
return [];
}
const user = await Meteor.userAsync();
const room = await Rooms.findOneById(thread.rid);
if (!user || !room || !(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' });
}
if (!thread.tcount) {
return [];
}
await callbacks.run('beforeReadMessages', thread.rid, user._id);
await readThread({ user: user as IUser, room, tmid: thread._id });
const result = await Messages.findVisibleThreadByThreadId(thread._id, {
...(skip && { skip }),
...(limit && { limit }),
sort: { ts: -1 },
}).toArray();
return [thread, ...result];
},
});

View File

@@ -0,0 +1,45 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { settings } from '../../settings';
const MAX_LIMIT = 100;
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getThreadsList(params: { rid: IRoom['_id']; limit?: number; skip?: number }): IMessage[];
}
}
Meteor.methods<ServerMethods>({
async getThreadsList({ rid, limit = 50, skip = 0 }) {
methodDeprecationLogger.method('getThreadsList', '9.0.0', '/v1/chat.getThreadsList');
if (limit > MAX_LIMIT) {
throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, {
method: 'getThreadsList',
});
}
if (!Meteor.userId() || !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'getThreadsList' });
}
if (typeof rid !== 'string') {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getThreadsList' });
}
const user = await Meteor.userAsync();
const room = await Rooms.findOneById(rid);
if (!user || !room || !(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not Allowed', { method: 'getThreadsList' });
}
return Messages.findThreadsByRoomId(room._id, skip, limit).toArray();
},
});

View File

@@ -0,0 +1,53 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Users, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getUserMentionsByChannel(params: { roomId: string; options: { limit: number; skip: number; sort: { ts: -1 | 1 } } }): IMessage[];
}
}
export const getUserMentionsByChannel = async (
userId: string,
roomId: string,
options: { limit?: number; skip?: number; sort?: { ts?: -1 | 1 } },
) => {
check(roomId, String);
const user = await Users.findOneById(userId);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
const room = await Rooms.findOneById(roomId);
if (!room || !(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'getUserMentionsByChannel',
});
}
return Messages.findVisibleByMentionAndRoomId(user.username, roomId, options).toArray();
};
Meteor.methods<ServerMethods>({
async getUserMentionsByChannel({ roomId, options }) {
methodDeprecationLogger.method('getUserMentionsByChannel', '9.0.0', '/v1/channels.getAllUserMentionsByChannel');
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getUserMentionsByChannel',
});
}
return getUserMentionsByChannel(uid, roomId, options);
},
});

View File

@@ -0,0 +1,67 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync, roomAccessAttributes } from '../../lib/authorization';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { loadMessageHistory } from '../../lib/messages/loadMessageHistory';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadHistory(
rid: IRoom['_id'],
ts?: Date,
limit?: number,
ls?: string | Date,
showThreadMessages?: boolean,
):
| {
messages: IMessage[];
firstUnread: IMessage | undefined;
unreadNotLoaded: number;
}
| false;
}
}
Meteor.methods<ServerMethods>({
async loadHistory(rid, end, limit = 20, ls, showThreadMessages = true) {
methodDeprecationLogger.method('loadHistory', '9.0.0', '/v1/rooms.history');
check(rid, String);
const fromUser = await Meteor.userAsync();
if (!fromUser && settings.get('Accounts_AllowAnonymousRead') === false) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'loadHistory',
});
}
const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, sysMes: 1 } });
if (!room) {
return false;
}
// this checks the Allow Anonymous Read setting, so no need to check again
if (!(await canAccessRoomAsync(room, fromUser || undefined))) {
return false;
}
// if fromId is undefined and it passed the previous check, the user is reading anonymously
if (!fromUser) {
return loadMessageHistory({ rid, end, limit, ls, showThreadMessages, room });
}
const canPreview = await hasPermissionAsync(fromUser, 'preview-c-room');
if (room.t === 'c' && !canPreview && !(await Subscriptions.findOneByRoomIdAndUserId(rid, fromUser._id, { projection: { _id: 1 } }))) {
return false;
}
return loadMessageHistory({ userId: fromUser._id, rid, end, limit, ls, showThreadMessages, room });
},
});

View File

@@ -0,0 +1,39 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadMissedMessages(rid: IRoom['_id'], ts: Date): Promise<false | IMessage[]>;
}
}
Meteor.methods<ServerMethods>({
async loadMissedMessages(rid, start) {
methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.syncMessages');
check(rid, String);
check(start, Date);
const fromId = Meteor.userId() ?? undefined;
if (!rid) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getUsersOfRoom' });
}
if (!(await canAccessRoomIdAsync(rid, fromId))) {
return false;
}
return Messages.findVisibleByRoomIdAfterTimestamp(rid, start, true, {
sort: {
ts: -1,
},
}).toArray();
},
});

View File

@@ -0,0 +1,61 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadNextMessages(rid: IRoom['_id'], end?: Date, limit?: number): Promise<{ messages: IMessage[] }>;
}
}
Meteor.methods<ServerMethods>({
async loadNextMessages(rid, end, limit = 20) {
methodDeprecationLogger.method('loadNextMessages', '9.0.0', '/v1/rooms.history');
check(rid, String);
check(limit, Number);
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'loadNextMessages',
});
}
if (!rid) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'loadNextMessages' });
}
const fromId = Meteor.userId();
if (!fromId || !(await canAccessRoomIdAsync(rid, fromId))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'loadNextMessages' });
}
let records;
if (end) {
records = await Messages.findVisibleByRoomIdAfterTimestamp(rid, end, true, {
sort: {
ts: 1,
},
limit,
}).toArray();
} else {
records = await Messages.findVisibleByRoomId(rid, {
sort: {
ts: 1,
},
limit,
}).toArray();
}
return {
messages: await normalizeMessagesForUser(records, fromId),
};
},
});

View File

@@ -0,0 +1,102 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { FindOptions } from 'mongodb';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadSurroundingMessages(
message: Pick<IMessage, '_id' | 'rid'> & { ts?: Date },
limit?: number,
showThreadMessages?: boolean,
):
| {
messages: IMessage[];
moreBefore: boolean;
moreAfter: boolean;
}
| false;
}
}
Meteor.methods<ServerMethods>({
async loadSurroundingMessages(message, limit = 50, showThreadMessages = true) {
methodDeprecationLogger.method('loadSurroundingMessages', '9.0.0', '/v1/rooms.history');
check(message, Object);
check(limit, Number);
check(showThreadMessages, Boolean);
const fromId = Meteor.userId() ?? undefined;
if (!fromId && settings.get('Accounts_AllowAnonymousRead') === false) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'loadSurroundingMessages',
});
}
if (!message._id) {
return false;
}
const mainMessage = await Messages.findOneById(message._id);
if (!mainMessage?.rid) {
return false;
}
if (!(await canAccessRoomIdAsync(mainMessage.rid, fromId))) {
return false;
}
limit -= 1;
const options: FindOptions<IMessage> = {
sort: {
ts: -1,
},
limit: Math.ceil(limit / 2),
};
const messages = await Messages.findVisibleByRoomIdBeforeTimestamp(
mainMessage.rid,
mainMessage.ts,
showThreadMessages,
options,
).toArray();
const moreBefore = messages.length === options.limit;
messages.push(mainMessage);
options.sort = {
ts: 1,
};
options.limit = Math.floor(limit / 2);
const afterMessages = await Messages.findVisibleByRoomIdAfterTimestamp(
mainMessage.rid,
mainMessage.ts,
showThreadMessages,
options,
).toArray();
const moreAfter = afterMessages.length === options.limit;
messages.push(...afterMessages);
return {
messages: fromId ? await normalizeMessagesForUser(messages, fromId) : messages,
moreBefore,
moreAfter,
};
},
});

View File

@@ -0,0 +1,121 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Logger } from '@rocket.chat/logger';
import { Messages, Subscriptions, Users } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { parseMessageSearchQuery } from '../../lib/parseMessageSearchQuery';
import type { IRawSearchResult } from '../../lib/search/model/ISearchResult';
import { settings } from '../../settings';
const logger = new Logger('MessageSearch');
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
messageSearch(text: string, rid?: string, limit?: number, offset?: number): IRawSearchResult | false;
}
}
export const messageSearch = async function (
userId: string,
text: string,
rid?: string,
limit?: number,
offset?: number,
): Promise<IRawSearchResult | false> {
check(text, String);
check(rid, Match.Maybe(String));
check(limit, Match.Optional(Number));
check(offset, Match.Optional(Number));
// Don't process anything else if the user can't access the room
if (rid) {
if (!(await canAccessRoomIdAsync(rid, userId))) {
return false;
}
} else if (settings.get('Search.defaultProvider.GlobalSearchEnabled') !== true) {
return {
message: {
docs: [],
},
};
}
const user = (await Users.findOneById(userId)) || undefined;
let parsedQuery: ReturnType<typeof parseMessageSearchQuery>;
try {
parsedQuery = parseMessageSearchQuery(text, {
user,
offset,
limit,
forceRegex: settings.get('Message_AlwaysSearchRegExp'),
});
} catch (error: unknown) {
logger.error({ msg: 'Error while parsing message search query', error });
if (error instanceof SyntaxError) {
return { message: { docs: [] } };
}
throw error;
}
const { query, options } = parsedQuery;
if (Object.keys(query).length === 0) {
return {
message: {
docs: [],
},
};
}
query.t = {
$ne: 'rm', // hide removed messages (useful when searching for user messages)
};
query._hidden = {
$ne: true, // don't return _hidden messages
};
if (rid) {
query.rid = rid;
} else {
query.rid = {
$in: user?._id ? (await Subscriptions.findByUserId(user._id).toArray()).map((subscription: ISubscription) => subscription.rid) : [],
};
}
try {
return {
message: {
docs: await Messages.find(query, {
// @ts-expect-error col.s.db is not typed
readPreference: readSecondaryPreferred(Messages.col.s.db),
...options,
}).toArray(),
},
};
} catch (error) {
logger.error({ msg: 'Error while finding messages', error });
throw new Error('error-while-finding-messages', { cause: error });
}
};
Meteor.methods<ServerMethods>({
async messageSearch(text, rid, limit, offset) {
methodDeprecationLogger.method('messageSearch', '9.0.0', '/v1/chat.search');
const currentUserId = Meteor.userId();
if (!currentUserId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'messageSearch',
});
}
return messageSearch(currentUserId, text, rid, limit, offset);
},
});

View File

@@ -0,0 +1,39 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { readMessages } from '../../lib/readMessages';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
readMessages(rid: string, readThreads?: boolean): Promise<void>;
}
}
Meteor.methods<ServerMethods>({
async readMessages(rid, readThreads = false) {
check(rid, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'readMessages',
});
}
const user = ((await Meteor.userAsync()) as IUser | null) ?? undefined;
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'readMessages' });
}
if (!(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readMessages' });
}
await readMessages(room, userId, readThreads);
},
});

View File

@@ -0,0 +1,53 @@
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { callbacks } from '../../lib/callbacks';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { readThread } from '../../lib/messaging/threads/functions';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
readThreads(tmid: IMessage['_id']): void;
}
}
Meteor.methods<ServerMethods>({
async readThreads(tmid) {
methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/chat.readThread');
check(tmid, String);
if (!Meteor.userId() || !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', {
method: 'getThreadMessages',
});
}
const thread = await Messages.findOneById(tmid);
if (!thread) {
return;
}
const user = (await Meteor.userAsync()) ?? undefined;
const room = await Rooms.findOneById(thread.rid);
if (!room) {
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'getThreadMessages' });
}
if (!(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' });
}
await callbacks.run('beforeReadMessages', thread.rid, user?._id);
if (user?._id) {
await readThread({ user: user as IUser, room, tmid });
}
},
});

View File

@@ -0,0 +1,111 @@
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const findOneByIdAndUserIdAndRoomId = sinon.stub();
const updateFileMetadata = sinon.stub().resolves();
const getPath = sinon.stub().returns('/path/to/file.txt');
const isImagePreviewSupported = sinon.stub().returns(false);
const getFileExtension = sinon.stub().returns('txt');
const { parseFileIntoMessageAttachments } = proxyquire.noCallThru().load('./sendFileMessage', {
'@rocket.chat/models': {
Uploads: { findOneByIdAndUserIdAndRoomId, updateFileMetadata },
Rooms: { findOneById: sinon.stub() },
Users: { findOneById: sinon.stub() },
},
'meteor/check': {
check: sinon.stub(),
Match: {
Maybe: sinon.stub(),
Optional: sinon.stub(),
ObjectIncluding: sinon.stub(),
},
},
'meteor/meteor': {
Meteor: {
Error: class Error extends global.Error {},
methods: sinon.stub(),
},
},
'../../lib/media/file-upload/lib/FileUpload': {
FileUpload: { getPath },
},
'../../lib/media/file-upload/isImagePreviewSupported': { isImagePreviewSupported },
'../../../lib/utils/getFileExtension': { getFileExtension },
'../../lib/callbacks': { callbacks: { runAsync: sinon.stub() } },
'../../lib/logger/system': { SystemLogger: { error: sinon.stub() } },
'../../lib/authorization/canAccessRoom': { canAccessRoomAsync: sinon.stub().resolves(true) },
'./sendMessage': { executeSendMessage: sinon.stub().resolves({}) },
});
describe('sendFileMessage - Mass Assignment & Type Pollution Prevention', () => {
const mockUser = { _id: 'user123' };
const roomId = 'room123';
beforeEach(() => {
findOneByIdAndUserIdAndRoomId.reset();
updateFileMetadata.reset();
findOneByIdAndUserIdAndRoomId.resolves({ _id: 'file123' });
});
it('should filter out invalid types, nulls, and malicious fields before updating the database', async () => {
const maliciousFilePayload = {
_id: 'file123',
name: null, // invalid type, must be ignored
type: 'text/plain',
size: 1024,
description: 12345, // invalid type, must be ignored
typeGroup: 'image', // only valid field
content: null, // invalid type, must be ignored
maliciousRoleAssignment: 'admin', // mass assignment, must be ignored
$set: { bypassSecurity: true }, // mongo injection, must be ignored
};
await parseFileIntoMessageAttachments(maliciousFilePayload as any, roomId, mockUser as any);
expect(updateFileMetadata.calledOnce).to.equal(true);
const [fileId, userId, safeMetadata] = updateFileMetadata.getCall(0).args;
expect(fileId).to.equal('file123');
expect(userId).to.equal('user123');
expect(safeMetadata).to.deep.equal({
typeGroup: 'image',
});
});
it('should pass valid fields correctly to the database', async () => {
const validFilePayload = {
_id: 'file123',
name: 'picture.jpg',
type: 'image/jpeg',
size: 2048,
description: 'Description',
typeGroup: 'image',
content: {
algorithm: 'rc.v1.aes-sha2',
ciphertext: 'test',
},
};
await parseFileIntoMessageAttachments(validFilePayload as any, roomId, mockUser as any);
expect(updateFileMetadata.calledOnce).to.equal(true);
const [, , safeMetadata] = updateFileMetadata.getCall(0).args;
expect(safeMetadata).to.deep.equal({
name: 'picture.jpg',
description: 'Description',
typeGroup: 'image',
content: {
algorithm: 'rc.v1.aes-sha2',
ciphertext: 'test',
},
});
});
});

View File

@@ -0,0 +1,255 @@
import type {
MessageAttachment,
FileAttachmentProps,
IUser,
IUpload,
AtLeast,
FilesAndAttachments,
IMessage,
FileProp,
} from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Uploads, Users } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { executeSendMessage } from './sendMessage';
import { getFileExtension } from '../../../lib/utils/getFileExtension';
import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom';
import { callbacks } from '../../lib/callbacks';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { SystemLogger } from '../../lib/logger/system';
import { isImagePreviewSupported } from '../../lib/media/file-upload/isImagePreviewSupported';
import { FileUpload } from '../../lib/media/file-upload/lib/FileUpload';
function validateFileRequiredFields(file: Partial<IUpload>): asserts file is AtLeast<IUpload, '_id' | 'name' | 'type' | 'size'> {
const requiredFields = ['_id', 'name', 'type', 'size'];
requiredFields.forEach((field) => {
if (!Object.keys(file).includes(field)) {
throw new Meteor.Error('error-invalid-file', 'Invalid file');
}
});
}
export const parseFileIntoMessageAttachments = async (
file: Partial<IUpload>,
roomId: string,
user: IUser,
): Promise<FilesAndAttachments> => {
validateFileRequiredFields(file);
const upload = await Uploads.findOneByIdAndUserIdAndRoomId(file._id, user._id, roomId, { projection: { _id: 1 } });
if (!upload) {
throw new Meteor.Error('error-invalid-file', 'Invalid file', {
method: 'sendFileMessage',
});
}
const safeMetadata = {
...(typeof file.name === 'string' && { name: file.name }),
...(typeof file.description === 'string' && { description: file.description }),
...(typeof file.typeGroup === 'string' && { typeGroup: file.typeGroup }),
...(file.content && typeof file.content === 'object' && { content: file.content }),
};
await Uploads.updateFileMetadata(file._id, user._id, safeMetadata);
const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`);
const attachments: MessageAttachment[] = [];
const files: FileProp[] = [
{
_id: file._id,
name: file.name || '',
type: file.type || 'file',
size: file.size || 0,
format: file.identify?.format || '',
typeGroup: file.typeGroup,
},
];
if (isImagePreviewSupported(file.type as string)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
image_alt: file?.description,
title_link: fileUrl,
title_link_download: true,
image_url: fileUrl,
image_type: file.type as string,
image_size: file.size,
fileId: file._id,
};
if (file.identify?.size) {
attachment.image_dimensions = file.identify.size;
}
try {
attachment.image_preview = await FileUpload.resizeImagePreview(file);
const thumbResult = await FileUpload.createImageThumbnail(file);
if (thumbResult) {
const { data: thumbBuffer, width, height, thumbFileType, thumbFileName, originalFileId } = thumbResult;
const thumbnail = await FileUpload.uploadImageThumbnail(
{
thumbFileName,
thumbFileType,
originalFileId,
},
thumbBuffer,
roomId,
user._id,
);
const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`);
attachment.image_url = thumbUrl;
attachment.image_type = thumbnail.type;
attachment.image_dimensions = {
width,
height,
};
files.push({
_id: thumbnail._id,
name: thumbnail.name || '',
type: thumbnail.type || 'file',
size: thumbnail.size || 0,
format: thumbnail.identify?.format || '',
typeGroup: thumbnail.typeGroup || '',
});
}
} catch (err) {
SystemLogger.error({ err });
}
attachments.push(attachment);
} else if (/^audio\/.+/.test(file.type as string)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
audio_url: fileUrl,
audio_type: file.type as string,
audio_size: file.size,
fileId: file._id,
};
attachments.push(attachment);
} else if (/^video\/.+/.test(file.type as string)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
video_url: fileUrl,
video_type: file.type as string,
video_size: file.size as number,
fileId: file._id,
};
attachments.push(attachment);
} else {
const attachment = {
title: file.name,
type: 'file',
format: getFileExtension(file.name),
description: file.description,
title_link: fileUrl,
title_link_download: true,
size: file.size as number,
fileId: file._id,
};
attachments.push(attachment);
}
return { files, attachments };
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
sendFileMessage: (roomId: string, _store: string, file: Partial<IUpload>, msgData?: Record<string, any>) => boolean;
}
}
export const sendFileMessage = async (
userId: string,
{
roomId,
file,
msgData,
}: {
roomId: string;
file: Partial<IUpload>;
msgData?: Record<string, any>;
},
): Promise<boolean> => {
const user = await Users.findOneById(userId, { projection: { services: 0 } });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'sendFileMessage',
} as any);
}
const room = await Rooms.findOneById(roomId);
if (!room) {
return false;
}
if (user?.type !== 'app' && !(await canAccessRoomAsync(room, user))) {
return false;
}
check(
msgData,
Match.Maybe({
avatar: Match.Optional(String),
emoji: Match.Optional(String),
alias: Match.Optional(String),
groupable: Match.Optional(Boolean),
msg: Match.Optional(String),
tmid: Match.Optional(String),
customFields: Match.Optional(String),
t: Match.Optional(String),
content: Match.Optional(
Match.ObjectIncluding({
algorithm: String,
ciphertext: String,
}),
),
}),
);
const data = {
rid: roomId,
ts: new Date(),
...(msgData as Partial<IMessage>),
...(msgData?.customFields && { customFields: JSON.parse(msgData.customFields) }),
msg: msgData?.msg ?? '',
groupable: msgData?.groupable ?? false,
};
const { files, attachments } = await parseFileIntoMessageAttachments(file, roomId, user);
data.file = files[0];
data.files = files;
data.attachments = attachments;
const msg = await executeSendMessage(userId, data);
callbacks.runAsync('afterFileUpload', { user, room, message: msg });
return msg;
};
Meteor.methods<ServerMethods>({
async sendFileMessage(roomId, _store, file, msgData = {}) {
methodDeprecationLogger.method('sendFileMessage', '9.0.0', '/v1/rooms.mediaConfirm/:rid/:fileId');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'sendFileMessage',
} as any);
}
return sendFileMessage(userId, { roomId, file, msgData });
},
});

View File

@@ -0,0 +1,187 @@
import { api } from '@rocket.chat/core-services';
import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import type { RocketchatI18nKeys } from '@rocket.chat/i18n';
import { MessageTypes } from '@rocket.chat/message-types';
import { Messages, Users } from '@rocket.chat/models';
import type { TOptions } from 'i18next';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { canSendMessageAsync } from '../../lib/authorization/canSendMessage';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { i18n } from '../../lib/i18n';
import { SystemLogger } from '../../lib/logger/system';
import { sendMessage } from '../../lib/messages/sendMessage';
import { metrics } from '../../lib/metrics';
import { settings } from '../../settings';
/**
*
* @param uid
* @param message
* @param extraInfo
* - ts: The timestamp of the message. the message object already has a ts, but this value is validated and only a window of 10 seconds is allowed to be used. this value overrides the message.ts value without validation.
*
*
* @returns
*/
export async function executeSendMessage(
uid: IUser['_id'] | IUser,
message: AtLeast<IMessage, 'rid'>,
extraInfo?: { ts?: Date; previewUrls?: string[] },
) {
if (message.tshow && !message.tmid) {
throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', {
method: 'sendMessage',
});
}
if (message.tmid && !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', {
method: 'sendMessage',
});
}
const isTimestampFromClient = Boolean(!extraInfo?.ts && message.ts);
const now = new Date();
message.ts = extraInfo?.ts ?? message.ts ?? now;
if (isTimestampFromClient) {
const tsDiff = Math.abs(moment(message.ts).diff(Date.now()));
if (tsDiff > 60000) {
throw new Meteor.Error('error-message-ts-out-of-sync', 'Message timestamp is out of sync', {
method: 'sendMessage',
message_ts: message.ts,
server_ts: new Date().getTime(),
});
}
if (tsDiff > 10000) {
message.ts = now;
}
}
if (message.msg) {
if (message.msg.length > (settings.get<number>('Message_MaxAllowedSize') ?? 0)) {
throw new Meteor.Error('error-message-size-exceeded', 'Message size exceeds Message_MaxAllowedSize', {
method: 'sendMessage',
});
}
}
const user = typeof uid === 'string' ? await Users.findOneById(uid) : uid;
if (!user?.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
let { rid } = message;
// do not allow nested threads
if (message.tmid) {
const parentMessage = await Messages.findOneById(message.tmid, { projection: { rid: 1, tmid: 1 } });
message.tmid = parentMessage?.tmid || message.tmid;
if (parentMessage?.rid) {
rid = parentMessage?.rid;
}
}
if (!rid) {
throw new Error("The 'rid' property on the message object is missing.");
}
check(rid, String);
try {
const room = await canSendMessageAsync(rid, user);
if (room.encrypted && settings.get<boolean>('E2E_Enable') && !settings.get<boolean>('E2E_Allow_Unencrypted_Messages')) {
if (message.t !== 'e2e') {
throw new Meteor.Error('error-not-allowed', 'Not allowed to send un-encrypted messages in an encrypted room', {
method: 'sendMessage',
});
}
}
const result = await sendMessage(user, message, room, { previewUrls: extraInfo?.previewUrls });
metrics.messagesSent.inc();
metrics.messagesSentTotal.inc();
return result;
} catch (err: any) {
SystemLogger.error({ msg: 'Error sending message:', err });
const errorMessage: RocketchatI18nKeys = typeof err === 'string' ? err : err.error || err.message;
const errorContext: TOptions = err.details ?? {};
void api.broadcast('notify.ephemeralMessage', user._id, message.rid, {
msg: i18n.t(errorMessage, { ...errorContext, lng: user.language }),
});
if (typeof err === 'string') {
throw new Error(err);
}
throw err;
}
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
sendMessage(message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>, previewUrls?: string[]): any;
}
}
Meteor.methods<ServerMethods>({
async sendMessage(message, previewUrls) {
methodDeprecationLogger.method('sendMessage', '9.0.0', '/v1/chat.sendMessage');
check(message, {
_id: Match.Maybe(String),
rid: Match.Maybe(String),
msg: Match.Maybe(String),
tmid: Match.Maybe(String),
tshow: Match.Maybe(Boolean),
ts: Match.Maybe(Date),
t: Match.Maybe(String),
bot: Match.Maybe(Object),
content: Match.Maybe(Object),
e2e: Match.Maybe(String),
e2eMentions: Match.Maybe(Object),
customFields: Match.Maybe(Object),
federation: Match.Maybe(Object),
groupable: Match.Maybe(Boolean),
sentByEmail: Match.Maybe(Boolean),
});
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'sendMessage',
});
}
if (MessageTypes.isSystemMessage(message)) {
throw new Error("Cannot send system messages using 'sendMessage'");
}
try {
return await applyAirGappedRestrictionsValidation(() => executeSendMessage(user, message, { previewUrls }));
} catch (error: any) {
if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) {
throw new Meteor.Error(error.error || error.message, error.reason, {
method: 'sendMessage',
});
}
}
},
});
// Limit a user, who does not have the "bot" role, to sending 5 msgs/second
RateLimiter.limitMethod('sendMessage', 5, 1000, {
async userId(userId: IUser['_id']) {
return !(await hasPermissionAsync(userId, 'send-many-messages'));
},
});

View File

@@ -0,0 +1,70 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { unfollow } from '../../lib/messaging/threads/functions';
import { notifyOnMessageChange } from '../../lib/notifyListener';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
unfollowMessage(message: { mid: IMessage['_id'] }): false | undefined;
}
}
export const unfollowMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise<false | undefined> => {
if (mid && !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' });
}
const message = await Messages.findOneById(mid);
if (!message) {
throw new Meteor.Error('error-invalid-message', 'Invalid message', {
method: 'unfollowMessage',
});
}
if (!(await canAccessRoomIdAsync(message.rid, user._id))) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' });
}
const id = message.tmid || message._id;
const unfollowResult = await unfollow({ rid: message.rid, tmid: id, uid: user._id });
void notifyOnMessageChange({
id,
});
const isFollowed = false;
await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed);
return unfollowResult;
};
Meteor.methods<ServerMethods>({
async unfollowMessage({ mid }) {
methodDeprecationLogger.method('unfollowMessage', '9.0.0', '/v1/chat.unfollowMessage');
check(mid, String);
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unfollowMessage' });
}
return unfollowMessage(user, { mid });
},
});
RateLimiter.limitMethod('unfollowMessage', 5, 5000, {
userId() {
return true;
},
});

View File

@@ -0,0 +1,121 @@
import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Users } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { canSendMessageAsync } from '../../lib/authorization/canSendMessage';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper';
import { updateMessage } from '../../lib/messages/updateMessage';
import { settings } from '../../settings';
const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content', 'e2eMentions'];
export async function executeUpdateMessage(
uid: IUser['_id'],
message: AtLeast<IMessage, '_id' | 'rid' | 'msg' | 'customFields'> | AtLeast<IMessage, '_id' | 'rid' | 'content'>,
previewUrls?: string[],
) {
const originalMessage = await Messages.findOneById(message._id);
if (!originalMessage?._id) {
return;
}
Object.entries(message).forEach(([key, value]) => {
if (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) {
throw new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, {
method: 'updateMessage',
});
}
});
// IF the message has custom fields, always update
// Ideally, we'll compare the custom fields to check for change, but since we don't know the shape of
// custom fields, as it's user defined, we're gonna update
const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg;
if (msgText === message.msg && !previewUrls && !message.customFields) {
return;
}
if (!!message.tmid && originalMessage._id === message.tmid) {
throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', {
method: 'updateMessage',
});
}
if (!originalMessage.tmid && !!message.tmid) {
throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' });
}
const _hasPermission = await hasPermissionAsync(uid, 'edit-message', message.rid);
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = originalMessage.u && originalMessage.u._id === uid;
if (!_hasPermission && (!editAllowed || !editOwn)) {
throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', {
method: 'updateMessage',
action: 'Message_editing',
});
}
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes');
const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete', message.rid);
if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) {
let currentTsDiff = 0;
let msgTs;
if (originalMessage.ts instanceof Date || Match.test(originalMessage.ts, Number)) {
msgTs = moment(originalMessage.ts);
}
if (msgTs) {
currentTsDiff = moment().diff(msgTs, 'minutes');
}
if (currentTsDiff >= blockEditInMinutes) {
throw new Meteor.Error('error-message-editing-blocked', 'Message editing is blocked', {
method: 'updateMessage',
});
}
}
const user = await Users.findOneById(uid);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user });
// It is possible to have an empty array as the attachments property, so ensure both things exist
if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) {
originalMessage.attachments[0].description = message.msg;
message.attachments = originalMessage.attachments;
message.msg = originalMessage.msg;
}
message.u = originalMessage.u;
return updateMessage(message, user, originalMessage, previewUrls);
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
updateMessage(message: IEditedMessage, previewUrls?: string[]): void;
}
}
Meteor.methods<ServerMethods>({
async updateMessage(message: IEditedMessage, previewUrls?: string[]) {
check(message, Match.ObjectIncluding({ _id: String }));
check(previewUrls, Match.Maybe([String]));
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}
return applyAirGappedRestrictionsValidation(() => executeUpdateMessage(uid, message, previewUrls));
},
});

View File

@@ -0,0 +1,97 @@
import type {
FileAttachmentProps,
ImageAttachmentProps,
AudioAttachmentProps,
VideoAttachmentProps,
IUpload,
} from '@rocket.chat/core-typings';
import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Match, check } from 'meteor/check';
import { sendMessageLivechat } from './sendMessageLivechat';
import { FileUpload } from '../../lib/media/file-upload';
interface ISendFileLivechatMessage {
roomId: string;
visitorToken: string;
file: IUpload;
msgData?: { avatar?: string; emoji?: string; alias?: string; groupable?: boolean; msg?: string };
}
export const sendFileLivechatMessage = async ({ roomId, visitorToken, file, msgData = {} }: ISendFileLivechatMessage): Promise<boolean> => {
const visitor = await LivechatVisitors.getVisitorByToken(visitorToken);
if (!visitor) {
return false;
}
const room = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(roomId, visitorToken);
if (!room) {
return false;
}
check(msgData, {
avatar: Match.Optional(String),
emoji: Match.Optional(String),
alias: Match.Optional(String),
groupable: Match.Optional(Boolean),
msg: Match.Optional(String),
});
const fileUrl = file.name && FileUpload.getPath(`${file._id}/${encodeURI(file.name)}`);
const attachment: Partial<FileAttachmentProps> = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
};
if (file.type && /^image\/.+/.test(file.type)) {
(attachment as ImageAttachmentProps).image_url = fileUrl || '';
(attachment as ImageAttachmentProps).image_type = file.type;
(attachment as ImageAttachmentProps).image_size = file.size;
if (file.identify?.size) {
(attachment as ImageAttachmentProps).image_dimensions = file.identify.size;
}
(attachment as ImageAttachmentProps).image_preview = await FileUpload.resizeImagePreview(file);
} else if (file.type && /^audio\/.+/.test(file.type)) {
(attachment as AudioAttachmentProps).audio_url = fileUrl || '';
(attachment as AudioAttachmentProps).audio_type = file.type;
(attachment as AudioAttachmentProps).audio_size = file.size;
} else if (file.type && /^video\/.+/.test(file.type)) {
(attachment as VideoAttachmentProps).video_url = fileUrl || '';
(attachment as VideoAttachmentProps).video_type = file.type;
(attachment as VideoAttachmentProps).video_size = file.size || 0;
}
const msg = Object.assign(
{
_id: Random.id(),
rid: roomId,
ts: new Date(),
msg: '',
file: {
_id: file._id,
name: file.name,
type: file.type,
},
files: [
{
_id: file._id,
name: file.name,
type: file.type,
},
],
groupable: false,
attachments: [attachment],
token: visitorToken,
},
msgData,
);
return sendMessageLivechat({ message: msg });
};

View File

@@ -0,0 +1,82 @@
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { LivechatVisitors } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import type { ILivechatMessage } from '../../lib/omnichannel/localTypes';
import { sendMessage } from '../../lib/omnichannel/messages';
import { settings } from '../../settings';
interface ILivechatMessageAgent {
agentId: string;
username: string;
}
interface ISendMessageLivechat {
message: ILivechatMessage;
agent?: ILivechatMessageAgent;
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
sendMessageLivechat(message: ILivechatMessage, agent: ILivechatMessageAgent): boolean;
}
}
export const sendMessageLivechat = async ({
message: { token, _id, rid, msg, file, files, attachments },
agent,
}: ISendMessageLivechat): Promise<boolean> => {
check(token, String);
check(_id, String);
check(rid, String);
check(msg, String);
check(
agent,
Match.Maybe({
agentId: String,
username: String,
}),
);
// No projection: the guest is forwarded into the message-sending pipeline, which expects a full visitor.
const guest = await LivechatVisitors.getVisitorByToken(token);
if (!guest) {
throw new Meteor.Error('invalid-token');
}
if (settings.get('Livechat_enable_message_character_limit') && msg.length > parseInt(settings.get('Livechat_message_character_limit'))) {
throw new Meteor.Error('message-length-exceeds-character-limit');
}
return sendMessage({
guest,
message: {
_id,
rid,
msg,
token,
file,
files,
attachments,
},
agent,
roomInfo: {
source: {
type: OmnichannelSourceType.API,
},
},
});
};
Meteor.methods<ServerMethods>({
async sendMessageLivechat({ token, _id, rid, msg, file, files, attachments }: ILivechatMessage, agent: ILivechatMessageAgent) {
methodDeprecationLogger.method('sendMessageLivechat', '9.0.0', '/v1/livechat/message');
return sendMessageLivechat({ message: { token, _id, rid, msg, file, files, attachments }, agent });
},
});

View File

@@ -0,0 +1,36 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { OEmbedCache } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
OEmbedCacheCleanup(): { message: string };
}
}
export const executeClearOEmbedCache = async () => {
const date = new Date();
const expirationDays = settings.get<number>('API_EmbedCacheExpirationDays');
date.setDate(date.getDate() - expirationDays);
return OEmbedCache.removeBeforeDate(date);
};
Meteor.methods<ServerMethods>({
async OEmbedCacheCleanup() {
const uid = Meteor.userId();
if (!uid || !(await hasPermissionAsync(uid, 'clear-oembed-cache'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'OEmbedCacheCleanup',
});
}
await executeClearOEmbedCache();
return {
message: 'cache_cleared',
};
},
});

View File

@@ -0,0 +1,33 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { notifyOnUserChange } from '../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'banner/dismiss'({ id }: { id: string }): void;
}
}
Meteor.methods<ServerMethods>({
async 'banner/dismiss'({ id }) {
methodDeprecationLogger.method('banner/dismiss', '9.0.0', '/v1/banners.dismiss');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'banner/dismiss' });
}
await Users.setBannerReadById(userId, id);
void notifyOnUserChange({
id: userId,
clientAction: 'updated',
diff: {
[`banners.${id}.read`]: true,
},
});
},
});

View File

@@ -0,0 +1,84 @@
import { Federation, FederationEE, Authorization } from '@rocket.chat/core-services';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { License } from '@rocket.chat/license';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
checkFederationConfiguration(): Promise<{ message: string }>;
}
}
Meteor.methods<ServerMethods>({
async checkFederationConfiguration() {
methodDeprecationLogger.method('checkFederationConfiguration', '9.0.0', []);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'checkFederationConfiguration',
});
}
if (!(await Authorization.hasPermission(uid, 'view-privileged-setting'))) {
throw new Meteor.Error('error-not-allowed', 'Action not allowed', {
method: 'checkFederationConfiguration',
});
}
const errors: string[] = [];
const successes: string[] = [];
const service = License.hasValidLicense() ? FederationEE : Federation;
const status = await service.configurationStatus();
if (status.externalReachability.ok) {
successes.push('homeserver configuration looks good');
} else {
let err = 'external reachability could not be verified';
const { error } = status.externalReachability;
if (error) {
err += `, error: ${error}`;
}
errors.push(err);
}
const {
roundTrip: { durationMs: duration },
} = status.appservice;
if (status.appservice.ok) {
successes.push(`appservice configuration looks good, total round trip time to homeserver ${duration}ms`);
} else {
errors.push(`failed to verify appservice configuration: ${status.appservice.error}`);
}
if (errors.length) {
void service.markConfigurationInvalid();
if (successes.length) {
const message = ['Configuration could only be partially verified'].concat(successes).concat(errors).join(', ');
throw new Meteor.Error('error-invalid-configuration', message, { method: 'checkFederationConfiguration' });
}
throw new Meteor.Error('error-invalid-configuration', ['Invalid configuration'].concat(errors).join(', '), {
method: 'checkFederationConfiguration',
});
}
void service.markConfigurationValid();
return {
message: ['All configuration looks good'].concat(successes).join(', '),
};
},
});

View File

@@ -0,0 +1,214 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { buildWorkspaceRegistrationData } from '../../lib/cloud/buildRegistrationData';
import { checkUserHasCloudLogin } from '../../lib/cloud/checkUserHasCloudLogin';
import { connectWorkspace } from '../../lib/cloud/connectWorkspace';
import { finishOAuthAuthorization } from '../../lib/cloud/finishOAuthAuthorization';
import { getOAuthAuthorizationUrl } from '../../lib/cloud/getOAuthAuthorizationUrl';
import { retrieveRegistrationStatus } from '../../lib/cloud/retrieveRegistrationStatus';
import { startRegisterWorkspace } from '../../lib/cloud/startRegisterWorkspace';
import { syncWorkspace } from '../../lib/cloud/syncWorkspace';
import { userLogout } from '../../lib/cloud/userLogout';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'cloud:checkRegisterStatus': () => {
workspaceRegistered: boolean;
workspaceId: string;
uniqueId: string;
token: string;
email: string;
};
'cloud:getWorkspaceRegisterData': () => string;
'cloud:registerWorkspace': () => boolean;
'cloud:syncWorkspace': () => boolean;
'cloud:connectWorkspace': (token: string) => boolean | Error;
'cloud:getOAuthAuthorizationUrl': () => string;
'cloud:finishOAuthAuthorization': (code: string, state: string) => boolean;
'cloud:checkUserLoggedIn': () => boolean;
'cloud:logout': () => Promise<boolean | string>;
}
}
Meteor.methods<ServerMethods>({
/**
* @deprecated this method is deprecated and will be removed soon.
* Prefer using cloud.registrationStatus rest api.
*/
async 'cloud:checkRegisterStatus'() {
methodDeprecationLogger.method('cloud:checkRegisterStatus', '9.0.0', '/v1/cloud.registrationStatus');
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:checkRegisterStatus',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:checkRegisterStatus',
});
}
return retrieveRegistrationStatus();
},
/**
* @deprecated this method is deprecated and will be removed soon.
* Prefer using cloud.workspaceRegisterData rest api.
*/
async 'cloud:getWorkspaceRegisterData'() {
methodDeprecationLogger.method('cloud:getWorkspaceRegisterData', '9.0.0', '/v1/cloud.workspaceRegisterData');
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:getWorkspaceRegisterData',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:getWorkspaceRegisterData',
});
}
return Buffer.from(JSON.stringify(await buildWorkspaceRegistrationData(undefined))).toString('base64');
},
async 'cloud:registerWorkspace'() {
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:registerWorkspace',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:registerWorkspace',
});
}
return startRegisterWorkspace();
},
async 'cloud:syncWorkspace'() {
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:syncWorkspace',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:syncWorkspace',
});
}
await syncWorkspace();
return true;
},
async 'cloud:connectWorkspace'(token) {
methodDeprecationLogger.method('cloud:connectWorkspace', '9.0.0', '/v1/cloud.connectWorkspace');
check(token, String);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:connectWorkspace',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:connectWorkspace',
});
}
if (!token) {
throw new Meteor.Error('error-invalid-payload', 'Token is required.', {
method: 'cloud:connectWorkspace',
});
}
return connectWorkspace(token);
},
// Currently unused but will link local account to Rocket.Chat Cloud account.
async 'cloud:getOAuthAuthorizationUrl'() {
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:getOAuthAuthorizationUrl',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:getOAuthAuthorizationUrl',
});
}
return getOAuthAuthorizationUrl();
},
async 'cloud:finishOAuthAuthorization'(code, state) {
check(code, String);
check(state, String);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:finishOAuthAuthorization',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:finishOAuthAuthorization',
});
}
return finishOAuthAuthorization(code, state);
},
async 'cloud:checkUserLoggedIn'() {
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:checkUserLoggedIn',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:checkUserLoggedIn',
});
}
return checkUserHasCloudLogin(uid);
},
async 'cloud:logout'() {
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'cloud:logout',
});
}
if (!(await hasPermissionAsync(uid, 'manage-cloud'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'cloud:logout',
});
}
return userLogout(uid);
},
});

View File

@@ -0,0 +1,23 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'e2e.fetchMyKeys'(): { public_key?: string; private_key?: string };
}
}
Meteor.methods<ServerMethods>({
async 'e2e.fetchMyKeys'() {
methodDeprecationLogger.method('e2e.fetchMyKeys', '9.0.0', '/v1/e2e.fetchMyKeys');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.fetchMyKeys' });
}
return Users.fetchKeysByUserId(userId);
},
});

View File

@@ -0,0 +1,28 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { TranslationProviderRegistry } from '../../lib/autotranslate/autotranslate';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'autoTranslate.getProviderUiMetadata'(): Record<string, { name: string; displayName: string }>;
}
}
Meteor.methods<ServerMethods>({
'autoTranslate.getProviderUiMetadata'() {
if (!Meteor.userId()) {
throw new Meteor.Error('error-action-not-allowed', 'Login neccessary', {
method: 'autoTranslate.getProviderUiMetadata',
});
}
return Object.fromEntries(
TranslationProviderRegistry.getProviders().map((provider) => {
const { name, displayName } = provider._getProviderMetadata();
return [name, { name, displayName }];
}),
);
},
});

View File

@@ -0,0 +1,27 @@
import type { IStats } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { getLastStatistics } from '../../lib/statistics/functions/getLastStatistics';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getStatistics(refresh?: boolean): IStats;
}
}
Meteor.methods<ServerMethods>({
async getStatistics(refresh) {
methodDeprecationLogger.method('getStatistics', '9.0.0', '/v1/statistics');
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getStatistics' });
}
return getLastStatistics({
userId: uid,
refresh,
});
},
});

View File

@@ -0,0 +1,39 @@
import type { ISupportedLanguage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
import { Meteor } from 'meteor/meteor';
import { getSupportedLanguages } from '../../lib/autotranslate/functions/getSupportedLanguages';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'autoTranslate.getSupportedLanguages'(targetLanguage: string): ISupportedLanguage[] | undefined;
}
}
Meteor.methods<ServerMethods>({
async 'autoTranslate.getSupportedLanguages'(targetLanguage) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getSupportedLanguages',
});
}
return getSupportedLanguages(userId, targetLanguage);
},
});
DDPRateLimiter.addRule(
{
type: 'method',
name: 'autoTranslate.getSupportedLanguages',
userId(/* userId*/) {
return true;
},
},
5,
60000,
);

View File

@@ -0,0 +1,56 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Users } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'e2e.getUsersOfRoomWithoutKey'(rid: IRoom['_id']): { users: Pick<IUser, '_id' | 'e2e'>[] };
}
}
export const getUsersOfRoomWithoutKeyMethod = async (
userId: string,
rid: IRoom['_id'],
): Promise<{ users: Pick<IUser, '_id' | 'e2e'>[] }> => {
if (!(await canAccessRoomIdAsync(rid, userId))) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.getUsersOfRoomWithoutKey' });
}
const subscriptions = await Subscriptions.findByRidWithoutE2EKey(rid, {
projection: { 'u._id': 1 },
}).toArray();
const userIds = subscriptions.map((s) => s.u._id);
const options = { projection: { 'e2e.public_key': 1 } };
const users = await Users.findByIdsWithPublicE2EKey(userIds, options).toArray();
return {
users,
};
};
Meteor.methods<ServerMethods>({
async 'e2e.getUsersOfRoomWithoutKey'(rid) {
check(rid, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'e2e.getUsersOfRoomWithoutKey',
});
}
if (!rid) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'e2e.getUsersOfRoomWithoutKey',
});
}
return getUsersOfRoomWithoutKeyMethod(userId, rid);
},
});

View File

@@ -0,0 +1,24 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { getMomentLocale } from '../../lib/getMomentLocale';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
loadLocale(locale: string): string | undefined;
}
}
Meteor.methods<ServerMethods>({
loadLocale(locale) {
check(locale, String);
try {
return getMomentLocale(locale);
} catch (error: any) {
throw new Meteor.Error(error.message, `Moment locale not found: ${locale}`);
}
},
});

View File

@@ -0,0 +1,80 @@
import { Push } from '@rocket.chat/core-services';
import type { IPushToken } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { PushToken } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { logger } from '../../lib/notifications/push/logger';
import { _matchToken } from '../../lib/notifications/push/push';
type PushUpdateOptions = {
id?: string;
token: IPushToken['token'];
authToken: string;
appName: string;
userId: string | null;
metadata?: Record<string, unknown>;
};
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'raix:push-update'(options: PushUpdateOptions): Promise<Omit<IPushToken, 'authToken'>>;
'raix:push-setuser'(options: { id: string; userId: string }): Promise<boolean>;
}
}
Meteor.methods<ServerMethods>({
async 'raix:push-update'(options) {
methodDeprecationLogger.method('raix:push-update', '9.0.0', '/v1/push.token');
logger.debug({ msg: 'Got push token from app', options });
check(options, {
id: Match.Optional(String),
token: _matchToken,
authToken: String,
appName: String,
userId: Match.OneOf(String, null),
metadata: Match.Optional(Object),
});
// The if user id is set then user id should match on client and connection
if (!this.userId || (options.userId && options.userId !== this.userId)) {
throw new Meteor.Error(403, 'Forbidden access');
}
// Retain old behavior: if id is not specified but userId is explicitly set, then update the user's first token
if (!options.id && options.userId) {
const firstDoc = await PushToken.findFirstByUserId(options.userId, { projection: { _id: 1 } });
if (firstDoc) {
options.id = firstDoc._id;
}
}
const authToken = Accounts._hashLoginToken(options.authToken);
return Push.registerPushToken({
...(options.id && { _id: options.id }),
token: options.token,
appName: options.appName,
authToken,
userId: this.userId,
...(options.metadata && { metadata: options.metadata }),
});
},
async 'raix:push-setuser'(id) {
methodDeprecationLogger.method('raix:push-setuser', '9.0.0', []);
check(id, String);
if (!this.userId) {
throw new Meteor.Error(403, 'Forbidden access');
}
logger.debug({ msg: 'Setting userId for app', userId: this.userId, appId: id });
const found = await PushToken.updateOne({ _id: id }, { $set: { userId: this.userId } });
return !!found;
},
});

View File

@@ -0,0 +1,123 @@
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path, { join } from 'node:path';
import type { IExportOperation, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { ExportOperations, UserDataFiles } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import * as dataExport from '../../lib/dataExport';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
requestDataDownload(params: { fullExport?: boolean }): Promise<{
requested: boolean;
exportOperation: IExportOperation;
url: string | null;
pendingOperationsBeforeMyRequest: number;
}>;
}
}
export const requestDataDownload = async ({
userData,
fullExport = false,
}: {
userData: IUser;
fullExport?: boolean;
}): Promise<{
requested: boolean;
exportOperation: IExportOperation;
url: string | null;
pendingOperationsBeforeMyRequest: number;
}> => {
const currentUserData = userData;
if (!currentUserData) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
const userId = currentUserData._id;
const lastOperation = await ExportOperations.findLastOperationByUser(userId, fullExport);
const requestDay = lastOperation ? lastOperation.createdAt : new Date();
const pendingOperationsBeforeMyRequestCount = await ExportOperations.countAllPendingBeforeMyRequest(requestDay);
if (lastOperation) {
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
if (lastOperation.createdAt > yesterday) {
if (lastOperation.status === 'completed') {
const file = lastOperation.fileId
? await UserDataFiles.findOneById(lastOperation.fileId)
: await UserDataFiles.findLastFileByUser(userId);
if (file) {
return {
requested: false,
exportOperation: lastOperation,
url: dataExport.getPath(file._id),
pendingOperationsBeforeMyRequest: pendingOperationsBeforeMyRequestCount,
};
}
}
return {
requested: false,
exportOperation: lastOperation,
url: null,
pendingOperationsBeforeMyRequest: pendingOperationsBeforeMyRequestCount,
};
}
}
const tempFolder = settings.get<string | undefined>('UserData_FileSystemPath')?.trim() || (await mkdtemp(join(tmpdir(), 'userData')));
const exportOperation = {
status: 'preparing',
userId: currentUserData._id,
roomList: undefined,
fileList: [],
generatedFile: undefined,
fullExport,
userData: currentUserData,
userNameTable: {},
} as unknown as IExportOperation; // @todo yikes!
const id = await ExportOperations.create(exportOperation);
exportOperation._id = id;
const folderName = path.join(tempFolder, id);
const assetsFolder = path.join(folderName, 'assets');
exportOperation.exportPath = folderName;
exportOperation.assetsPath = assetsFolder;
exportOperation.status = 'pending';
await ExportOperations.updateOperation(exportOperation);
return {
requested: true,
exportOperation,
url: null,
pendingOperationsBeforeMyRequest: pendingOperationsBeforeMyRequestCount,
};
};
Meteor.methods<ServerMethods>({
async requestDataDownload({ fullExport = false }) {
methodDeprecationLogger.method('requestDataDownload', '9.0.0', '/v1/users.requestDataDownload');
const currentUserData = await Meteor.userAsync();
if (!currentUserData) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
return requestDataDownload({ userData: currentUserData as IUser, fullExport });
},
});

View File

@@ -0,0 +1,51 @@
import { api } from '@rocket.chat/core-services';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'e2e.requestSubscriptionKeys'(): boolean;
}
}
export const requestSubscriptionKeysMethod = async (userId: string): Promise<void> => {
// Get all encrypted rooms that the user is subscribed to and has no E2E key yet
const subscriptions = await Subscriptions.findByUserIdWithoutE2E(userId).toArray();
const roomIds = subscriptions.map((subscription) => subscription.rid);
// For all subscriptions without E2E key, get the rooms that have encryption enabled
const query = {
e2eKeyId: {
$exists: true,
},
_id: {
$in: roomIds,
},
};
const rooms = Rooms.find(query);
await rooms.forEach((room) => {
void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId);
});
};
Meteor.methods<ServerMethods>({
async 'e2e.requestSubscriptionKeys'() {
methodDeprecationLogger.method('e2e.requestSubscriptionKeys', '9.0.0', '/v1/e2e.requestSubscriptionKeys');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'requestSubscriptionKeys',
});
}
await requestSubscriptionKeysMethod(userId);
return true;
},
});

View File

@@ -0,0 +1,34 @@
import { MeteorError } from '@rocket.chat/core-services';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { twoFactorRequired } from '../../lib/2fa/twoFactorRequired';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { resetUserE2EEncriptionKey } from '../../lib/resetUserE2EKey';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'e2e.resetOwnE2EKey'(): Promise<boolean>;
}
}
Meteor.methods<ServerMethods>({
'e2e.resetOwnE2EKey': twoFactorRequired(async () => {
methodDeprecationLogger.method('e2e.resetOwnE2EKey', '9.0.0', '/v1/users.resetE2EKey');
const userId = Meteor.userId();
if (!userId) {
throw new MeteorError('error-invalid-user', 'Invalid user', {
method: 'resetOwnE2EKey',
});
}
if (!(await resetUserE2EEncriptionKey(userId, false))) {
throw new MeteorError('failed-reset-e2e-password', 'Failed to reset E2E password', {
method: 'resetOwnE2EKey',
});
}
return true;
}),
});

View File

@@ -0,0 +1,41 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
restart_server(): {
message: string;
params: [number];
};
}
}
Meteor.methods<ServerMethods>({
async restart_server() {
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'restart_server' });
}
if ((await hasPermissionAsync(uid, 'restart-server')) !== true) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'restart_server' });
}
setTimeout(() => {
setTimeout(() => {
console.warn('Call to process.exit() timed out, aborting.');
process.abort();
}, 1000);
process.exit(1);
}, 1000);
return {
message: 'The_server_will_restart_in_s_seconds',
params: [2],
};
},
});

View File

@@ -0,0 +1,26 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { saveAutoTranslateSettings } from '../../lib/autotranslate/functions/saveSettings';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'autoTranslate.saveSettings'(rid: string, field: string, value: string, options: { defaultLanguage: string }): boolean;
}
}
Meteor.methods<ServerMethods>({
async 'autoTranslate.saveSettings'(rid, field, value, options) {
methodDeprecationLogger.method('autoTranslate.saveSettings', '9.0.0', '/v1/autotranslate.saveSettings');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'saveAutoTranslateSettings',
});
}
return saveAutoTranslateSettings(userId, rid, field, value, options);
},
});

View File

@@ -0,0 +1,94 @@
import type { IMessageSearchProvider, IMessageSearchSuggestion, IRoom, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { SearchLogger } from '../../lib/search/logger/logger';
import type { IRawSearchResult, ISearchResult } from '../../lib/search/model/ISearchResult';
import { searchProviderService, validationService } from '../../lib/search/service';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'rocketchatSearch.getProvider'(): IMessageSearchProvider | undefined;
'rocketchatSearch.search'(text: string, context: { uid?: IUser['_id']; rid: IRoom['_id'] }, payload: unknown): Promise<ISearchResult>;
'rocketchatSearch.suggest'(
text: string,
context: { uid?: IUser['_id']; rid: IRoom['_id'] },
payload: unknown,
): Promise<IMessageSearchSuggestion[]>;
}
}
Meteor.methods<ServerMethods>({
/**
* Get the current provider with key, description, resultTemplate, suggestionItemTemplate and settings (as Map)
*/
'rocketchatSearch.getProvider'() {
const provider = searchProviderService.activeProvider;
if (!provider) {
return undefined;
}
return {
key: provider.key,
description: provider.i18nDescription,
icon: provider.iconName,
resultTemplate: provider.resultTemplate,
supportsSuggestions: provider.supportsSuggestions,
suggestionItemTemplate: provider.suggestionItemTemplate,
settings: Object.fromEntries(Object.values(provider.settingsAsMap).map((setting) => [setting.key, setting.value] as const)),
};
},
/**
* Search using the current search provider and check if results are valid for the user. The search result has
* the format `{messages:{start:0,numFound:1,docs:[{...}]},users:{...},rooms:{...}}`
* @param text the search text
* @param context the context (uid, rid)
* @param payload custom payload (e.g. for paging)
*/
async 'rocketchatSearch.search'(text, context, payload) {
payload = payload !== null ? payload : undefined; // TODO is this cleanup necessary?
if (!searchProviderService.activeProvider) {
throw new Error('Provider currently not active');
}
SearchLogger.debug({ msg: 'search', text, context, payload });
const userId = Meteor.userId();
if (!userId) {
throw new Error('User not logged in');
}
return new Promise<IRawSearchResult>((resolve, reject) => {
void searchProviderService.activeProvider?.search(userId, text, context, payload, (error, data) => {
if (error) {
return reject(error);
}
return resolve(data);
});
}).then((result) => validationService.validateSearchResult(result));
},
async 'rocketchatSearch.suggest'(text, context, payload) {
payload ??= undefined; // TODO is this cleanup necessary?
if (!searchProviderService.activeProvider) {
throw new Error('Provider currently not active');
}
SearchLogger.debug({ msg: 'suggest', text, context, payload });
return new Promise((resolve, reject) => {
searchProviderService.activeProvider?.suggest(text, context, payload, (error, data) => {
if (error) {
reject(error);
return;
}
resolve(data);
});
});
},
});

View File

@@ -0,0 +1,49 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { notifyOnRoomChanged } from '../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'e2e.setRoomKeyID'(rid: IRoom['_id'], keyID: string): void;
}
}
export const setRoomKeyIDMethod = async (userId: string, rid: IRoom['_id'], keyID: string): Promise<void> => {
if (!(await canAccessRoomIdAsync(rid, userId))) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' });
}
const room = await Rooms.setE2eKeyId(rid, keyID);
if (!room) {
throw new Meteor.Error('error-room-e2e-key-already-exists', 'E2E Key ID already exists', {
method: 'e2e.setRoomKeyID',
});
}
void notifyOnRoomChanged(room);
};
Meteor.methods<ServerMethods>({
async 'e2e.setRoomKeyID'(rid, keyID) {
check(rid, String);
check(keyID, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.setRoomKeyID' });
}
if (!rid) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' });
}
await setRoomKeyIDMethod(userId, rid, keyID);
},
});

View File

@@ -0,0 +1,63 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { notifyOnRoomChangedById } from '../../lib/notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'e2e.setUserPublicAndPrivateKeys'({ public_key, private_key }: { public_key: string; private_key: string; force?: boolean }): void;
}
}
const isKeysResult = (result: any): result is { public_key: string; private_key: string } => {
return result.private_key && result.public_key;
};
export const setUserPublicAndPrivateKeysMethod = async (
userId: string,
keyPair: { public_key: string; private_key: string; force?: boolean },
): Promise<void> => {
if (!keyPair.force) {
const keys = await Users.fetchKeysByUserId(userId);
if (isKeysResult(keys)) {
throw new Meteor.Error('error-keys-already-set', 'Keys already set', {
method: 'e2e.setUserPublicAndPrivateKeys',
});
}
}
await Users.setE2EPublicAndPrivateKeysByUserId(userId, {
private_key: keyPair.private_key,
public_key: keyPair.public_key,
});
const subscribedRoomIds = await Rooms.getSubscribedRoomIdsWithoutE2EKeys(userId);
await Rooms.addUserIdToE2EEQueueByRoomIds(subscribedRoomIds, userId);
void notifyOnRoomChangedById(subscribedRoomIds);
};
Meteor.methods<ServerMethods>({
async 'e2e.setUserPublicAndPrivateKeys'(keyPair) {
methodDeprecationLogger.method('e2e.setUserPublicAndPrivateKeys', '9.0.0', '/v1/e2e.setUserPublicAndPrivateKeys');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'e2e.setUserPublicAndPrivateKeys',
});
}
if (!keyPair.public_key || !keyPair.private_key) {
throw new Meteor.Error('error-invalid-keys', 'Invalid keys', {
method: 'e2e.setUserPublicAndPrivateKeys',
});
}
await setUserPublicAndPrivateKeysMethod(userId, keyPair);
},
});

View File

@@ -0,0 +1,37 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../lib/authorization';
import { translateMessage } from '../../lib/autotranslate/functions/translateMessage';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'autoTranslate.translateMessage'(message: IMessage | undefined, targetLanguage: string): Promise<IMessage | undefined>;
}
}
Meteor.methods<ServerMethods>({
async 'autoTranslate.translateMessage'(message, targetLanguage) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'autoTranslate.translateMessage',
});
}
check(message?._id, String);
check(targetLanguage, String);
const msg = await Messages.findOneById(message._id);
if (!msg) {
throw new Meteor.Error('error-message-not-found', 'Message not found');
}
const room = await Rooms.findOneById(msg.rid);
if (!room || !(await canAccessRoomAsync(room, { _id: userId }))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed');
}
return translateMessage(targetLanguage, msg);
},
});

View File

@@ -0,0 +1,34 @@
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { notifyOnSubscriptionChangedById, notifyOnSubscriptionChanged, notifyOnRoomChangedById } from '../../lib/notifyListener';
export async function updateGroupKey(rid: string, uid: string, key: string, callerUserId: string) {
// I have a subscription to this room
const mySub = await Subscriptions.findOneByRoomIdAndUserId(rid, callerUserId);
if (mySub) {
// Setting the key to myself, can set directly to the final field
if (callerUserId === uid) {
const setGroupE2EKeyResponse = await Subscriptions.setGroupE2EKey(mySub._id, key);
// Case: I create an encrypted room before setting up my keys, and I reset the e2e keys
// Next login, I'll create the keys for the room, and set them here.
// However as I reset my keys, I'm on the `usersWaitingForKeys` queue
// So I need to remove myself from the queue and notify the time i reach here
// This way, I can provide the keys to other users
const { modifiedCount } = await Rooms.removeUsersFromE2EEQueueByRoomId(mySub.rid, [callerUserId]);
if (setGroupE2EKeyResponse.modifiedCount) {
void notifyOnSubscriptionChangedById(mySub._id);
}
if (modifiedCount) {
void notifyOnRoomChangedById(mySub.rid);
}
return;
}
// uid also has subscription to this room
const value = await Subscriptions.setGroupE2ESuggestedKey(uid, rid, key);
if (value) {
void notifyOnSubscriptionChanged(value);
}
}
}

View File

@@ -0,0 +1,98 @@
import { Message } from '@rocket.chat/core-services';
import type { IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Rooms, Users } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { beforeAddUserToRoom } from '../../hooks/rooms/beforeAddUserToRoom';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { callbacks } from '../../lib/callbacks';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { getSubscriptionAutotranslateDefaultConfig } from '../../lib/getSubscriptionAutotranslateDefaultConfig';
import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener';
import { getDefaultSubscriptionPref } from '../../lib/utils/lib/getDefaultSubscriptionPref';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addAllUserToRoom(rid: IRoom['_id'], activeUsersOnly?: boolean): Promise<true>;
}
}
export const addAllUserToRoomFn = async (userId: string, rid: IRoom['_id'], activeUsersOnly = false): Promise<true> => {
check(rid, String);
check(activeUsersOnly, Boolean);
if (!(await hasPermissionAsync(userId, 'add-all-to-room'))) {
throw new Meteor.Error(403, 'Access to Method Forbidden', {
method: 'addAllToRoom',
});
}
const userFilter: {
active?: boolean;
} = {};
if (activeUsersOnly === true) {
userFilter.active = true;
}
const users = await Users.find(userFilter).toArray();
if (users.length > settings.get<number>('API_User_Limit')) {
throw new Meteor.Error('error-user-limit-exceeded', 'User Limit Exceeded', {
method: 'addAllToRoom',
});
}
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'addAllToRoom',
});
}
await beforeAddUserToRoom(
users.map((u) => u.username!),
room,
);
const now = new Date();
for await (const user of users) {
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id);
if (subscription != null) {
continue;
}
await callbacks.run('beforeJoinRoom', user, room);
const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user);
const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, {
ts: now,
open: true,
alert: true,
unread: 1,
userMentions: 1,
groupMentions: 0,
...autoTranslateConfig,
...getDefaultSubscriptionPref(user),
});
if (insertedId) {
void notifyOnSubscriptionChangedById(insertedId, 'inserted');
}
await Message.saveSystemMessage('uj', rid, user.username || '', user, { ts: now });
await callbacks.run('afterJoinRoom', user, room);
}
return true;
};
Meteor.methods<ServerMethods>({
async addAllUserToRoom(rid, activeUsersOnly = false) {
methodDeprecationLogger.method('addAllUserToRoom', '9.0.0', ['/v1/channels.addAll', '/v1/groups.addAll']);
if (!this.userId) {
throw new Meteor.Error(403, 'Access to Method Forbidden', {
method: 'addAllToRoom',
});
}
return addAllUserToRoomFn(this.userId, rid, activeUsersOnly);
},
});

View File

@@ -0,0 +1,103 @@
import { api, Message, Team } from '@rocket.chat/core-services';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Users } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener';
import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addRoomLeader(rid: IRoom['_id'], userId: IUser['_id']): boolean;
}
}
export const addRoomLeader = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise<boolean> => {
check(rid, String);
check(userId, String);
if (!(await hasPermissionAsync(fromUserId, 'set-leader', rid))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'addRoomLeader',
});
}
const user = await Users.findOneById(userId);
if (!user?.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomLeader',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id);
if (!subscription) {
throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', {
method: 'addRoomLeader',
});
}
if (subscription.roles && Array.isArray(subscription.roles) === true && subscription.roles.includes('leader') === true) {
throw new Meteor.Error('error-user-already-leader', 'User is already a leader', {
method: 'addRoomLeader',
});
}
const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'leader');
await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['leader']) || ['leader']);
if (addRoleResponse.modifiedCount) {
void notifyOnSubscriptionChangedById(subscription._id);
}
const fromUser = await Users.findOneById(fromUserId);
if (!fromUser) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomLeader',
});
}
await Message.saveSystemMessage('subscription-role-added', rid, user.username, fromUser, { role: 'leader' });
const team = await Team.getOneByMainRoomId(rid);
if (team) {
await Team.addRolesToMember(team._id, userId, ['leader']);
}
if (settings.get<boolean>('UI_DisplayRoles')) {
void api.broadcast('user.roleUpdate', {
type: 'added',
_id: 'leader',
u: {
_id: user._id,
username: user.username,
},
scope: rid,
});
}
return true;
};
Meteor.methods<ServerMethods>({
async addRoomLeader(rid, userId) {
methodDeprecationLogger.method('addRoomLeader', '9.0.0', ['/v1/channels.addLeader', '/v1/groups.addLeader']);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomLeader',
});
}
return addRoomLeader(uid, rid, userId);
},
});

View File

@@ -0,0 +1,125 @@
import { api, Message, Team } from '@rocket.chat/core-services';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Rooms, Users } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener';
import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority';
import { isFederationEnabled, FederationMatrixInvalidConfigurationError } from '../../services/federation/utils';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addRoomModerator(rid: IRoom['_id'], userId: IUser['_id']): boolean;
}
}
export const addRoomModerator = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise<boolean> => {
check(rid, String);
check(userId, String);
const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } });
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'addRoomModerator',
});
}
const isFederated = isRoomFederated(room);
if (!(await hasPermissionAsync(fromUserId, 'set-moderator', rid)) && !isFederated) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'addRoomModerator',
});
}
if (isFederated && !isFederationEnabled()) {
throw new FederationMatrixInvalidConfigurationError('unable to change room owners');
}
const user = await Users.findOneById(userId);
if (!user?.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomModerator',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id);
if (!subscription) {
throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', {
method: 'addRoomModerator',
});
}
if (subscription.roles && Array.isArray(subscription.roles) === true && subscription.roles.includes('moderator') === true) {
throw new Meteor.Error('error-user-already-moderator', 'User is already a moderator', {
method: 'addRoomModerator',
});
}
await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'moderator' });
const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'moderator');
await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['moderator']) || ['moderator']);
if (addRoleResponse.modifiedCount) {
void notifyOnSubscriptionChangedById(subscription._id);
}
const fromUser = await Users.findOneById(fromUserId);
if (!fromUser) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomLeader',
});
}
await Message.saveSystemMessage('subscription-role-added', rid, user.username, fromUser, { role: 'moderator' });
const team = await Team.getOneByMainRoomId(rid);
if (team) {
await Team.addRolesToMember(team._id, userId, ['moderator']);
}
const event = {
type: 'added',
_id: 'moderator',
u: {
_id: user._id,
username: user.username,
name: fromUser.name,
},
scope: rid,
} as const;
if (settings.get<boolean>('UI_DisplayRoles')) {
void api.broadcast('user.roleUpdate', event);
}
void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId });
return true;
};
Meteor.methods<ServerMethods>({
async addRoomModerator(rid, userId) {
methodDeprecationLogger.method('addRoomModerator', '9.0.0', ['/v1/channels.addModerator', '/v1/groups.addModerator']);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomModerator',
});
}
return addRoomModerator(uid, rid, userId);
},
});

View File

@@ -0,0 +1,122 @@
import { api, Message, Team } from '@rocket.chat/core-services';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Rooms, Users } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener';
import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority';
import { isFederationEnabled, FederationMatrixInvalidConfigurationError } from '../../services/federation/utils';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addRoomOwner(rid: IRoom['_id'], userId: IUser['_id']): boolean;
}
}
export const addRoomOwner = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise<boolean> => {
check(rid, String);
check(userId, String);
const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } });
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'addRoomOwner',
});
}
const isFederated = isRoomFederated(room);
if (!(await hasPermissionAsync(fromUserId, 'set-owner', rid)) && !isFederated) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'addRoomOwner',
});
}
if (isFederated && !isFederationEnabled()) {
throw new FederationMatrixInvalidConfigurationError('unable to change room owners');
}
const user = await Users.findOneById(userId);
if (!user?.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomOwner',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id);
if (!subscription) {
throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', {
method: 'addRoomOwner',
});
}
if (subscription.roles && Array.isArray(subscription.roles) === true && subscription.roles.includes('owner') === true) {
throw new Meteor.Error('error-user-already-owner', 'User is already an owner', {
method: 'addRoomOwner',
});
}
await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'owner' });
const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'owner');
await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['owner']) || ['owner']);
if (addRoleResponse.modifiedCount) {
void notifyOnSubscriptionChangedById(subscription._id);
}
const fromUser = await Users.findOneById(fromUserId);
if (!fromUser) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomLeader',
});
}
await Message.saveSystemMessage('subscription-role-added', rid, user.username, fromUser, { role: 'owner' });
const team = await Team.getOneByMainRoomId(rid);
if (team) {
await Team.addRolesToMember(team._id, userId, ['owner']);
}
const event = {
type: 'added',
_id: 'owner',
u: {
_id: user._id,
username: user.username,
name: user.name,
},
scope: rid,
} as const;
if (settings.get('UI_DisplayRoles')) {
void api.broadcast('user.roleUpdate', event);
}
void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId });
return true;
};
Meteor.methods<ServerMethods>({
async addRoomOwner(rid, userId) {
methodDeprecationLogger.method('addRoomOwner', '9.0.0', ['/v1/channels.addOwner', '/v1/groups.addOwner']);
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addRoomOwner',
});
}
return addRoomOwner(uid, rid, userId);
},
});

View File

@@ -0,0 +1,29 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Meteor } from 'meteor/meteor';
import { addUsersToRoomMethod } from './addUsersToRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addUserToRoom(data: { rid: string; username: string }): void;
}
}
Meteor.methods<ServerMethods>({
async addUserToRoom(data) {
methodDeprecationLogger.method('addUserToRoom', '9.0.0', ['/v1/channels.invite', '/v1/groups.invite']);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addUserToRoom',
});
}
await addUsersToRoomMethod(userId, {
rid: data.rid,
users: [data.username],
});
},
});

View File

@@ -0,0 +1,137 @@
import { api } from '@rocket.chat/core-services';
import { isBannedSubscription, isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { beforeAddUsersToRoom } from '../../lib/callbacks/beforeAddUserToRoom';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { i18n } from '../../lib/i18n';
import { addUserToRoom } from '../../lib/rooms/addUserToRoom';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addUsersToRoom(data: { rid: string; users: string[] }): boolean;
}
}
export const sanitizeUsername = (username: string) => {
const isFederatedUsername = username.includes('@') && username.includes(':');
if (isFederatedUsername) {
return username;
}
return username.replace(/(^@)|( @)/, '');
};
export const addUsersToRoomMethod = async (userId: string, data: { rid: string; users: string[] }, user?: IUser): Promise<boolean> => {
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addUsersToRoom',
});
}
if (!Match.test(data.rid, String)) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'addUsersToRoom',
});
}
// Get user and room details
const room = await Rooms.findOneById(data.rid);
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'addUsersToRoom',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, userId, {
projection: { _id: 1 },
});
const userInRoom = subscription != null;
if (room.t === 'd' && !isRoomNativeFederated(room)) {
throw new Meteor.Error('error-cant-invite-for-direct-room', "Can't invite user to direct rooms", {
method: 'addUsersToRoom',
});
}
// Can add to any room you're in, with permission, otherwise need specific room type permission
let canAddUser = false;
if (userInRoom && (await hasPermissionAsync(userId, 'add-user-to-joined-room', room._id))) {
canAddUser = true;
} else if (room.t === 'c' && (await hasPermissionAsync(userId, 'add-user-to-any-c-room'))) {
canAddUser = true;
} else if (room.t === 'p' && (await hasPermissionAsync(userId, 'add-user-to-any-p-room'))) {
canAddUser = true;
}
// Adding wasn't allowed
if (!canAddUser) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'addUsersToRoom',
});
}
// Missing the users to be added
if (!Array.isArray(data.users)) {
throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', {
method: 'addUsersToRoom',
});
}
await beforeAddUsersToRoom.run({ usernames: data.users, inviter: user }, room);
await Promise.all(
data.users.map(async (username) => {
const sanitizedUsername = sanitizeUsername(username);
const newUser = await Users.findOneByUsernameIgnoringCase(sanitizedUsername);
if (!newUser) {
throw new Meteor.Error('error-user-not-found', 'User not found', {
method: 'addUsersToRoom',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id);
if (subscription && isBannedSubscription(subscription)) {
throw new Meteor.Error('error-user-is-banned', 'User is banned from this room', {
method: 'addUsersToRoom',
});
}
if (!subscription) {
return addUserToRoom(data.rid, newUser, user);
}
if (!newUser.username) {
return;
}
void api.broadcast('notify.ephemeralMessage', userId, data.rid, {
msg: i18n.t('Username_is_already_in_here', {
interpolation: { escapeValue: false },
username: newUser.username,
lng: user?.language,
}),
});
}),
);
return true;
};
Meteor.methods<ServerMethods>({
async addUsersToRoom(data) {
methodDeprecationLogger.method('addUsersToRoom', '9.0.0', ['/v1/channels.invite', '/v1/groups.invite']);
const uid = Meteor.userId();
// Validate user and room
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addUsersToRoom',
});
}
return addUsersToRoomMethod(uid, data, ((await Meteor.userAsync()) as IUser | null) ?? undefined);
},
});

View File

@@ -0,0 +1,54 @@
import { isRegisterUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { RoomMemberActions } from '../../../definition/IRoomTypeConfig';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { archiveRoom } from '../../lib/rooms/archiveRoom';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
archiveRoom(rid: string): Promise<void>;
}
}
export const executeArchiveRoom = async (userId: string, rid: string) => {
check(rid, String);
const user = await Users.findOneById(userId, { projection: { username: 1, name: 1 } });
if (!user || !isRegisterUser(user)) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' });
}
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'archiveRoom' });
}
if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.ARCHIVE, userId))) {
throw new Meteor.Error('error-direct-message-room', `rooms type: ${room.t} can not be archived`, { method: 'archiveRoom' });
}
if (!(await hasPermissionAsync(userId, 'archive-room', room._id))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'archiveRoom' });
}
return archiveRoom(rid, user);
};
Meteor.methods<ServerMethods>({
async archiveRoom(rid) {
methodDeprecationLogger.method('archiveRoom', '9.0.0', '/v1/channels.archive');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' });
}
return executeArchiveRoom(userId, rid);
},
});

Some files were not shown because too many files have changed in this diff Show More