server/contactUsApi.ts (166 lines of code) (raw):
import { captureMessage } from '@sentry/node';
import type { Request, Response } from 'express';
import fetch from 'node-fetch';
import { contactUsConfig } from '../shared/contactUsConfig';
import type { ContactUsReq } from '../shared/contactUsTypes';
import {
validateBase64FileSize,
validateImageFileExtension,
} from '../shared/fileUploadUtils';
import { isEmail } from '../shared/validationUtils';
import { getContactUsAPIHostAndKey } from './apiGatewayDiscovery';
import { log } from './log';
import { recaptchaConfigPromise } from './recaptchaConfig';
export const contactUsFormHandler = async (req: Request, res: Response) => {
const validBody = await parseAndValidate(req.body);
if (!validBody) {
// This could indicate we have a bug in our code or an external system is making invalid requests to this endpoint
const errorMessage = `Could not parse and validate Contact Us request body.`;
log.error(errorMessage);
captureMessage(errorMessage);
return res.status(400).send();
}
const apiConfig = await getContactUsAPIHostAndKey();
if (!apiConfig) {
const errorMessage = 'Could not obtain contact-us-api host/key.';
log.error(errorMessage);
captureMessage(errorMessage);
return res.status(500).send();
}
fetch(apiConfig.host, {
method: 'POST',
body: JSON.stringify(validBody),
headers: {
'Content-Type': 'application/json',
'x-api-key': apiConfig.apiKey,
},
})
.then((contactUsAPIResponse) => {
if (!contactUsAPIResponse.ok) {
const errorMessage = `Unexpected error from contact-us-api endpoint. ${contactUsAPIResponse.status} ${contactUsAPIResponse.statusText}`;
log.error(errorMessage);
captureMessage(errorMessage);
}
res.status(contactUsAPIResponse.status).send();
})
.catch((error) => {
const errorMessage =
'Unexpected error when trying to contact contact-us-api endpoint.';
log.error(errorMessage, error);
captureMessage(errorMessage);
res.status(500).send();
});
};
const parseAndValidate = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- assume we don't know the range of possible types for the body argument?
body: any,
): Promise<ContactUsReq | undefined> => {
try {
const bodyAsJson = body ? JSON.parse(body) : '{}';
const isBodyValid = await validateContactUsFormBody(bodyAsJson);
return isBodyValid ? buildContactUsReqBody(bodyAsJson) : undefined;
} catch {
return undefined;
}
};
const validateCaptchaToken = async (token: string) => {
const captchaConfigPromise = await recaptchaConfigPromise;
if (!captchaConfigPromise) {
captureMessage('Could not retrieve recaptcha config');
return false;
}
const recaptchaSecret = captchaConfigPromise?.secretKey;
const captchaValidationResponse = await fetch(
'https://www.google.com/recaptcha/api/siteverify',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `secret=${recaptchaSecret}&response=${token}`,
},
);
const json = await captchaValidationResponse.json();
return json.success;
};
const validateFileAttachment = (fileName: string, base64String: string) =>
validateBase64FileSize(base64String) &&
validateImageFileExtension(fileName);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- assume we don't know the range of possible types for the body argument?
const validateContactUsFormBody = async (body: any): Promise<boolean> =>
body &&
body.topic &&
validateTopics(body.topic, body.subtopic, body.subsubtopic) &&
body.name &&
body.email &&
isEmail(body.email) &&
body.subject &&
body.message &&
body.captchaToken &&
(await validateCaptchaToken(body.captchaToken)) &&
(body.attachment
? validateFileAttachment(body.attachment.name, body.attachment.contents)
: true);
const validateTopics = (
reqTopic: unknown,
reqSubtopic: unknown,
reqSubsubtopic: unknown,
): boolean => {
// Validate topic
const topic = contactUsConfig.find(
(topicEntry) => topicEntry.id === reqTopic,
);
if (!topic || topic.noForm) {
return false;
}
// Validate subtopic
const subtopicList = topic.subtopics;
if (subtopicList) {
if (!reqSubtopic) {
return false;
}
const subtopic = subtopicList.find(
(subtopicEntry) => subtopicEntry.id === reqSubtopic,
);
if (!subtopic || subtopic.noForm) {
return false;
}
// Validate subsubtopic
const subsubtopicList = subtopic.subsubtopics;
if (subsubtopicList) {
if (!reqSubsubtopic) {
return false;
}
const subsubtopic = subsubtopicList.find(
(subsubtopicEntry) => subsubtopicEntry.id === reqSubsubtopic,
);
if (!subsubtopic || subsubtopic.noForm) {
return false;
}
} else if (reqSubsubtopic) {
return false;
}
} else if (reqSubtopic || reqSubsubtopic) {
return false;
}
return true;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- assume we don't know the range of possible types for the body argument?
const buildContactUsReqBody = (body: any): ContactUsReq => {
const attachment =
body.attachment?.name && body.attachment?.contents
? {
name: body.attachment.name,
contents: body.attachment.contents,
}
: undefined;
return {
topic: body.topic,
...(body.subtopic && {
subtopic: body.subtopic,
}),
...(body.subsubtopic && {
subsubtopic: body.subsubtopic,
}),
name: (body.name as string).substr(0, 50),
email: (body.email as string).substr(0, 50),
subject: (body.subject as string).substr(0, 100),
message: (body.message as string).substr(0, 2500),
...(attachment && {
attachment,
}),
};
};