UNPKG

@dwp/govuk-casa

Version:

A framework for building GOVUK Collect-And-Submit-Applications

356 lines (319 loc) 11.2 kB
import MutableRouter from "../lib/MutableRouter.js"; import skipWaypointMiddlewareFactory from "../middleware/skip-waypoint.js"; import steerJourneyMiddlewareFactory from "../middleware/steer-journey.js"; import sanitiseFieldsMiddlewareFactory from "../middleware/sanitise-fields.js"; import gatherFieldsMiddlewareFactory from "../middleware/gather-fields.js"; import validateFieldsMiddlewareFactory from "../middleware/validate-fields.js"; import progressJourneyMiddlewareFactory from "../middleware/progress-journey.js"; import waypointUrl from "../lib/waypoint-url.js"; import logger from "../lib/logger.js"; import { resolveMiddlewareHooks } from "../lib/utils.js"; import { CONFIG_ERROR_VISIBILITY_ALWAYS } from "../lib/constants.js"; const log = logger("routes:journey"); /** * @param {import("../casa.js").GlobalHook} GlobalHook * @access private */ /** * @param {import("../casa.js").Page} Page * @access private */ /** * @param {import("../casa.js").Plan} Plan * @access private */ /** @typedef {import("../casa.js").ValidationError} ValidationError */ /** * @typedef {object} GovUkErrorObject * @property {string} text Error message text * @property {string} href Error message anchor href */ /** * @typedef {object} JourneyRouterOptions Options to configure static router * @property {GlobalHook[]} globalHooks Global hooks * @property {Page[]} pages Page definitions * @property {Plan} plan Plan * @property {Function[]} csrfMiddleware Middleware for providing CSRF controls */ const renderMiddlewareFactory = (view, contextFactory) => [ (req, res, next) => { res.render( view, { // Common template variables for both GET and POST requests inEditMode: req.casa.editMode, editOriginUrl: req.casa.editOrigin, editCancelUrl: generateEditCancelUrl( req.casa.editOrigin, req.casa.waypoint, ), activeContextId: req.casa.journeyContext.identity.id, ...contextFactory(req), }, (err, templateString) => { if (err) { next(err); } else { res.send(templateString); } }, ); }, ]; /** * Generate page validation errors * * @param {Record<string, ValidationError>} errors Object of page validation * errors * @param {import("express").Request} req Casa request object * @returns {GovUkErrorObject[]} Array of error objects */ export const generateGovukErrors = (errors, req) => Object.values(errors || {}).map(([error]) => ({ text: req.t(error.summary, error.variables), href: error.fieldHref, })); const generateEditCancelUrl = (editOrigin, waypoint) => { const url = new URL(editOrigin, "https://placeholder.test/"); url.searchParams.set("editcancel", waypoint); return `${url.pathname}${url.search}`; }; /** * Handle errorVisibility flag and function and return boolean * * @param {object} req Casa request object * @param {symbol | Function} errorVisibility ErrorVisibility config option * @returns {boolean} True if errorVisibility is "always" or function condition * true */ const resolveErrorVisibility = (req, errorVisibility) => typeof errorVisibility === "function" ? errorVisibility({ req }) : errorVisibility === CONFIG_ERROR_VISIBILITY_ALWAYS; /** * Create an instance of the router for all waypoints visited during a Journey * through the Plan. * * @param {JourneyRouterOptions} opts Options * @returns {MutableRouter} Router * @access private */ export default function journeyRouter({ globalHooks, pages, plan, csrfMiddleware, globalErrorVisibility, }) { // Router const router = new MutableRouter({ mergeParams: true }); // Special "_" route which handles redirecting the user between sub-apps // /app1/_/?refmount=app2&route=prev router.all("/_", (req, res) => { const mountUrl = `${req.baseUrl}/`; const refmount = req.query?.refmount; const route = req.query?.route; log.trace(`App root ${mountUrl}: refmount = ${refmount}, route = ${route}`); let redirectTo; const fallback = waypointUrl({ mountUrl, waypoint: plan.traverse(req.casa.journeyContext, { stopCondition: () => true, // we only need one; stop at the first })[0], journeyContext: req.casa.journeyContext, }); // If the refmount doesn't exist in our Plan, we can assume that the two // Plans are not linked in any way, i.e. the other Plan is simply redirecting // the user to our Plan and we don't intend to link back. if (!plan.getWaypoints().includes(refmount)) { redirectTo = fallback; } else if (route === "prev") { const routes = plan.traversePrevRoutes(req.casa.journeyContext, { startWaypoint: refmount, }); redirectTo = routes.length ? waypointUrl({ mountUrl, waypoint: routes[0].target, journeyContext: req.casa.journeyContext, }) : fallback; } else { const routes = plan.traverseNextRoutes(req.casa.journeyContext, { startWaypoint: refmount, }); if (routes[0].target !== null) { redirectTo = routes.length ? waypointUrl({ mountUrl, waypoint: routes[0].target, journeyContext: req.casa.journeyContext, }) : fallback; } else { redirectTo = fallback; } } // Carry over any params const url = new URL(redirectTo, "https://placeholder.test/"); const searchParams = new URLSearchParams(req.query); searchParams.delete("refmount"); searchParams.delete("route"); url.search = searchParams.toString(); redirectTo = `${url.pathname.replace(/\/+/g, "/")}${url.search}`; log.trace(`Redirect to ${redirectTo}`); // nosemgrep: nodejs_scan.javascript-redirect-rule-express_open_redirect return res.redirect(redirectTo); }); // Create GET / POST routes for each page const commonMiddleware = [...csrfMiddleware]; for (const page of pages) { const { waypoint, view, hooks: pageHooks = [], fields, errorVisibility, } = page; const waypointPath = `/${waypoint}`; let commonWaypointMiddleware = [ (req, res, next) => { req.casa.waypoint = waypoint; res.locals.casa.waypoint = waypoint; next(); }, ]; if (plan.isSkippable(waypoint)) { log.info(`Configuring "${waypoint}" as a skippable waypoint`); commonWaypointMiddleware = [ ...commonWaypointMiddleware, ...skipWaypointMiddlewareFactory({ waypoint }), ]; } router.get( waypointPath, ...commonMiddleware, ...commonWaypointMiddleware, ...resolveMiddlewareHooks("journey.presteer", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...steerJourneyMiddlewareFactory({ waypoint, plan }), ...resolveMiddlewareHooks("journey.poststeer", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...resolveMiddlewareHooks("journey.prerender", waypointPath, [ ...globalHooks, ...pageHooks, ]), renderMiddlewareFactory(view, (req) => { const displayErrors = resolveErrorVisibility(req, globalErrorVisibility) || resolveErrorVisibility(req, errorVisibility); const errors = displayErrors && (req.casa.journeyContext.getValidationErrorsForPageByField( waypoint, ) ?? Object.create(null)); const govukErrors = displayErrors && generateGovukErrors(errors, req); return { formUrl: waypointUrl({ mountUrl: `${req.baseUrl}/`, waypoint, journeyContext: req.casa.journeyContext, }), formData: req.casa.journeyContext.getDataForPage(waypoint), formErrors: Object.keys(errors).length && displayErrors ? errors : null, formErrorsGovukArray: govukErrors.length && displayErrors ? govukErrors : null, }; }), ); router.post( waypointPath, ...commonMiddleware, ...commonWaypointMiddleware, ...resolveMiddlewareHooks("journey.presteer", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...steerJourneyMiddlewareFactory({ waypoint, plan }), ...resolveMiddlewareHooks("journey.poststeer", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...resolveMiddlewareHooks("journey.presanitise", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...sanitiseFieldsMiddlewareFactory({ waypoint, fields }), ...resolveMiddlewareHooks("journey.postsanitise", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...resolveMiddlewareHooks("journey.pregather", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...gatherFieldsMiddlewareFactory({ waypoint, fields }), ...resolveMiddlewareHooks("journey.postgather", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...resolveMiddlewareHooks("journey.prevalidate", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...validateFieldsMiddlewareFactory({ waypoint, fields, plan }), ...resolveMiddlewareHooks("journey.postvalidate", waypointPath, [ ...globalHooks, ...pageHooks, ]), // If there were validation errors, jump out of this route and into the // next, where the errors will be rendered (req, res, next) => req.casa.journeyContext.hasValidationErrorsForPage(waypoint) ? next("route") : next(), ...resolveMiddlewareHooks("journey.preredirect", waypointPath, [ ...globalHooks, ...pageHooks, ]), ...progressJourneyMiddlewareFactory({ waypoint, plan }), ); router.post( waypointPath, ...resolveMiddlewareHooks("journey.prerender", waypointPath, [ ...globalHooks, ...pageHooks, ]), renderMiddlewareFactory(view, (req) => { const errors = req.casa.journeyContext.getValidationErrorsForPageByField(waypoint) ?? Object.create(null); // This is a convenience for the template. The `govukErrorSummary` macro // requires the errors be in a particular format, so here we provide our // errors in that format. // Where there are multiple errors against a particular field, only the // first one is shown. // Disabling security/detect-object-injection rule because both `errors` // and the `k` property are known entities const govukErrors = generateGovukErrors(errors, req); return { formUrl: waypointUrl({ mountUrl: `${req.baseUrl}/`, waypoint, journeyContext: req.casa.journeyContext, }), formData: req.body, formErrors: Object.keys(errors).length ? errors : null, formErrorsGovukArray: govukErrors.length ? govukErrors : null, }; }), ); } return router; }