247 lines
7.7 KiB
JavaScript
247 lines
7.7 KiB
JavaScript
import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards";
|
|
import { consoleLogger } from "../../../actions/core/logger";
|
|
import removeContactUsEmailLinks from "../../../lib/security/contactus-email-links";
|
|
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
|
|
|
const TEMPLATE_IDS = {
|
|
en: "69228c5f-8500-40ff-a6c5-95a9434c3ec8",
|
|
cy: "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3"
|
|
};
|
|
|
|
const REFERENCE = "PEDW-CONTACT-US";
|
|
|
|
const allowedSubjects = {
|
|
plq: [
|
|
"Questions about Planning legislation or policy",
|
|
"Planning legislation query",
|
|
"Ymholiad deddfwriaeth gynllunio"
|
|
],
|
|
ipq: [
|
|
"Questions or comments about applications for major infrastructure projects",
|
|
"Infrastructure procedure query",
|
|
"Ymholiad gweithdrefn seilwaith"
|
|
],
|
|
pcpq: [
|
|
"Questions or comments about planning and environmental appeals",
|
|
"Planning casework procedure query",
|
|
"Ymholiad gweithdrefn gwaith achos cynllunio"
|
|
],
|
|
pi: ["Query about using the Casework Portal", "Portal Issues", "Materion Porth"],
|
|
appadvice: [
|
|
"Advice How to submit an appeal",
|
|
"Appeal submission advice",
|
|
"Cyngor cyflwyno apêl"
|
|
],
|
|
repadvice: [
|
|
"Advice How to comment on an appeal or application",
|
|
"Representation submission advice",
|
|
"Cyngor cyflwyno sylwadau"
|
|
],
|
|
ceq: [
|
|
"Queries about forthcoming Hearing or Inquiry event",
|
|
"Casework Events Query",
|
|
"Ymholiad Digwyddiadau Gwaith Achos"
|
|
],
|
|
other: ["Other enquiry for PEDW", "Any Other Enquiries", "Unrhyw Ymholiadau Eraill"],
|
|
postdec: [
|
|
"Issues or questions about a case after the decision is issued",
|
|
"Post Decision",
|
|
"Ar ôl Penderfyniad"
|
|
],
|
|
comp: [
|
|
"General Complaints of Compliments",
|
|
"Complaints/Complements",
|
|
"Cwynion/Canmoliaethau"
|
|
]
|
|
};
|
|
|
|
const allowedFields = new Set([
|
|
"contactEmail",
|
|
"contactSubject",
|
|
"contactbody",
|
|
"typeOfContact",
|
|
"language"
|
|
]);
|
|
|
|
const emailRegex =
|
|
/^(?!.*(?:.+\.)?(?:burpcollaborator\.net|interactsh\.com|mailinator\.com|10minutemail\.com|yopmail\.com|trashmail\.com)$)(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:.+)])$/;
|
|
|
|
// Origin checking is browser defence in depth only. It is not authentication and
|
|
// does not prevent a direct HTTP client from attempting abuse.
|
|
const hasTrustedOrigin = (req) => {
|
|
const requestOrigin = req?.headers?.origin;
|
|
|
|
if (typeof requestOrigin !== "string" || requestOrigin === "null") {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const requestUrl = new URL(requestOrigin);
|
|
if (requestOrigin !== requestUrl.origin) {
|
|
return false;
|
|
}
|
|
|
|
const trustedOrigins = [process.env.NEXTAUTH_URL, process.env.CY_API_ROOT]
|
|
.filter(
|
|
(configuredUrl) =>
|
|
typeof configuredUrl === "string" && configuredUrl.length > 0
|
|
)
|
|
.map((configuredUrl) => new URL(configuredUrl).origin);
|
|
|
|
return trustedOrigins.includes(requestUrl.origin);
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const validationError = (res, code, message) =>
|
|
respondError(res, { status: 400, code, message });
|
|
|
|
const isPlainObject = (value) => {
|
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === null || prototype?.constructor?.name === "Object";
|
|
};
|
|
|
|
export default async function ApiProxy(req, res) {
|
|
if (req.method !== "POST") {
|
|
res.setHeader("Allow", "POST");
|
|
return respondError(res, {
|
|
status: 405,
|
|
code: "METHOD_NOT_ALLOWED",
|
|
message: "Method not allowed"
|
|
});
|
|
}
|
|
|
|
const contentType = req.headers?.["content-type"];
|
|
if (
|
|
typeof contentType !== "string" ||
|
|
contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json"
|
|
) {
|
|
return respondError(res, {
|
|
status: 415,
|
|
code: "UNSUPPORTED_MEDIA_TYPE",
|
|
message: "Content-Type must be application/json"
|
|
});
|
|
}
|
|
|
|
if (!hasTrustedOrigin(req)) {
|
|
return respondError(res, {
|
|
status: 403,
|
|
code: "FORBIDDEN",
|
|
message: "Request not permitted"
|
|
});
|
|
}
|
|
|
|
const requestBody = req.body;
|
|
if (!isPlainObject(requestBody)) {
|
|
return validationError(
|
|
res,
|
|
"INVALID_REQUEST_BODY",
|
|
"Request body must be a JSON object"
|
|
);
|
|
}
|
|
|
|
const requestFields = Object.keys(requestBody);
|
|
if (
|
|
requestFields.length !== allowedFields.size ||
|
|
requestFields.some((field) => !allowedFields.has(field))
|
|
) {
|
|
return validationError(
|
|
res,
|
|
"INVALID_REQUEST_FIELDS",
|
|
"Request must contain exactly the supported fields"
|
|
);
|
|
}
|
|
|
|
const contactEmail = sanitizeString(requestBody.contactEmail);
|
|
const contactSubject = sanitizeString(requestBody.contactSubject);
|
|
const contactBody = sanitizeString(requestBody.contactbody);
|
|
const typeOfContact = requestBody.typeOfContact;
|
|
const language = requestBody.language;
|
|
|
|
if (
|
|
typeof requestBody.contactEmail !== "string" ||
|
|
!isNonEmptyString(contactEmail) ||
|
|
contactEmail.length > 200 ||
|
|
!emailRegex.test(contactEmail)
|
|
) {
|
|
return validationError(
|
|
res,
|
|
"INVALID_CONTACT_EMAIL",
|
|
"contactEmail must be a valid email address of 200 characters or fewer"
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof typeOfContact !== "string" ||
|
|
!Object.prototype.hasOwnProperty.call(allowedSubjects, typeOfContact)
|
|
) {
|
|
return validationError(
|
|
res,
|
|
"INVALID_CONTACT_TYPE",
|
|
"typeOfContact is not supported"
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof requestBody.contactbody !== "string" ||
|
|
!isNonEmptyString(contactBody) ||
|
|
contactBody.length > 800
|
|
) {
|
|
return validationError(
|
|
res,
|
|
"INVALID_CONTACT_BODY",
|
|
"contactbody must contain between 1 and 800 characters"
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof requestBody.contactSubject !== "string" ||
|
|
!allowedSubjects[typeOfContact].includes(contactSubject)
|
|
) {
|
|
return validationError(
|
|
res,
|
|
"INVALID_CONTACT_SUBJECT",
|
|
"contactSubject does not match typeOfContact"
|
|
);
|
|
}
|
|
|
|
if (language !== "en" && language !== "cy") {
|
|
return validationError(
|
|
res,
|
|
"INVALID_LANGUAGE",
|
|
"language must be either en or cy"
|
|
);
|
|
}
|
|
|
|
const personalisation = {
|
|
contactEmail,
|
|
contactSubject,
|
|
contactbody: removeContactUsEmailLinks(contactBody),
|
|
typeOfContact
|
|
};
|
|
|
|
const NotifyClient = require("notifications-node-client").NotifyClient;
|
|
const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY);
|
|
|
|
try {
|
|
await notifyClient.sendEmail(TEMPLATE_IDS[language], contactEmail, {
|
|
personalisation,
|
|
reference: REFERENCE
|
|
});
|
|
|
|
return respondSuccess(res, { sent: true });
|
|
} catch (error) {
|
|
consoleLogger(error);
|
|
return respondError(res, {
|
|
status: 400,
|
|
code: "CONTACT_US_CONFIRMATION_FAILED",
|
|
message: "Failed to send Contact Us confirmation email"
|
|
});
|
|
}
|
|
}
|