@dwp/govuk-casa 8.7.12 → 8.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/dist/casa.js +2 -1
  2. package/dist/casa.js.map +1 -0
  3. package/dist/lib/CasaTemplateLoader.js +1 -0
  4. package/dist/lib/CasaTemplateLoader.js.map +1 -0
  5. package/dist/lib/JourneyContext.d.ts +1 -1
  6. package/dist/lib/JourneyContext.js +2 -1
  7. package/dist/lib/JourneyContext.js.map +1 -0
  8. package/dist/lib/MutableRouter.js +1 -0
  9. package/dist/lib/MutableRouter.js.map +1 -0
  10. package/dist/lib/Plan.d.ts +2 -1
  11. package/dist/lib/Plan.js +4 -3
  12. package/dist/lib/Plan.js.map +1 -0
  13. package/dist/lib/ValidationError.js +1 -0
  14. package/dist/lib/ValidationError.js.map +1 -0
  15. package/dist/lib/ValidatorFactory.d.ts +2 -2
  16. package/dist/lib/ValidatorFactory.js +3 -2
  17. package/dist/lib/ValidatorFactory.js.map +1 -0
  18. package/dist/lib/configuration-ingestor.js +1 -0
  19. package/dist/lib/configuration-ingestor.js.map +1 -0
  20. package/dist/lib/configure.js +2 -1
  21. package/dist/lib/configure.js.map +1 -0
  22. package/dist/lib/end-session.js +1 -0
  23. package/dist/lib/end-session.js.map +1 -0
  24. package/dist/lib/field.js +1 -0
  25. package/dist/lib/field.js.map +1 -0
  26. package/dist/lib/index.js +1 -0
  27. package/dist/lib/index.js.map +1 -0
  28. package/dist/lib/logger.js +1 -0
  29. package/dist/lib/logger.js.map +1 -0
  30. package/dist/lib/mount.js +3 -2
  31. package/dist/lib/mount.js.map +1 -0
  32. package/dist/lib/nunjucks-filters.js +1 -0
  33. package/dist/lib/nunjucks-filters.js.map +1 -0
  34. package/dist/lib/nunjucks.js +1 -0
  35. package/dist/lib/nunjucks.js.map +1 -0
  36. package/dist/lib/utils.d.ts +45 -27
  37. package/dist/lib/utils.js +105 -67
  38. package/dist/lib/utils.js.map +1 -0
  39. package/dist/lib/validators/dateObject.js +4 -3
  40. package/dist/lib/validators/dateObject.js.map +1 -0
  41. package/dist/lib/validators/email.js +1 -0
  42. package/dist/lib/validators/email.js.map +1 -0
  43. package/dist/lib/validators/inArray.js +1 -0
  44. package/dist/lib/validators/inArray.js.map +1 -0
  45. package/dist/lib/validators/index.js +1 -0
  46. package/dist/lib/validators/index.js.map +1 -0
  47. package/dist/lib/validators/nino.js +1 -0
  48. package/dist/lib/validators/nino.js.map +1 -0
  49. package/dist/lib/validators/postalAddressObject.d.ts +2 -2
  50. package/dist/lib/validators/postalAddressObject.js +2 -1
  51. package/dist/lib/validators/postalAddressObject.js.map +1 -0
  52. package/dist/lib/validators/regex.js +1 -0
  53. package/dist/lib/validators/regex.js.map +1 -0
  54. package/dist/lib/validators/required.js +1 -0
  55. package/dist/lib/validators/required.js.map +1 -0
  56. package/dist/lib/validators/strlen.js +1 -0
  57. package/dist/lib/validators/strlen.js.map +1 -0
  58. package/dist/lib/validators/wordCount.js +1 -0
  59. package/dist/lib/validators/wordCount.js.map +1 -0
  60. package/dist/lib/waypoint-url.js +1 -0
  61. package/dist/lib/waypoint-url.js.map +1 -0
  62. package/dist/middleware/body-parser.js +1 -0
  63. package/dist/middleware/body-parser.js.map +1 -0
  64. package/dist/middleware/csrf.js +1 -0
  65. package/dist/middleware/csrf.js.map +1 -0
  66. package/dist/middleware/data.js +1 -0
  67. package/dist/middleware/data.js.map +1 -0
  68. package/dist/middleware/gather-fields.js +1 -0
  69. package/dist/middleware/gather-fields.js.map +1 -0
  70. package/dist/middleware/i18n.js +1 -0
  71. package/dist/middleware/i18n.js.map +1 -0
  72. package/dist/middleware/post.js +1 -0
  73. package/dist/middleware/post.js.map +1 -0
  74. package/dist/middleware/pre.js +1 -0
  75. package/dist/middleware/pre.js.map +1 -0
  76. package/dist/middleware/progress-journey.js +1 -0
  77. package/dist/middleware/progress-journey.js.map +1 -0
  78. package/dist/middleware/sanitise-fields.js +1 -0
  79. package/dist/middleware/sanitise-fields.js.map +1 -0
  80. package/dist/middleware/serve-first-waypoint.js +1 -0
  81. package/dist/middleware/serve-first-waypoint.js.map +1 -0
  82. package/dist/middleware/session.js +1 -0
  83. package/dist/middleware/session.js.map +1 -0
  84. package/dist/middleware/skip-waypoint.js +1 -0
  85. package/dist/middleware/skip-waypoint.js.map +1 -0
  86. package/dist/middleware/steer-journey.js +1 -0
  87. package/dist/middleware/steer-journey.js.map +1 -0
  88. package/dist/middleware/strip-proxy-path.js +1 -0
  89. package/dist/middleware/strip-proxy-path.js.map +1 -0
  90. package/dist/middleware/validate-fields.js +1 -0
  91. package/dist/middleware/validate-fields.js.map +1 -0
  92. package/dist/mjs/esm-wrapper.js +10 -15
  93. package/dist/routes/ancillary.js +1 -0
  94. package/dist/routes/ancillary.js.map +1 -0
  95. package/dist/routes/journey.js +1 -0
  96. package/dist/routes/journey.js.map +1 -0
  97. package/dist/routes/static.js +1 -0
  98. package/dist/routes/static.js.map +1 -0
  99. package/locales/cy/error.json +1 -1
  100. package/locales/en/error.json +1 -1
  101. package/package.json +16 -15
  102. package/src/casa.js +320 -0
  103. package/src/lib/CasaTemplateLoader.js +104 -0
  104. package/src/lib/JourneyContext.js +783 -0
  105. package/src/lib/MutableRouter.js +310 -0
  106. package/src/lib/Plan.js +624 -0
  107. package/src/lib/ValidationError.js +163 -0
  108. package/src/lib/ValidatorFactory.js +105 -0
  109. package/src/lib/configuration-ingestor.js +457 -0
  110. package/src/lib/configure.js +202 -0
  111. package/src/lib/dirname.cjs +1 -0
  112. package/src/lib/end-session.js +45 -0
  113. package/src/lib/field.js +456 -0
  114. package/src/lib/index.js +33 -0
  115. package/src/lib/logger.js +16 -0
  116. package/src/lib/mount.js +127 -0
  117. package/src/lib/nunjucks-filters.js +150 -0
  118. package/src/lib/nunjucks.js +53 -0
  119. package/src/lib/utils.js +232 -0
  120. package/src/lib/validators/dateObject.js +169 -0
  121. package/src/lib/validators/email.js +55 -0
  122. package/src/lib/validators/inArray.js +81 -0
  123. package/src/lib/validators/index.js +24 -0
  124. package/src/lib/validators/nino.js +57 -0
  125. package/src/lib/validators/postalAddressObject.js +162 -0
  126. package/src/lib/validators/regex.js +48 -0
  127. package/src/lib/validators/required.js +74 -0
  128. package/src/lib/validators/strlen.js +66 -0
  129. package/src/lib/validators/wordCount.js +70 -0
  130. package/src/lib/waypoint-url.js +93 -0
  131. package/src/middleware/body-parser.js +31 -0
  132. package/src/middleware/csrf.js +29 -0
  133. package/src/middleware/data.js +105 -0
  134. package/src/middleware/dirname.cjs +1 -0
  135. package/src/middleware/gather-fields.js +51 -0
  136. package/src/middleware/i18n.js +106 -0
  137. package/src/middleware/post.js +61 -0
  138. package/src/middleware/pre.js +91 -0
  139. package/src/middleware/progress-journey.js +92 -0
  140. package/src/middleware/sanitise-fields.js +58 -0
  141. package/src/middleware/serve-first-waypoint.js +28 -0
  142. package/src/middleware/session.js +129 -0
  143. package/src/middleware/skip-waypoint.js +46 -0
  144. package/src/middleware/steer-journey.js +78 -0
  145. package/src/middleware/strip-proxy-path.js +56 -0
  146. package/src/middleware/validate-fields.js +84 -0
  147. package/src/routes/ancillary.js +29 -0
  148. package/src/routes/dirname.cjs +1 -0
  149. package/src/routes/journey.js +212 -0
  150. package/src/routes/static.js +77 -0
  151. package/views/casa/components/character-count/README.md +10 -0
  152. package/views/casa/components/character-count/template.njk +6 -2
  153. package/views/casa/components/checkboxes/README.md +43 -34
  154. package/views/casa/components/checkboxes/template.njk +8 -7
  155. package/views/casa/components/date-input/README.md +11 -1
  156. package/views/casa/components/date-input/template.njk +6 -4
  157. package/views/casa/components/input/README.md +9 -0
  158. package/views/casa/components/input/template.njk +6 -2
  159. package/views/casa/components/postal-address-object/README.md +10 -0
  160. package/views/casa/components/postal-address-object/template.njk +20 -5
  161. package/views/casa/components/radios/README.md +49 -24
  162. package/views/casa/components/radios/template.njk +6 -3
  163. package/views/casa/components/select/README.md +65 -0
  164. package/views/casa/components/select/macro.njk +3 -0
  165. package/views/casa/components/select/template.njk +49 -0
  166. package/views/casa/components/textarea/README.md +9 -0
  167. package/views/casa/components/textarea/template.njk +6 -2
  168. package/views/casa/layouts/journey.njk +1 -1
@@ -0,0 +1,129 @@
1
+ // A last-modified cookie is used to control whether the end user sees a
2
+ // session-timeout page, or they are simply given a new session without
3
+ // interrupting their journey.
4
+ import expressSession, { MemoryStore } from 'express-session';
5
+ import logger from '../lib/logger.js';
6
+ import { validateUrlPath } from '../lib/utils.js';
7
+
8
+ const log = logger('middleware:session');
9
+
10
+ const sessionExpiryMiddleware = (
11
+ ttl,
12
+ getCookie,
13
+ touchCookie,
14
+ removeCookie,
15
+ ) => (req, res, next) => {
16
+ const lastModified = getCookie(req);
17
+ const age = Math.floor(Date.now() * 0.001) - lastModified;
18
+
19
+ if (lastModified === 0) {
20
+ // New session, or grace period cookie no longer available after
21
+ // expiring; generate a new session, and create grace-period cookie.
22
+ // This will invalidate any CSRF tokens, so by letting the request POST
23
+ // requests through the user may see a 500 error response.
24
+ log.info('Session is new, or grace period has expired. Regenerating session.');
25
+ req.session.regenerate((err) => {
26
+ if (err) {
27
+ next(err);
28
+ } else {
29
+ touchCookie(res);
30
+ if (req.method === 'POST') {
31
+ log.info('The CSRF token for this POST request will now be invalid for this regenerated session. Redirecting to app mount point.');
32
+ res.redirect(302, validateUrlPath(`${req.baseUrl}/`));
33
+ } else {
34
+ next();
35
+ }
36
+ }
37
+ });
38
+ } else if (age > ttl) {
39
+ // Cookie has become stale and server session will have been removed;
40
+ // redirect to session-timeout
41
+ log.info('Session has timed out within grace period. Destroying session and redirecting to timeout page.');
42
+ const language = req.session.language ?? 'en';
43
+ req.session.destroy((err) => {
44
+ if (err) {
45
+ next(err);
46
+ } else {
47
+ removeCookie(res);
48
+ const params = new URLSearchParams({
49
+ referrer: req.originalUrl,
50
+ lang: language,
51
+ });
52
+ /* eslint-disable-next-line prefer-template */
53
+ res.redirect(302, validateUrlPath(`${req.baseUrl}/session-timeout`) + `?${params.toString()}`);
54
+ }
55
+ });
56
+ } else {
57
+ // Touch cookie and continue
58
+ touchCookie(res);
59
+ next();
60
+ }
61
+ };
62
+
63
+ // 3 middleware:
64
+ // - set the session cookie
65
+ // - parse request cookies
66
+ // - handle expiry of server-side session
67
+ export default function sessionMiddleware({
68
+ cookieParserMiddleware,
69
+ secret,
70
+ name,
71
+ secure,
72
+ ttl,
73
+ cookieSameSite = true,
74
+ cookiePath = '/',
75
+ store = new MemoryStore(),
76
+ }) {
77
+ const commonCookieOptions = {
78
+ httpOnly: true,
79
+ path: cookiePath,
80
+ secure,
81
+ };
82
+
83
+ if (cookieSameSite !== false) {
84
+ commonCookieOptions.sameSite = cookieSameSite === true ? 'Strict' : cookieSameSite;
85
+ }
86
+
87
+ const ttlGrace = 1800; // user will see session-timeout if session expires within 30mins
88
+ const touchCookieName = `${name}.t`;
89
+ const touchCookieOptions = {
90
+ ...commonCookieOptions,
91
+ maxAge: (ttl + ttlGrace) * 1000,
92
+ signed: true,
93
+ };
94
+
95
+ const getCookie = (req) => {
96
+ // Disabled eslint as `touchCookieName` is a constant, known value
97
+ /* eslint-disable-next-line security/detect-object-injection */
98
+ const lastModified = Date.parse(String(req.signedCookies[touchCookieName] ?? '1970-01-01T00:00:00+0000'));
99
+ return Number.isNaN(lastModified) ? 0 : Math.floor(lastModified * 0.001);
100
+ }
101
+
102
+ const touchCookie = (res) => {
103
+ // Touch cookie expiry is a short period after the session ttl. This gives
104
+ // a small period of time where a user will see the session-timeout message,
105
+ // which is important to avoid the confusion of simply being redirected back
106
+ // to the start of their journey.
107
+ res.cookie(touchCookieName, (new Date(Date.now())).toUTCString(), touchCookieOptions);
108
+ }
109
+
110
+ const removeCookie = (res) => {
111
+ res.clearCookie(touchCookieName, touchCookieOptions);
112
+ }
113
+
114
+ return [
115
+ expressSession({
116
+ secret,
117
+ name,
118
+ saveUninitialized: false,
119
+ resave: false,
120
+ cookie: {
121
+ ...commonCookieOptions,
122
+ maxAge: null,
123
+ },
124
+ store,
125
+ }),
126
+ cookieParserMiddleware,
127
+ sessionExpiryMiddleware(ttl, getCookie, touchCookie, removeCookie),
128
+ ];
129
+ }
@@ -0,0 +1,46 @@
1
+ // Mark a waypoint as skipped
2
+
3
+ import lodash from 'lodash';
4
+ import JourneyContext from '../lib/JourneyContext.js';
5
+ import waypointUrl from '../lib/waypoint-url.js';
6
+ import logger from '../lib/logger.js';
7
+
8
+ const { has } = lodash;
9
+
10
+ const log = logger('middleware:skip-waypoint');
11
+
12
+ export default ({
13
+ waypoint,
14
+ }) => [
15
+ (req, res, next) => {
16
+ if (!has(req.query, 'skipto')) {
17
+ return next();
18
+ }
19
+ const skipTo = String(req.query.skipto);
20
+
21
+ // Inject a special `__skipped__` attribute into this waypoint's data
22
+ log.info(`Marking waypoint "${waypoint}" as skipped`);
23
+ req.casa.journeyContext.clearValidationErrorsForPage(waypoint);
24
+ req.casa.journeyContext.setDataForPage(waypoint, {
25
+ __skipped__: true,
26
+ });
27
+ JourneyContext.putContext(req.session, req.casa.journeyContext);
28
+
29
+ const redirectUrl = waypointUrl({
30
+ mountUrl: `${req.baseUrl}/`,
31
+ waypoint: skipTo,
32
+ edit: req.casa.editMode,
33
+ editOrigin: req.casa.editOrigin,
34
+ journeyContext: req.casa.journeyContext,
35
+ });
36
+ log.debug(`Will redirect to "${redirectUrl}" after skipping "${waypoint}"`);
37
+
38
+ return req.session.save((err) => {
39
+ if (err) {
40
+ next(err);
41
+ } else {
42
+ res.redirect(302, redirectUrl);
43
+ }
44
+ });
45
+ },
46
+ ];
@@ -0,0 +1,78 @@
1
+ // This sits in front of all other middleware and prevents the user from
2
+ // "jumping ahead" in the Plan.
3
+
4
+ import waypointUrl from '../lib/waypoint-url.js';
5
+ import logger from '../lib/logger.js';
6
+
7
+ const log = logger('middleware:steer-journey');
8
+
9
+ /**
10
+ * @access private
11
+ * @typedef {import('../lib/Plan')} Plan
12
+ */
13
+
14
+ /**
15
+ * This sits in front of all other journey middleware and prevents the user from
16
+ * "jumping ahead" in the Plan.
17
+ *
18
+ * @param {object} obj Options
19
+ * @param {string} obj.waypoint Current waypoint
20
+ * @param {Plan} obj.plan CASA Plan
21
+ * @returns {void}
22
+ */
23
+ export default ({
24
+ waypoint,
25
+ plan,
26
+ }) => [
27
+ (req, res, next) => {
28
+ const mountUrl = `${req.baseUrl}/`;
29
+
30
+ // If the requested waypoint doesn't exist in the traversed journey, send
31
+ // the user back to the last good waypoint.
32
+ const traversed = plan.traverse(req.casa.journeyContext);
33
+ if (traversed.indexOf(waypoint) === -1) {
34
+ const redirectTo = traversed[traversed.length - 1];
35
+ log.trace(`Attempted to access "${waypoint}" when not in the journey; redirecting to "${redirectTo}"`);
36
+
37
+ return res.redirect(302, waypointUrl({
38
+ waypoint: redirectTo,
39
+ mountUrl,
40
+ journeyContext: req.casa.journeyContext,
41
+ edit: req.casa.editMode,
42
+ editOrigin: req.casa.editOrigin,
43
+ }));
44
+ }
45
+
46
+ // Edit mode
47
+ // Cannot be in edit mode if we're already on the `editorigin` URL
48
+ if (req.casa.editMode) {
49
+ const { pathname: currentPathname } = new URL(req.originalUrl, 'https://placeholder.test/');
50
+
51
+ if (req.casa.editOrigin === currentPathname) {
52
+ log.debug(`Disabling edit mode as we are on the edit origin (${req.casa.editOrigin})`);
53
+ req.casa.editMode = false;
54
+ req.casa.editOrigin = undefined;
55
+ }
56
+ }
57
+
58
+ // difficult: first waypoint on a Plan - how do we determine if there are
59
+ // other plans pointing at this one? and how do we determine if those others
60
+ // are part of a future plan, or a past one? Think we'll have to leave it up
61
+ // to the dev to add the back link for the first page in a Plan.
62
+
63
+ // Calculate URL for the "back" link
64
+ const [prevRoute] = plan.traversePrevRoutes(req.casa.journeyContext, {
65
+ startWaypoint: waypoint,
66
+ stopCondition: () => (true), // stop at the first one
67
+ });
68
+ res.locals.casa.journeyPreviousUrl = prevRoute.target ? waypointUrl({
69
+ mountUrl,
70
+ waypoint: prevRoute.target,
71
+ routeName: 'prev',
72
+ edit: req.casa.editMode,
73
+ editOrigin: req.casa.editOrigin,
74
+ }) : undefined;
75
+
76
+ return next();
77
+ },
78
+ ];
@@ -0,0 +1,56 @@
1
+ // Strip any "proxy path" prefix present on the request URL
2
+ //
3
+ // The default "mountUrl" will be match whatever path that the CASA app
4
+ // instance will be mounted onto. So if you mount on `/a/b/c` then all
5
+ // URLs generated for the browser will be prefixed with `/a/b/c`.
6
+ //
7
+ // Defining a `mountUrl` will change that behaviour into a "proxy mode".
8
+ // This mode assumes you have a forwarding proxy that will alter
9
+ // the incoming request paths from `mountUrl` to the original path you
10
+ // have mounted this CASA app instance onto.
11
+ //
12
+ // For example, if you mount the app on `/a/b/c/x`, but want the browser
13
+ // URLs to just use the `/x` prefix, then pass a `mountUrl` of `/x`.
14
+ //
15
+ // See docs in `docs/guides/setup-behind-a-proxy.md`
16
+
17
+ import logger from '../lib/logger.js';
18
+
19
+ const log = logger('casa:middleware:strip-proxy-path');
20
+
21
+ export default ({
22
+ mountUrl = '/',
23
+ }) => [
24
+ (req, res, next) => {
25
+ // TODO:
26
+ // We _may_ have to start tracking the various prefix in order to differentiate
27
+ // between a proxy prefix, and a parent app's path.
28
+
29
+ // Assume everything before `mountUrl` is the proxy path prefix and remove it
30
+ req.originalBaseUrl = req.originalBaseUrl ?? req.baseUrl;
31
+ req.baseUrl = mountUrl.replace(/\/$/, '');
32
+
33
+ // If the app has been mounted directly on the specific `mountUrl`, then
34
+ // there's nothing we need to do and can let this request pass-through.
35
+ if (req.baseUrl === req.originalBaseUrl) {
36
+ next();
37
+ } else if (req.__CASA_BASE_URL_REWRITTEN__) {
38
+ delete req.__CASA_BASE_URL_REWRITTEN__;
39
+ next();
40
+ } else {
41
+ // Strip the proxy path prefix from the original URL so that
42
+ // subsequent middleware sees the URL path as though proxy wasn't there.
43
+ // req.url will already have the proxy prefix and mountUrl removed.
44
+ /* eslint-disable security/detect-non-literal-regexp */
45
+ log.trace(`req.originalUrl before proxy stripping: ${req.originalUrl}`);
46
+ req.originalUrl = req.originalUrl.replace(new RegExp(`^/.+?${mountUrl}`), mountUrl);
47
+ log.trace(`req.originalUrl after proxy stripping: ${req.originalUrl}`);
48
+ /* eslint-enable security/detect-non-literal-regexp */
49
+
50
+ // Issuing this call will re-run this same middleware, so we use this
51
+ // `__CASA_BASE_URL_REWRITTEN__` flag to prevent recursion.
52
+ req.__CASA_BASE_URL_REWRITTEN__ = true;
53
+ req.app.handle(req, res, next);
54
+ }
55
+ },
56
+ ];
@@ -0,0 +1,84 @@
1
+ // Validate the data captured in the journey context
2
+ import JourneyContext from '../lib/JourneyContext.js';
3
+
4
+ const updateContext = ({
5
+ waypoint,
6
+ errors = null,
7
+ journeyContext,
8
+ session,
9
+ }) => {
10
+ // Set validation state
11
+ if (errors === null) {
12
+ journeyContext.clearValidationErrorsForPage(waypoint);
13
+ } else {
14
+ journeyContext.setValidationErrorsForPage(waypoint, errors);
15
+ }
16
+
17
+ // Save to session
18
+ JourneyContext.putContext(session, journeyContext);
19
+ }
20
+
21
+ export default ({
22
+ waypoint,
23
+ fields = [],
24
+ plan,
25
+ }) => [
26
+ (req, res, next) => {
27
+ const mountUrl = `${req.baseUrl}/`;
28
+
29
+ // Run validators for every field to build up a complete list of errors
30
+ // currently associated with this waypoint.
31
+ let errors = [];
32
+ for (let i = 0, l = fields.length; i < l; i++) {
33
+ /* eslint-disable security/detect-object-injection */
34
+ // Dynamic object keys are only used on known entities (fields, waypoint)
35
+ const field = fields[i];
36
+ const fieldName = field.name;
37
+ const fieldValue = req.casa.journeyContext.data?.[waypoint]?.[fieldName];
38
+
39
+ // (type = ValidateContext)
40
+ const context = {
41
+ fieldName,
42
+ fieldValue,
43
+ waypoint,
44
+ journeyContext: req.casa.journeyContext,
45
+ };
46
+ /* eslint-enable security/detect-object-injection */
47
+
48
+ errors = [
49
+ ...errors,
50
+ ...field.runValidators(fieldValue, context),
51
+ ];
52
+ }
53
+
54
+ // Validation passed with no errors
55
+ if (!errors.length) {
56
+ updateContext({
57
+ waypoint,
58
+ session: req.session,
59
+ mountUrl,
60
+ plan,
61
+ journeyContext: req.casa.journeyContext,
62
+ });
63
+ return next();
64
+ }
65
+
66
+ // If there are any native errors in the list, we need to bail the request
67
+ const nativeError = errors.find((e) => e instanceof Error);
68
+ if (nativeError) {
69
+ return next(nativeError);
70
+ }
71
+
72
+ // Make the errors available to downstream middleware
73
+ updateContext({
74
+ errors,
75
+ waypoint,
76
+ session: req.session,
77
+ mountUrl,
78
+ plan,
79
+ journeyContext: req.casa.journeyContext,
80
+ });
81
+
82
+ return next();
83
+ },
84
+ ];
@@ -0,0 +1,29 @@
1
+ import MutableRouter from '../lib/MutableRouter.js';
2
+
3
+ /**
4
+ * @typedef {object} AncillaryRouterOptions Options to configure static router
5
+ * @property {number} sessionTtl Session timeout (seconds)
6
+ */
7
+
8
+ /**
9
+ * Create an instance of the ancillary router.
10
+ *
11
+ * @access private
12
+ * @param {AncillaryRouterOptions} options Options
13
+ * @returns {MutableRouter} ExpressJS Router instance
14
+ */
15
+ export default function ancillaryRouter({
16
+ sessionTtl,
17
+ }) {
18
+ // Router
19
+ const router = new MutableRouter();
20
+
21
+ // Session timeout
22
+ router.all('/session-timeout', (req, res) => {
23
+ res.render('casa/session-timeout.njk', {
24
+ sessionTtl: Math.floor(sessionTtl / 60),
25
+ });
26
+ });
27
+
28
+ return router;
29
+ }
@@ -0,0 +1 @@
1
+ module.exports = __dirname;
@@ -0,0 +1,212 @@
1
+ /* eslint-disable object-curly-newline,max-len */
2
+ import MutableRouter from '../lib/MutableRouter.js';
3
+ import skipWaypointMiddlewareFactory from '../middleware/skip-waypoint.js';
4
+ import steerJourneyMiddlewareFactory from '../middleware/steer-journey.js';
5
+ import sanitiseFieldsMiddlewareFactory from '../middleware/sanitise-fields.js';
6
+ import gatherFieldsMiddlewareFactory from '../middleware/gather-fields.js';
7
+ import validateFieldsMiddlewareFactory from '../middleware/validate-fields.js';
8
+ import progressJourneyMiddlewareFactory from '../middleware/progress-journey.js';
9
+ import waypointUrl from '../lib/waypoint-url.js';
10
+ import logger from '../lib/logger.js';
11
+ import { resolveMiddlewareHooks } from '../lib/utils.js';
12
+
13
+ const log = logger('routes:journey');
14
+
15
+ /**
16
+ * @access private
17
+ * @param {import('../casa.js').GlobalHook} GlobalHook
18
+ */
19
+
20
+ /**
21
+ * @access private
22
+ * @param {import('../casa.js').Page} Page
23
+ */
24
+
25
+ /**
26
+ * @access private
27
+ * @param {import('../casa.js').Plan} Plan
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} JourneyRouterOptions Options to configure static router
32
+ * @property {GlobalHook[]} globalHooks Global hooks
33
+ * @property {Page[]} pages Page definitions
34
+ * @property {Plan} plan Plan
35
+ * @property {Function[]} csrfMiddleware Middleware for providing CSRF controls
36
+ */
37
+
38
+ const renderMiddlewareFactory = (view, contextFactory) => [
39
+ (req, res, next) => {
40
+ res.render(view, {
41
+ // Common template variables for both GET and POST requests
42
+ inEditMode: req.casa.editMode,
43
+ editOriginUrl: req.casa.editOrigin,
44
+ activeContextId: req.casa.journeyContext.identity.id,
45
+ ...contextFactory(req),
46
+ }, (err, templateString) => {
47
+ if (err) {
48
+ next(err);
49
+ } else {
50
+ res.send(templateString);
51
+ }
52
+ });
53
+ },
54
+ ];
55
+
56
+ /**
57
+ * Create an instance of the router for all waypoints visited during a Journey
58
+ * through the Plan.
59
+ *
60
+ * @access private
61
+ * @param {JourneyRouterOptions} opts Options
62
+ * @returns {MutableRouter} Router
63
+ */
64
+ export default function journeyRouter({
65
+ globalHooks,
66
+ pages,
67
+ plan,
68
+ csrfMiddleware,
69
+ }) {
70
+ // Router
71
+ const router = new MutableRouter();
72
+
73
+ // Special "_" route which handles redirecting the user between sub-apps
74
+ // /app1/_/?refmount=app2&route=prev
75
+ router.all('/_', (req, res) => {
76
+ const mountUrl = `${req.baseUrl}/`;
77
+ const refmount = req.query?.refmount;
78
+ const route = req.query?.route;
79
+ log.trace(`App root ${mountUrl}: refmount = ${refmount}, route = ${route}`);
80
+
81
+ let redirectTo;
82
+ const fallback = waypointUrl({
83
+ mountUrl,
84
+ waypoint: plan.traverse(req.casa.journeyContext, {
85
+ stopCondition: () => (true), // we only need one; stop at the first
86
+ })[0],
87
+ });
88
+
89
+ if (route === 'prev') {
90
+ const routes = plan.traversePrevRoutes(req.casa.journeyContext, { startWaypoint: refmount });
91
+ redirectTo = routes.length ? waypointUrl({ mountUrl, waypoint: routes[0].target }) : fallback;
92
+ } else {
93
+ const routes = plan.traverseNextRoutes(req.casa.journeyContext, { startWaypoint: refmount });
94
+ if (routes[0].target !== null) {
95
+ redirectTo = routes.length ? waypointUrl({ mountUrl, waypoint: routes[0].target }) : fallback;
96
+ } else {
97
+ redirectTo = fallback;
98
+ }
99
+ }
100
+
101
+ // Carry over any params
102
+ const url = new URL(redirectTo, 'https://placeholder.test/');
103
+ const searchParams = new URLSearchParams(req.query);
104
+ searchParams.delete('refmount');
105
+ searchParams.delete('route');
106
+ url.search = searchParams.toString();
107
+ redirectTo = `${url.pathname.replace(/\/+/g, '/')}${url.search}`;
108
+
109
+ log.trace(`Redirect to ${redirectTo}`);
110
+ return res.redirect(redirectTo);
111
+ });
112
+
113
+ // Create GET / POST routes for each page
114
+ const commonMiddleware = [
115
+ ...csrfMiddleware,
116
+ ];
117
+
118
+ pages.forEach((page) => {
119
+ const { waypoint, view, hooks: pageHooks = [], fields } = page;
120
+ const waypointPath = `/${waypoint}`;
121
+
122
+ let commonWaypointMiddleware = [
123
+ (req, res, next) => {
124
+ req.casa.waypoint = waypoint;
125
+ res.locals.casa.waypoint = waypoint;
126
+ next();
127
+ },
128
+ ];
129
+
130
+ if (plan.isSkippable(waypoint)) {
131
+ log.info(`Configuring "${waypoint}" as a skippable waypoint`);
132
+ commonWaypointMiddleware = [
133
+ ...commonWaypointMiddleware,
134
+ ...skipWaypointMiddlewareFactory({ waypoint }),
135
+ ];
136
+ }
137
+
138
+ router.get(
139
+ waypointPath,
140
+ ...commonMiddleware,
141
+ ...commonWaypointMiddleware,
142
+
143
+ ...resolveMiddlewareHooks('journey.presteer', waypointPath, [...globalHooks, ...pageHooks]),
144
+ ...steerJourneyMiddlewareFactory({ waypoint, plan }),
145
+ ...resolveMiddlewareHooks('journey.poststeer', waypointPath, [...globalHooks, ...pageHooks]),
146
+
147
+ ...resolveMiddlewareHooks('journey.prerender', waypointPath, [...globalHooks, ...pageHooks]),
148
+ renderMiddlewareFactory(view, (req) => ({
149
+ formUrl: waypointUrl({ mountUrl: `${req.baseUrl}/`, waypoint }),
150
+ formData: req.casa.journeyContext.getDataForPage(waypoint),
151
+ })),
152
+ );
153
+
154
+ router.post(
155
+ waypointPath,
156
+ ...commonMiddleware,
157
+ ...commonWaypointMiddleware,
158
+
159
+ ...resolveMiddlewareHooks('journey.presteer', waypointPath, [...globalHooks, ...pageHooks]),
160
+ ...steerJourneyMiddlewareFactory({ waypoint, plan }),
161
+ ...resolveMiddlewareHooks('journey.poststeer', waypointPath, [...globalHooks, ...pageHooks]),
162
+
163
+ ...resolveMiddlewareHooks('journey.presanitise', waypointPath, [...globalHooks, ...pageHooks]),
164
+ ...sanitiseFieldsMiddlewareFactory({ waypoint, fields }),
165
+ ...resolveMiddlewareHooks('journey.postsanitise', waypointPath, [...globalHooks, ...pageHooks]),
166
+
167
+ ...resolveMiddlewareHooks('journey.pregather', waypointPath, [...globalHooks, ...pageHooks]),
168
+ ...gatherFieldsMiddlewareFactory({ waypoint, fields }),
169
+ ...resolveMiddlewareHooks('journey.postgather', waypointPath, [...globalHooks, ...pageHooks]),
170
+
171
+ ...resolveMiddlewareHooks('journey.prevalidate', waypointPath, [...globalHooks, ...pageHooks]),
172
+ ...validateFieldsMiddlewareFactory({ waypoint, fields, plan }),
173
+ ...resolveMiddlewareHooks('journey.postvalidate', waypointPath, [...globalHooks, ...pageHooks]),
174
+
175
+ // If there were validation errors, jump out of this route and into the
176
+ // next, where the errors will be rendered
177
+ (req, res, next) => (req.casa.journeyContext.hasValidationErrorsForPage(waypoint) ? next('route') : next()),
178
+
179
+ ...resolveMiddlewareHooks('journey.preredirect', waypointPath, [...globalHooks, ...pageHooks]),
180
+ ...progressJourneyMiddlewareFactory({ waypoint, plan }),
181
+ );
182
+
183
+ router.post(
184
+ waypointPath,
185
+ ...resolveMiddlewareHooks('journey.prerender', waypointPath, [...globalHooks, ...pageHooks]),
186
+ renderMiddlewareFactory(view, (req) => {
187
+ const errors = req.casa.journeyContext.getValidationErrorsForPageByField(waypoint) ?? Object.create(null);
188
+
189
+ // This is a convenience for the template. The `govukErrorSummary` macro
190
+ // requires the errors be in a particular format, so here we provide our
191
+ // errors in that format.
192
+ // Where there are multiple errors against a particular field, only the
193
+ // first one is shown.
194
+ // Disabling security/detect-object-injection rule because both `errors`
195
+ // and the `k` property are known entities
196
+ const govukErrors = Object.keys(errors).map((k) => ({
197
+ text: req.t(errors[k][0].summary, errors[k][0].variables), /* eslint-disable-line security/detect-object-injection */
198
+ href: errors[k][0].fieldHref, /* eslint-disable-line security/detect-object-injection */
199
+ }));
200
+
201
+ return {
202
+ formUrl: waypointUrl({ mountUrl: `${req.baseUrl}/`, waypoint }),
203
+ formData: req.body,
204
+ formErrors: Object.keys(errors).length ? errors : null,
205
+ formErrorsGovukArray: govukErrors.length ? govukErrors : null,
206
+ };
207
+ }),
208
+ );
209
+ });
210
+
211
+ return router;
212
+ }