feat: Report User (#29818)

pull/30088/head
gabriellsh 3 years ago committed by GitHub
parent 2e22874e6c
commit 4186eecf05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      .changeset/eleven-icons-tan.md
  2. 42
      apps/meteor/app/api/server/v1/moderation.ts
  3. 4
      apps/meteor/app/apps/server/bridges/moderation.ts
  4. 2
      apps/meteor/app/lib/server/functions/deleteUser.ts
  5. 4
      apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx
  6. 53
      apps/meteor/server/models/raw/ModerationReports.ts
  7. 46
      apps/meteor/tests/end-to-end/api/27-moderation.ts
  8. 21
      packages/core-typings/src/IModerationReport.ts
  9. 22
      packages/model-typings/src/models/IModerationReportsModel.ts
  10. 4
      packages/rest-typings/src/v1/moderation/ArchiveReportProps.ts
  11. 25
      packages/rest-typings/src/v1/moderation/ModerationReportUserPOST.ts
  12. 1
      packages/rest-typings/src/v1/moderation/index.ts
  13. 8
      packages/rest-typings/src/v1/moderation/moderation.ts

@ -0,0 +1,8 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/core-typings": minor
"@rocket.chat/model-typings": minor
"@rocket.chat/rest-typings": minor
---
Introduce the ability to report an user

@ -5,6 +5,7 @@ import {
isArchiveReportProps,
isReportInfoParams,
isReportMessageHistoryParams,
isModerationReportUserPost,
isModerationDeleteMsgHistoryParams,
isReportsByMsgIdParams,
} from '@rocket.chat/rest-typings';
@ -35,7 +36,11 @@ API.v1.addRoute(
const escapedSelector = escapeRegExp(selector);
const reports = await ModerationReports.findReportsGroupedByUser(latest, oldest, escapedSelector, { offset, count, sort }).toArray();
const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapedSelector, {
offset,
count,
sort,
}).toArray();
if (reports.length === 0) {
return API.v1.success({
@ -46,7 +51,7 @@ API.v1.addRoute(
});
}
const total = await ModerationReports.countReportsInRange(latest, oldest, escapedSelector);
const total = await ModerationReports.countMessageReportsInRange(latest, oldest, escapedSelector);
return API.v1.success({
reports,
@ -143,7 +148,7 @@ API.v1.addRoute(
moderator,
);
await ModerationReports.hideReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages');
await ModerationReports.hideMessageReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages');
return API.v1.success();
},
@ -181,9 +186,9 @@ API.v1.addRoute(
const { userId: moderatorId } = this;
if (userId) {
await ModerationReports.hideReportsByUserId(userId, moderatorId, sanitizedReason, action);
await ModerationReports.hideMessageReportsByUserId(userId, moderatorId, sanitizedReason, action);
} else {
await ModerationReports.hideReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action);
await ModerationReports.hideMessageReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action);
}
return API.v1.success();
@ -243,3 +248,30 @@ API.v1.addRoute(
},
},
);
API.v1.addRoute(
'moderation.reportUser',
{
authRequired: true,
validateParams: isModerationReportUserPost,
},
{
async post() {
const { userId, description } = this.bodyParams;
const {
user: { _id, name, username, createdAt },
} = this;
const reportedUser = await Users.findOneById(userId, { projection: { _id: 1, name: 1, username: 1, emails: 1, createdAt: 1 } });
if (!reportedUser) {
return API.v1.failure('Invalid user id provided.');
}
await ModerationReports.createWithDescriptionAndUser(reportedUser, description, { _id, name, username, createdAt });
return API.v1.success();
},
},
);

@ -32,7 +32,7 @@ export class AppModerationBridge extends ModerationBridge {
throw new Error('Invalid message id');
}
await ModerationReports.hideReportsByMessageId(messageId, appId, reason, action);
await ModerationReports.hideMessageReportsByMessageId(messageId, appId, reason, action);
}
protected async dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise<void> {
@ -41,6 +41,6 @@ export class AppModerationBridge extends ModerationBridge {
if (!userId) {
throw new Error('Invalid user id');
}
await ModerationReports.hideReportsByUserId(userId, appId, reason, action);
await ModerationReports.hideMessageReportsByUserId(userId, appId, reason, action);
}
}

@ -57,7 +57,7 @@ export async function deleteUser(userId: string, confirmRelinquish = false, dele
await Messages.removeByUserId(userId);
await ModerationReports.hideReportsByUserId(
await ModerationReports.hideMessageReportsByUserId(
userId,
deletedBy || userId,
deletedBy === userId ? 'user deleted own account' : 'user account deleted',

@ -1,4 +1,4 @@
import type { IMessage, IModerationReport } from '@rocket.chat/core-typings';
import type { IMessage, MessageReport } from '@rocket.chat/core-typings';
import { isE2EEMessage } from '@rocket.chat/core-typings';
import { Message, MessageName, MessageToolboxItem, MessageToolboxWrapper, MessageUsername } from '@rocket.chat/fuselage';
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
@ -25,7 +25,7 @@ const ContextMessage = ({
onChange,
}: {
message: any;
room: IModerationReport['room'];
room: MessageReport['room'];
deleted: boolean;
onRedirect: (id: IMessage['_id']) => void;
onChange: () => void;

@ -1,4 +1,11 @@
import type { IMessage, IModerationAudit, IModerationReport, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type {
IMessage,
IModerationAudit,
IModerationReport,
RocketChatRecordDeleted,
MessageReport,
UserReport,
} from '@rocket.chat/core-typings';
import type { FindPaginated, IModerationReportsModel, PaginationParams } from '@rocket.chat/model-typings';
import type { AggregationCursor, Collection, Db, Document, FindCursor, FindOptions, IndexDescription, UpdateResult } from 'mongodb';
@ -13,6 +20,7 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return [
{ key: { 'ts': 1, 'reports.ts': 1 } },
{ key: { 'message.u._id': 1, 'ts': 1 } },
{ key: { 'reportedUser._id': 1, 'ts': 1 } },
{ key: { 'message.rid': 1, 'ts': 1 } },
{ key: { userId: 1, ts: 1 } },
{ key: { 'message._id': 1, 'ts': 1 } },
@ -21,7 +29,7 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
createWithMessageDescriptionAndUserId(
message: IMessage,
description: string,
description: IModerationReport['description'],
room: IModerationReport['room'],
reportedBy: IModerationReport['reportedBy'],
): ReturnType<BaseRaw<IModerationReport>['insertOne']> {
@ -35,7 +43,22 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return this.insertOne(record);
}
findReportsGroupedByUser(
createWithDescriptionAndUser(
reportedUser: UserReport['reportedUser'],
description: UserReport['description'],
reportedBy: UserReport['reportedBy'],
): ReturnType<BaseRaw<IModerationReport>['insertOne']> {
const record = {
description,
reportedBy,
reportedUser,
ts: new Date(),
};
return this.insertOne(record);
}
findMessageReportsGroupedByUser(
latest: Date,
oldest: Date,
selector: string,
@ -109,7 +132,7 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return this.col.aggregate(params, { allowDiskUse: true });
}
countReportsInRange(latest: Date, oldest: Date, selector: string): Promise<number> {
countMessageReportsInRange(latest: Date, oldest: Date, selector: string): Promise<number> {
return this.col.countDocuments({
_hidden: { $ne: true },
ts: { $lt: latest, $gt: oldest },
@ -122,7 +145,7 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
selector: string,
pagination: PaginationParams<IModerationReport>,
options: FindOptions<IModerationReport> = {},
): FindPaginated<FindCursor<Pick<IModerationReport, '_id' | 'message' | 'ts' | 'room'>>> {
): FindPaginated<FindCursor<Pick<MessageReport, '_id' | 'message' | 'ts' | 'room'>>> {
const query = {
'_hidden': {
$ne: true,
@ -134,14 +157,10 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
const fuzzyQuery = selector
? {
$or: [
{
'message.msg': {
$regex: selector,
$options: 'i',
},
},
],
'message.msg': {
$regex: selector,
$options: 'i',
},
}
: {};
@ -198,7 +217,7 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return this.findPaginated(query, opts);
}
async hideReportsByMessageId(messageId: string, userId: string, reason: string, action: string): Promise<UpdateResult | Document> {
async hideMessageReportsByMessageId(messageId: string, userId: string, reason: string, action: string): Promise<UpdateResult | Document> {
const query = {
'message._id': messageId,
};
@ -213,7 +232,7 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
return this.updateMany(query, update);
}
async hideReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document> {
async hideMessageReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document> {
const query = {
'message.u._id': userId,
};
@ -228,10 +247,12 @@ export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements
}
private getSearchQueryForSelector(selector?: string): any {
const messageExistsQuery = { message: { $exists: true } };
if (!selector) {
return {};
return messageExistsQuery;
}
return {
...messageExistsQuery,
$or: [
{
'message.msg': {

@ -1,9 +1,10 @@
import type { IMessage, IModerationAudit, IModerationReport } from '@rocket.chat/core-typings';
import type { IMessage, IModerationAudit, IModerationReport, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { after, before, describe, it } from 'mocha';
import type { Response } from 'supertest';
import { getCredentials, api, request, credentials } from '../../data/api-data';
import { createUser, deleteUser } from '../../data/users.helper.js';
// test for the /moderation.reportsByUsers endpoint
@ -598,4 +599,47 @@ describe('[Moderation]', function () {
});
});
});
describe('[/moderation.reportUser]', () => {
let userToBeReported: IUser;
before(async () => {
userToBeReported = await createUser();
});
after(async () => {
await deleteUser(userToBeReported);
});
it('should report an user', async () => {
await request
.post(api('moderation.reportUser'))
.set(credentials)
.send({
userId: userToBeReported?._id,
description: 'sample report',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
});
});
it('should fail to report an user if not provided description', async () => {
await request
.post(api('moderation.reportUser'))
.set(credentials)
.send({
userId: userToBeReported?._id,
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body).to.have.property('errorType', 'invalid-params');
});
});
});
});

@ -3,6 +3,11 @@ import type { IRocketChatRecord } from './IRocketChatRecord';
import type { IRoom } from './IRoom';
import type { IUser } from './IUser';
/**
* Right now we're assuming neither Room Info or User Info changes.
* There's no update method referencing reports as of now (6.3.0-develop).
* This means that a possible user or room change will not be reflected in the report.
*/
export interface IModerationInfo {
moderatedBy: IUser['_id'];
hiddenAt: Date;
@ -11,15 +16,25 @@ export interface IModerationInfo {
}
export interface IModerationReport extends IRocketChatRecord {
message: IMessage;
message?: IMessage;
room?: Pick<IRoom, '_id' | 'name' | 'fname' | 't' | 'federated' | 'prid'>;
reportedUser?: Pick<IUser, '_id' | 'username' | 'name' | 'emails' | 'createdAt'>;
description: string;
ts: Date | string;
room: Pick<IRoom, '_id' | 'name' | 'fname' | 't' | 'federated' | 'prid'>;
reportedBy: Pick<IUser, '_id' | 'username' | 'name' | 'createdAt'>;
moderationInfo?: IModerationInfo;
_hidden?: boolean;
}
export type MessageReport = Omit<IModerationReport, 'reportedUser'> & {
room: Exclude<IModerationReport['room'], undefined>;
message: Exclude<IModerationReport['message'], undefined>;
};
export type UserReport = Omit<IModerationReport, 'message' | 'room'> & {
reportedUser: Exclude<IModerationReport['reportedUser'], undefined>;
};
export interface IModerationAudit {
userId: IUser['_id'];
username: IUser['username'];
@ -28,7 +43,7 @@ export interface IModerationAudit {
msgId: IMessage['_id'];
roomIds: IRoom['_id'][];
ts: IModerationReport['ts'];
rooms: IModerationReport['room'][];
rooms: MessageReport['room'][];
count: number;
isUserDeleted: boolean;
}

@ -1,4 +1,4 @@
import type { IModerationReport, IMessage, IModerationAudit } from '@rocket.chat/core-typings';
import type { IModerationReport, IMessage, IModerationAudit, MessageReport } from '@rocket.chat/core-typings';
import type { AggregationCursor, Document, FindCursor, FindOptions, UpdateResult } from 'mongodb';
import type { FindPaginated, IBaseModel } from './IBaseModel';
@ -17,17 +17,23 @@ export interface IModerationReportsModel extends IBaseModel<IModerationReport> {
reportedBy: IModerationReport['reportedBy'],
): ReturnType<IBaseModel<IModerationReport>['insertOne']>;
findReportsGroupedByUser(
createWithDescriptionAndUser(
reportedUser: Exclude<IModerationReport['reportedUser'], undefined>,
description: string,
reportedBy: IModerationReport['reportedBy'],
): ReturnType<IBaseModel<IModerationReport>['insertOne']>;
findMessageReportsGroupedByUser(
latest: Date,
oldest: Date,
selector: string,
pagination: PaginationParams<IModerationReport>,
): AggregationCursor<IModerationAudit>;
countReportsInRange(latest: Date, oldest: Date, selector: string): Promise<number>;
countMessageReportsInRange(latest: Date, oldest: Date, selector: string): Promise<number>;
findReportsByMessageId(
messageId: IModerationReport['message']['_id'],
messageId: IMessage['_id'],
selector: string,
pagination: PaginationParams<IModerationReport>,
options?: FindOptions<IModerationReport>,
@ -38,14 +44,14 @@ export interface IModerationReportsModel extends IBaseModel<IModerationReport> {
selector: string,
pagination: PaginationParams<IModerationReport>,
options?: FindOptions<IModerationReport>,
): FindPaginated<FindCursor<Pick<IModerationReport, '_id' | 'message' | 'ts' | 'room'>>>;
): FindPaginated<FindCursor<Pick<MessageReport, '_id' | 'message' | 'ts' | 'room'>>>;
hideReportsByMessageId(
messageId: IModerationReport['message']['_id'],
hideMessageReportsByMessageId(
messageId: IMessage['_id'],
userId: string,
reason: string,
action: string,
): Promise<UpdateResult | Document>;
hideReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document>;
hideMessageReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document>;
}

@ -1,10 +1,10 @@
import type { IModerationReport, IUser } from '@rocket.chat/core-typings';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import { ajv } from '../Ajv';
export type ArchiveReportPropsPOST = {
userId?: IUser['_id'];
msgId?: IModerationReport['message']['_id'];
msgId?: IMessage['_id'];
action?: string;
reason?: string;
};

@ -0,0 +1,25 @@
import type { IUser } from '@rocket.chat/core-typings';
import { ajv } from '../Ajv';
export type ModerationReportUserPOST = {
userId: IUser['_id'];
description: string;
};
const reportUserPropsSchema = {
type: 'object',
properties: {
userId: {
type: 'string',
},
description: {
type: 'string',
minLength: 1,
},
},
required: ['userId', 'description'],
additionalProperties: false,
};
export const isModerationReportUserPost = ajv.compile<ModerationReportUserPOST>(reportUserPropsSchema);

@ -6,3 +6,4 @@ export * from './ReportHistoryProps';
export * from './ReportInfoParams';
export * from './ReportsByMsgIdParams';
export * from './ReportMessageHistoryParams';
export * from './ModerationReportUserPOST';

@ -1,8 +1,9 @@
import type { IModerationReport, IModerationAudit, IUser } from '@rocket.chat/core-typings';
import type { IModerationAudit, IModerationReport, IUser, MessageReport } from '@rocket.chat/core-typings';
import type { PaginatedResult } from '../../helpers/PaginatedResult';
import type { ArchiveReportPropsPOST } from './ArchiveReportProps';
import type { ModerationDeleteMsgHistoryParamsPOST } from './ModerationDeleteMsgHistoryParams';
import type { ModerationReportUserPOST } from './ModerationReportUserPOST';
import type { ReportHistoryPropsGET } from './ReportHistoryProps';
import type { ReportInfoParams } from './ReportInfoParams';
import type { ReportMessageHistoryParamsGET } from './ReportMessageHistoryParams';
@ -21,7 +22,7 @@ export type ModerationEndpoints = {
'/v1/moderation.user.reportedMessages': {
GET: (params: ReportMessageHistoryParamsGET) => PaginatedResult<{
user: Pick<IUser, 'username' | 'name' | '_id'> | null;
messages: Pick<IModerationReport, 'message' | 'ts' | 'room' | '_id'>[];
messages: Pick<MessageReport, 'message' | 'ts' | 'room' | '_id'>[];
}>;
};
'/v1/moderation.user.deleteReportedMessages': {
@ -40,4 +41,7 @@ export type ModerationEndpoints = {
report: IModerationReport | null;
};
};
'/v1/moderation.reportUser': {
POST: (params: ModerationReportUserPOST) => void;
};
};

Loading…
Cancel
Save