feat(api): report user endpoint (#51170)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Niraj Nandish
2023-10-11 19:19:05 +04:00
committed by GitHub
parent ad90b4ed4f
commit 70741db619
8 changed files with 269 additions and 4 deletions
+4 -2
View File
@@ -36,13 +36,15 @@ export class NodemailerProvider implements MailProvider {
* @param param.from Email address to send from.
* @param param.subject Email subject.
* @param param.text Email body (raw text only).
* @param param.cc [Optional] Email address to CC.
*/
async send({ to, from, subject, text }: SendEmailArgs) {
async send({ to, from, subject, text, cc }: SendEmailArgs) {
await this.transporter.sendMail({
from,
to,
subject,
text
text,
cc
});
}
}
+4 -2
View File
@@ -39,11 +39,13 @@ export class SESProvider implements MailProvider {
* @param param.from Email address to send from.
* @param param.subject Email subject.
* @param param.text Email body (raw text only).
* @param param.cc [Optional] Email address to CC.
*/
async send({ to, from, subject, text }: SendEmailArgs) {
async send({ to, from, subject, text, cc }: SendEmailArgs) {
const opts = new SendEmailCommand({
Destination: {
ToAddresses: [to]
ToAddresses: [to],
CcAddresses: cc ? [cc] : []
},
Message: {
Subject: {
+1
View File
@@ -12,6 +12,7 @@ export type SendEmailArgs = {
from: string;
subject: string;
text: string;
cc?: string;
};
type SendEmail = (args: SendEmailArgs) => Promise<void>;
+120
View File
@@ -305,6 +305,7 @@ describe('userRoutes', () => {
expect(user).toMatchObject(baseProgressData);
});
});
describe('/user/user-token', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.userToken.create({
@@ -518,6 +519,7 @@ describe('userRoutes', () => {
expect(tokenData.id).toBe(userToken);
});
test('GET returns a minimal user when all optional properties are missing', async () => {
// To get a minimal test user we first delete the existing one...
await fastifyTestInstance.prisma.user.deleteMany({
@@ -557,6 +559,113 @@ describe('userRoutes', () => {
expect(testuser).toStrictEqual(publicUser);
});
});
describe('/user/report-user', () => {
let sendEmailSpy: jest.SpyInstance;
beforeEach(() => {
sendEmailSpy = jest
.spyOn(fastifyTestInstance, 'sendEmail')
.mockImplementation(jest.fn());
});
afterEach(() => {
jest.clearAllMocks();
});
test('POST returns 400 for empty username', async () => {
const response = await superRequest('/user/report-user', {
method: 'POST',
setCookies
}).send({
username: '',
reportDescription: 'Test Report'
});
expect(response.statusCode).toBe(400);
expect(response.body).toStrictEqual({
type: 'danger',
message: 'flash.provide-username'
});
});
test('POST returns 400 for empty report', async () => {
const response = await superRequest('/user/report-user', {
method: 'POST',
setCookies
}).send({
username: 'darth-vader',
reportDescription: ''
});
expect(response.statusCode).toBe(400);
expect(response.body).toStrictEqual({
type: 'danger',
message: 'flash.provide-username'
});
});
test('POST sanitises report description', async () => {
await superRequest('/user/report-user', {
method: 'POST',
setCookies
}).send({
username: 'darth-vader',
reportDescription:
'<script>const breath = "loud"</script>Luke, I am your father'
});
expect(sendEmailSpy).toBeCalledTimes(1);
expect(sendEmailSpy).toBeCalledWith(
expect.objectContaining({
text: expect.stringContaining(
'Report Details:\n\nLuke, I am your father'
)
})
);
});
test('POST returns 200 status code with "success" message', async () => {
const response = await superRequest('/user/report-user', {
method: 'POST',
setCookies
}).send({
username: 'darth-vader',
reportDescription: 'Luke, I am your father'
});
expect(sendEmailSpy).toBeCalledTimes(1);
expect(sendEmailSpy).toBeCalledWith({
from: 'team@freecodecamp.org',
to: 'support@freecodecamp.org',
cc: 'foo@bar.com',
subject: "Abuse Report: Reporting darth-vader's profile",
text: `
Hello Team,
This is to report the profile of darth-vader.
Report Details:
Luke, I am your father
Reported by:
Username:
Name:
Email: foo@bar.com
Thanks and regards,
`
});
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual({
type: 'info',
message: 'flash.report-sent',
variables: { email: 'foo@bar.com' }
});
});
});
});
describe('Unauthenticated user', () => {
@@ -584,5 +693,16 @@ describe('userRoutes', () => {
expect(response.statusCode).toBe(401);
});
});
describe('/user/report-user', () => {
test('POST returns 401 status code with error message', async () => {
const response = await superRequest('/user/report-user', {
method: 'POST',
setCookies
});
expect(response.statusCode).toBe(401);
});
});
});
});
+53
View File
@@ -16,6 +16,8 @@ import {
type ProgressTimestamp
} from '../utils/progress';
import { encodeUserToken } from '../utils/user-token';
import { trimTags } from '../utils/validation';
import { generateReportEmail } from '../utils/email-templates';
// Loopback creates a 64 character string for the user id, this customizes
// nanoid to do the same. Any unique key _should_ be fine, though.
@@ -174,6 +176,57 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
}
);
fastify.post(
'/user/report-user',
{
schema: schemas.reportUser,
preHandler: (req, _reply, done) => {
req.body.reportDescription = trimTags(req.body.reportDescription);
done();
}
},
async (req, reply) => {
try {
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
});
const { username, reportDescription: report } = req.body;
if (!username || !report || report === '') {
// NOTE: Do we want to log these instances?
void reply.code(400);
return {
type: 'danger',
message: 'flash.provide-username'
} as const;
}
await fastify.sendEmail({
from: 'team@freecodecamp.org',
to: 'support@freecodecamp.org',
cc: user.email,
subject: `Abuse Report: Reporting ${username}'s profile`,
text: generateReportEmail(user, username, report)
});
return {
type: 'info',
message: 'flash.report-sent',
variables: { email: user.email }
} as const;
} catch (err) {
fastify.log.error(err);
// TODO: redirect to the reported user's profile if there's an error
void reply.code(500);
return {
type: 'danger',
message:
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
} as const;
}
}
);
done();
};
+20
View File
@@ -383,6 +383,26 @@ export const schemas = {
})
}
},
reportUser: {
body: Type.Object({
username: Type.String(),
reportDescription: Type.String()
}),
response: {
200: Type.Object({
type: Type.Literal('info'),
message: Type.Literal('flash.report-sent'),
variables: Type.Object({
email: Type.String()
})
}),
400: Type.Object({
type: Type.Literal('danger'),
message: Type.Literal('flash.provide-username')
}),
500: generic500
}
},
// Deprecated endpoints:
deprecatedEndpoints: {
response: {
+32
View File
@@ -0,0 +1,32 @@
import { user } from '@prisma/client';
/**
* Generates an email template for reporting a user profile.
* @param reporter - The user who is reporting the profile.
* @param abuser - The username of the user being reported.
* @param reportDesc - The description of the report.
* @returns - The generated email template.
*/
export const generateReportEmail = (
reporter: user,
abuser: string,
reportDesc: string
) => {
return `
Hello Team,
This is to report the profile of ${abuser}.
Report Details:
${reportDesc}
Reported by:
Username: ${reporter.username}
Name: ${reporter.name ?? ''}
Email: ${reporter.email}
Thanks and regards,
${reporter.name ?? ''}`;
};
+35
View File
@@ -10,3 +10,38 @@ import { ObjectId } from 'mongodb';
*/
export const isObjectID = (id?: string): boolean =>
id ? ObjectId.isValid(id) : false;
// Refer : http://stackoverflow.com/a/430240/1932901
/**
* Sanitizes a input by removing HTML tags.
* @deprecated
* @param value A string to sanitize.
* @returns A string with HTML tags removed.
*/
export const trimTags = (value: string): string => {
const tagBody = '(?:[^"\'>]|"[^"]*"|\'[^\']*\')*';
const tagOrComment = new RegExp(
'<(?:' +
// Comment body.
'!--(?:(?:-*[^->])*--+|-?)' +
// Special "raw text" elements whose content should be elided.
'|script\\b' +
tagBody +
'>[\\s\\S]*?</script\\s*' +
'|style\\b' +
tagBody +
'>[\\s\\S]*?</style\\s*' +
// Regular name
'|/?[a-z]' +
tagBody +
')>',
'gi'
);
let rawValue;
do {
rawValue = value;
value = value.replace(tagOrComment, '');
} while (value !== rawValue);
return value.replace(/</g, '&lt;');
};