diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 536168691d2..caa4d26dc30 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -46,5 +46,6 @@ import './v1/voip/extensions'; import './v1/voip/queues'; import './v1/voip/omnichannel'; import './v1/voip'; +import './v1/moderation'; export { API, APIClass, defaultRateLimiterOptions } from './api'; diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 5c36d50dc39..46ccc45d56a 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -4,6 +4,7 @@ import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Message } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; +import { isChatReportMessageProps } from '@rocket.chat/rest-typings'; import { roomAccessAttributes } from '../../../authorization/server'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -18,6 +19,7 @@ import { getPaginationItems } from '../helpers/getPaginationItems'; import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; import { deleteMessageValidatingPermission } from '../../../lib/server/functions/deleteMessage'; +import { reportMessage } from '../../../../server/lib/moderation/reportMessage'; API.v1.addRoute( 'chat.delete', @@ -365,7 +367,7 @@ API.v1.addRoute( API.v1.addRoute( 'chat.reportMessage', - { authRequired: true }, + { authRequired: true, validateParams: isChatReportMessageProps }, { async post() { const { messageId, description } = this.bodyParams; @@ -377,7 +379,7 @@ API.v1.addRoute( return API.v1.failure('The required "description" param is missing.'); } - await Meteor.callAsync('reportMessage', messageId, description); + await reportMessage(messageId, description, this.userId); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts new file mode 100644 index 00000000000..01828d98275 --- /dev/null +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -0,0 +1,240 @@ +import { + isReportHistoryProps, + isArchiveReportProps, + isReportInfoParams, + isReportMessageHistoryParams, + isModerationDeleteMsgHistoryParams, + isReportsByMsgIdParams, +} from '@rocket.chat/rest-typings'; +import { ModerationReports, Users, Messages } from '@rocket.chat/models'; +import type { IModerationReport } from '@rocket.chat/core-typings'; + +import { API } from '../api'; +import { deleteReportedMessages } from '../../../../server/lib/moderation/deleteReportedMessages'; +import { getPaginationItems } from '../helpers/getPaginationItems'; + +type ReportMessage = Pick; + +API.v1.addRoute( + 'moderation.reportsByUsers', + { + authRequired: true, + validateParams: isReportHistoryProps, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const latest = _latest ? new Date(_latest) : new Date(); + const oldest = _oldest ? new Date(_oldest) : new Date(0); + + const reports = await ModerationReports.findReportsGroupedByUser(latest, oldest, selector, { offset, count, sort }).toArray(); + + if (reports.length === 0) { + return API.v1.success({ + reports, + count: 0, + offset, + total: 0, + }); + } + + const total = await ModerationReports.countReportsInRange(latest, oldest, selector); + + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); + }, + }, +); + +API.v1.addRoute( + 'moderation.user.reportedMessages', + { + authRequired: true, + validateParams: isReportMessageHistoryParams, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { userId, selector = '' } = this.queryParams; + + const { sort } = await this.parseJsonQuery(); + + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); + if (!user) { + return API.v1.failure('error-invalid-user'); + } + + const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, selector, { offset, count, sort }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + const uniqueMessages: ReportMessage[] = []; + const visited = new Set(); + for (const report of reports) { + if (visited.has(report.message._id)) { + continue; + } + visited.add(report.message._id); + uniqueMessages.push(report); + } + + return API.v1.success({ + messages: uniqueMessages, + count: reports.length, + total, + offset, + }); + }, + }, +); + +API.v1.addRoute( + 'moderation.user.deleteReportedMessages', + { + authRequired: true, + validateParams: isModerationDeleteMsgHistoryParams, + permissionsRequired: ['manage-moderation-actions'], + }, + { + async post() { + // TODO change complicated params + const { userId, reason } = this.bodyParams; + + const sanitizedReason = reason?.trim() ? reason : 'No reason provided'; + + const { user: moderator } = this; + + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); + if (!user) { + return API.v1.failure('error-invalid-user'); + } + + const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, '', { + offset, + count, + sort: { ts: -1 }, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + if (total === 0) { + return API.v1.failure('No reported messages found for this user.'); + } + + await deleteReportedMessages( + messages.map((message) => message.message), + moderator, + ); + + await ModerationReports.hideReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages'); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'moderation.dismissReports', + { + authRequired: true, + validateParams: isArchiveReportProps, + permissionsRequired: ['manage-moderation-actions'], + }, + { + async post() { + // TODO change complicated camelcases to simple verbs/nouns + const { userId, msgId, reason, action: actionParam } = this.bodyParams; + + if (userId) { + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); + if (!user) { + return API.v1.failure('user-not-found'); + } + } + + if (msgId) { + const message = await Messages.findOneById(msgId, { projection: { _id: 1 } }); + if (!message) { + return API.v1.failure('error-message-not-found'); + } + } + + const sanitizedReason: string = reason?.trim() ? reason : 'No reason provided'; + const action: string = actionParam ?? 'None'; + + const { userId: moderatorId } = this; + + if (userId) { + await ModerationReports.hideReportsByUserId(userId, moderatorId, sanitizedReason, action); + } else { + await ModerationReports.hideReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action); + } + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'moderation.reports', + { + authRequired: true, + validateParams: isReportsByMsgIdParams, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { msgId } = this.queryParams; + + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { selector = '' } = this.queryParams; + + const { cursor, totalCount } = ModerationReports.findReportsByMessageId(msgId, selector, { count, sort, offset }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); + }, + }, +); + +API.v1.addRoute( + 'moderation.reportInfo', + { + authRequired: true, + permissionsRequired: ['view-moderation-console'], + validateParams: isReportInfoParams, + }, + { + async get() { + const { reportId } = this.queryParams; + + const report = await ModerationReports.findOneById(reportId); + + if (!report) { + return API.v1.failure('error-report-not-found'); + } + + return API.v1.success({ report }); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 39332159fb8..8c0a8aea8e1 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -342,7 +342,10 @@ API.v1.addRoute( { authRequired: true, validateParams: isUserSetActiveStatusParamsPOST }, { async post() { - if (!(await hasPermissionAsync(this.userId, 'edit-other-user-active-status'))) { + if ( + !(await hasPermissionAsync(this.userId, 'edit-other-user-active-status')) && + !(await hasPermissionAsync(this.userId, 'manage-moderation-actions')) + ) { return API.v1.unauthorized(); } @@ -590,7 +593,10 @@ API.v1.addRoute( if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { await Meteor.callAsync('resetAvatar'); - } else if (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) { + } else if ( + (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) || + (await hasPermissionAsync(this.userId, 'manage-moderation-actions')) + ) { await Meteor.callAsync('resetAvatar', user._id); } else { throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { diff --git a/apps/meteor/app/apps/server/bridges/bridges.js b/apps/meteor/app/apps/server/bridges/bridges.js index 9e31d3f2f51..450bae2e323 100644 --- a/apps/meteor/app/apps/server/bridges/bridges.js +++ b/apps/meteor/app/apps/server/bridges/bridges.js @@ -21,6 +21,7 @@ import { AppSchedulerBridge } from './scheduler'; import { AppVideoConferenceBridge } from './videoConferences'; import { AppOAuthAppsBridge } from './oauthApps'; import { AppInternalFederationBridge } from './internalFederation'; +import { AppModerationBridge } from './moderation'; export class RealAppBridges extends AppBridges { constructor(orch) { @@ -47,6 +48,7 @@ export class RealAppBridges extends AppBridges { this._videoConfBridge = new AppVideoConferenceBridge(orch); this._oAuthBridge = new AppOAuthAppsBridge(orch); this._internalFedBridge = new AppInternalFederationBridge(orch); + this._moderationBridge = new AppModerationBridge(orch); } getCommandBridge() { @@ -132,4 +134,8 @@ export class RealAppBridges extends AppBridges { getInternalFederationBridge() { return this._internalFedBridge; } + + getModerationBridge() { + return this._moderationBridge; + } } diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index 28797d262e3..24dfb2a7b0a 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -10,6 +10,7 @@ import { updateMessage } from '../../../lib/server/functions/updateMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import notifications from '../../../notifications/server/lib/Notifications'; import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; +import { deleteMessage } from '../../../lib/server'; export class AppMessageBridge extends MessageBridge { // eslint-disable-next-line no-empty-function @@ -54,6 +55,19 @@ export class AppMessageBridge extends MessageBridge { await updateMessage(msg, editor); } + protected async delete(message: IMessage, user: IUser, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is deleting a message.`); + + if (!message.id) { + throw new Error('Invalid message id'); + } + + const convertedMsg = await this.orch.getConverters()?.get('messages').convertAppMessage(message); + const convertedUser = await this.orch.getConverters()?.get('users').convertById(user.id); + + await deleteMessage(convertedMsg, convertedUser); + } + protected async notifyUser(user: IUser, message: IMessage, appId: string): Promise { this.orch.debugLog(`The App ${appId} is notifying a user.`); diff --git a/apps/meteor/app/apps/server/bridges/moderation.ts b/apps/meteor/app/apps/server/bridges/moderation.ts new file mode 100644 index 00000000000..68d2260c571 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/moderation.ts @@ -0,0 +1,46 @@ +import { ModerationBridge } from '@rocket.chat/apps-engine/server/bridges/ModerationBridge'; +import { ModerationReports } from '@rocket.chat/models'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; +import { reportMessage } from '../../../../server/lib/moderation/reportMessage'; + +export class AppModerationBridge extends ModerationBridge { + constructor(private readonly orch: AppServerOrchestrator) { + super(); + } + + protected async report(messageId: IMessage['id'], description: string, userId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a new report.`); + + if (!messageId) { + throw new Error('Invalid message id'); + } + + if (!description) { + throw new Error('Invalid description'); + } + + await reportMessage(messageId, description, userId || 'rocket.cat'); + } + + protected async dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is dismissing reports by message id.`); + + if (!messageId) { + throw new Error('Invalid message id'); + } + + await ModerationReports.hideReportsByMessageId(messageId, appId, reason, action); + } + + protected async dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is dismissing reports by user id.`); + + if (!userId) { + throw new Error('Invalid user id'); + } + await ModerationReports.hideReportsByUserId(userId, appId, reason, action); + } +} diff --git a/apps/meteor/app/apps/server/bridges/users.ts b/apps/meteor/app/apps/server/bridges/users.ts index 14861588d42..27bac79b103 100644 --- a/apps/meteor/app/apps/server/bridges/users.ts +++ b/apps/meteor/app/apps/server/bridges/users.ts @@ -8,6 +8,7 @@ import type { UserStatus } from '@rocket.chat/core-typings'; import { setUserAvatar, deleteUser, getUserCreatedByApp } from '../../../lib/server/functions'; import { checkUsernameAvailability } from '../../../lib/server/functions/checkUsernameAvailability'; import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; +import { setUserActiveStatus } from '../../../lib/server/functions/setUserActiveStatus'; export class AppUserBridge extends UserBridge { // eslint-disable-next-line no-empty-function @@ -131,6 +132,19 @@ export class AppUserBridge extends UserBridge { return true; } + protected async deactivate(userId: IUser['id'], confirmRelinquish: boolean, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is deactivating a user.`); + + if (!userId) { + throw new Error('Invalid user id'); + } + const convertedUser = await this.orch.getConverters()?.get('users').convertById(userId); + + await setUserActiveStatus(convertedUser.id, false, confirmRelinquish); + + return true; + } + protected async getActiveUserCount(): Promise { return Users.getActiveLocalUserCount(); } diff --git a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts index e08034ce1a8..2236b113433 100644 --- a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts +++ b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts @@ -222,6 +222,8 @@ export const upsertPermissions = async (): Promise => { { _id: 'view-import-operations', roles: ['admin'] }, { _id: 'clear-oembed-cache', roles: ['admin'] }, { _id: 'videoconf-ring-users', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'view-moderation-console', roles: ['admin'] }, + { _id: 'manage-moderation-actions', roles: ['admin'] }, { _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] }, ]; diff --git a/apps/meteor/app/lib/server/methods/deleteMessage.ts b/apps/meteor/app/lib/server/methods/deleteMessage.ts index d717573eb8e..811e1e3e359 100644 --- a/apps/meteor/app/lib/server/methods/deleteMessage.ts +++ b/apps/meteor/app/lib/server/methods/deleteMessage.ts @@ -3,6 +3,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { deleteMessageValidatingPermission } from '../functions/deleteMessage'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -13,6 +14,15 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async deleteMessage(message) { + methodDeprecationLogger.warn('deleteMessage method is deprecated, and will be removed in future versions'); + + check( + message, + Match.ObjectIncluding({ + _id: String, + }), + ); + const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/client/components/AdministrationList/AdministrationList.tsx b/apps/meteor/client/components/AdministrationList/AdministrationList.tsx index 8270738b0e1..1de4319ed62 100644 --- a/apps/meteor/client/components/AdministrationList/AdministrationList.tsx +++ b/apps/meteor/client/components/AdministrationList/AdministrationList.tsx @@ -31,6 +31,7 @@ const ADMIN_PERMISSIONS = [ 'manage-own-outgoing-integrations', 'manage-own-incoming-integrations', 'view-engagement-dashboard', + 'view-moderation-console', ]; const AdministrationList = ({ accountBoxItems, onDismiss }: AdministrationListProps): ReactElement => { diff --git a/apps/meteor/client/components/Emoji.tsx b/apps/meteor/client/components/Emoji.tsx index b2aa27435f1..69eeadc9d9f 100644 --- a/apps/meteor/client/components/Emoji.tsx +++ b/apps/meteor/client/components/Emoji.tsx @@ -22,8 +22,16 @@ const EmojiComponent = styled('span', ({ fillContainer: _fillContainer, ...props `; function Emoji({ emojiHandle, className = undefined, fillContainer }: EmojiProps): ReactElement { - const { className: emojiClassName, ...props } = getEmojiClassNameAndDataTitle(emojiHandle); - return ; + const { className: emojiClassName, image, ...props } = getEmojiClassNameAndDataTitle(emojiHandle); + + return ( + + ); } export default Emoji; diff --git a/apps/meteor/client/lib/createSidebarItems.ts b/apps/meteor/client/lib/createSidebarItems.ts index 89d8dac42a9..22af68db347 100644 --- a/apps/meteor/client/lib/createSidebarItems.ts +++ b/apps/meteor/client/lib/createSidebarItems.ts @@ -5,7 +5,7 @@ type Item = { i18nLabel: string; href?: string; icon?: IconProps['name']; - tag?: 'Alpha'; + tag?: 'Alpha' | 'Beta'; permissionGranted?: boolean | (() => boolean); pathSection?: string; pathGroup?: string; diff --git a/apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx b/apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx new file mode 100644 index 00000000000..8b2e31763fc --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/MessageContextFooter.tsx @@ -0,0 +1,35 @@ +import { Button, Icon, Menu, Option, ButtonGroup } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import type { FC } from 'react'; + +import useDeactivateUserAction from './hooks/useDeactivateUserAction'; +import useDeleteMessagesAction from './hooks/useDeleteMessagesAction'; +import useDismissUserAction from './hooks/useDismissUserAction'; +import useResetAvatarAction from './hooks/useResetAvatarAction'; + +const MessageContextFooter: FC<{ userId: string }> = ({ userId }) => { + const t = useTranslation(); + const { action } = useDeleteMessagesAction(userId); + + return ( + + + + ( +