mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): report user endpoint (#51170)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type SendEmailArgs = {
|
||||
from: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
cc?: string;
|
||||
};
|
||||
|
||||
type SendEmail = (args: SendEmailArgs) => Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 ?? ''}`;
|
||||
};
|
||||
@@ -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, '<');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user