@dwp/govuk-casa 8.7.12 → 8.9.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 (173) hide show
  1. package/dist/casa.d.ts +12 -1
  2. package/dist/casa.js +10 -2
  3. package/dist/casa.js.map +1 -0
  4. package/dist/lib/CasaTemplateLoader.js +1 -0
  5. package/dist/lib/CasaTemplateLoader.js.map +1 -0
  6. package/dist/lib/JourneyContext.d.ts +12 -3
  7. package/dist/lib/JourneyContext.js +20 -5
  8. package/dist/lib/JourneyContext.js.map +1 -0
  9. package/dist/lib/MutableRouter.js +1 -0
  10. package/dist/lib/MutableRouter.js.map +1 -0
  11. package/dist/lib/Plan.d.ts +1 -1
  12. package/dist/lib/Plan.js +2 -5
  13. package/dist/lib/Plan.js.map +1 -0
  14. package/dist/lib/ValidationError.js +1 -0
  15. package/dist/lib/ValidationError.js.map +1 -0
  16. package/dist/lib/ValidatorFactory.d.ts +2 -2
  17. package/dist/lib/ValidatorFactory.js +3 -2
  18. package/dist/lib/ValidatorFactory.js.map +1 -0
  19. package/dist/lib/configuration-ingestor.js +1 -0
  20. package/dist/lib/configuration-ingestor.js.map +1 -0
  21. package/dist/lib/configure.js +2 -1
  22. package/dist/lib/configure.js.map +1 -0
  23. package/dist/lib/constants.d.ts +9 -0
  24. package/dist/lib/constants.js +13 -0
  25. package/dist/lib/constants.js.map +1 -0
  26. package/dist/lib/end-session.js +1 -0
  27. package/dist/lib/end-session.js.map +1 -0
  28. package/dist/lib/field.js +1 -0
  29. package/dist/lib/field.js.map +1 -0
  30. package/dist/lib/index.js +1 -0
  31. package/dist/lib/index.js.map +1 -0
  32. package/dist/lib/logger.js +1 -0
  33. package/dist/lib/logger.js.map +1 -0
  34. package/dist/lib/mount.js +3 -2
  35. package/dist/lib/mount.js.map +1 -0
  36. package/dist/lib/nunjucks-filters.js +1 -0
  37. package/dist/lib/nunjucks-filters.js.map +1 -0
  38. package/dist/lib/nunjucks.js +1 -0
  39. package/dist/lib/nunjucks.js.map +1 -0
  40. package/dist/lib/utils.d.ts +45 -27
  41. package/dist/lib/utils.js +105 -67
  42. package/dist/lib/utils.js.map +1 -0
  43. package/dist/lib/validators/dateObject.js +4 -3
  44. package/dist/lib/validators/dateObject.js.map +1 -0
  45. package/dist/lib/validators/email.js +1 -0
  46. package/dist/lib/validators/email.js.map +1 -0
  47. package/dist/lib/validators/inArray.js +1 -0
  48. package/dist/lib/validators/inArray.js.map +1 -0
  49. package/dist/lib/validators/index.js +1 -0
  50. package/dist/lib/validators/index.js.map +1 -0
  51. package/dist/lib/validators/nino.js +1 -0
  52. package/dist/lib/validators/nino.js.map +1 -0
  53. package/dist/lib/validators/postalAddressObject.d.ts +2 -2
  54. package/dist/lib/validators/postalAddressObject.js +2 -1
  55. package/dist/lib/validators/postalAddressObject.js.map +1 -0
  56. package/dist/lib/validators/regex.js +1 -0
  57. package/dist/lib/validators/regex.js.map +1 -0
  58. package/dist/lib/validators/required.js +1 -0
  59. package/dist/lib/validators/required.js.map +1 -0
  60. package/dist/lib/validators/strlen.js +1 -0
  61. package/dist/lib/validators/strlen.js.map +1 -0
  62. package/dist/lib/validators/wordCount.js +1 -0
  63. package/dist/lib/validators/wordCount.js.map +1 -0
  64. package/dist/lib/waypoint-url.js +40 -9
  65. package/dist/lib/waypoint-url.js.map +1 -0
  66. package/dist/middleware/body-parser.js +1 -0
  67. package/dist/middleware/body-parser.js.map +1 -0
  68. package/dist/middleware/csrf.js +1 -0
  69. package/dist/middleware/csrf.js.map +1 -0
  70. package/dist/middleware/data.js +1 -0
  71. package/dist/middleware/data.js.map +1 -0
  72. package/dist/middleware/gather-fields.js +10 -2
  73. package/dist/middleware/gather-fields.js.map +1 -0
  74. package/dist/middleware/i18n.js +1 -0
  75. package/dist/middleware/i18n.js.map +1 -0
  76. package/dist/middleware/post.js +1 -0
  77. package/dist/middleware/post.js.map +1 -0
  78. package/dist/middleware/pre.js +1 -0
  79. package/dist/middleware/pre.js.map +1 -0
  80. package/dist/middleware/progress-journey.js +7 -2
  81. package/dist/middleware/progress-journey.js.map +1 -0
  82. package/dist/middleware/sanitise-fields.js +1 -0
  83. package/dist/middleware/sanitise-fields.js.map +1 -0
  84. package/dist/middleware/serve-first-waypoint.js +1 -0
  85. package/dist/middleware/serve-first-waypoint.js.map +1 -0
  86. package/dist/middleware/session.js +1 -0
  87. package/dist/middleware/session.js.map +1 -0
  88. package/dist/middleware/skip-waypoint.js +1 -0
  89. package/dist/middleware/skip-waypoint.js.map +1 -0
  90. package/dist/middleware/steer-journey.js +2 -0
  91. package/dist/middleware/steer-journey.js.map +1 -0
  92. package/dist/middleware/strip-proxy-path.js +1 -0
  93. package/dist/middleware/strip-proxy-path.js.map +1 -0
  94. package/dist/middleware/validate-fields.js +7 -1
  95. package/dist/middleware/validate-fields.js.map +1 -0
  96. package/dist/mjs/esm-wrapper.js +11 -15
  97. package/dist/routes/ancillary.js +1 -0
  98. package/dist/routes/ancillary.js.map +1 -0
  99. package/dist/routes/journey.js +1 -0
  100. package/dist/routes/journey.js.map +1 -0
  101. package/dist/routes/static.js +1 -0
  102. package/dist/routes/static.js.map +1 -0
  103. package/locales/cy/error.json +1 -1
  104. package/locales/en/error.json +1 -1
  105. package/package.json +17 -16
  106. package/src/casa.js +330 -0
  107. package/src/lib/CasaTemplateLoader.js +104 -0
  108. package/src/lib/JourneyContext.js +797 -0
  109. package/src/lib/MutableRouter.js +310 -0
  110. package/src/lib/Plan.js +619 -0
  111. package/src/lib/ValidationError.js +163 -0
  112. package/src/lib/ValidatorFactory.js +105 -0
  113. package/src/lib/configuration-ingestor.js +457 -0
  114. package/src/lib/configure.js +202 -0
  115. package/src/lib/constants.js +9 -0
  116. package/src/lib/dirname.cjs +1 -0
  117. package/src/lib/end-session.js +45 -0
  118. package/src/lib/field.js +456 -0
  119. package/src/lib/index.js +33 -0
  120. package/src/lib/logger.js +16 -0
  121. package/src/lib/mount.js +127 -0
  122. package/src/lib/nunjucks-filters.js +150 -0
  123. package/src/lib/nunjucks.js +53 -0
  124. package/src/lib/utils.js +232 -0
  125. package/src/lib/validators/dateObject.js +169 -0
  126. package/src/lib/validators/email.js +55 -0
  127. package/src/lib/validators/inArray.js +81 -0
  128. package/src/lib/validators/index.js +24 -0
  129. package/src/lib/validators/nino.js +57 -0
  130. package/src/lib/validators/postalAddressObject.js +162 -0
  131. package/src/lib/validators/regex.js +48 -0
  132. package/src/lib/validators/required.js +74 -0
  133. package/src/lib/validators/strlen.js +66 -0
  134. package/src/lib/validators/wordCount.js +70 -0
  135. package/src/lib/waypoint-url.js +126 -0
  136. package/src/middleware/body-parser.js +31 -0
  137. package/src/middleware/csrf.js +29 -0
  138. package/src/middleware/data.js +105 -0
  139. package/src/middleware/dirname.cjs +1 -0
  140. package/src/middleware/gather-fields.js +58 -0
  141. package/src/middleware/i18n.js +106 -0
  142. package/src/middleware/post.js +61 -0
  143. package/src/middleware/pre.js +91 -0
  144. package/src/middleware/progress-journey.js +96 -0
  145. package/src/middleware/sanitise-fields.js +58 -0
  146. package/src/middleware/serve-first-waypoint.js +28 -0
  147. package/src/middleware/session.js +129 -0
  148. package/src/middleware/skip-waypoint.js +46 -0
  149. package/src/middleware/steer-journey.js +79 -0
  150. package/src/middleware/strip-proxy-path.js +56 -0
  151. package/src/middleware/validate-fields.js +89 -0
  152. package/src/routes/ancillary.js +29 -0
  153. package/src/routes/dirname.cjs +1 -0
  154. package/src/routes/journey.js +212 -0
  155. package/src/routes/static.js +77 -0
  156. package/views/casa/components/character-count/README.md +10 -0
  157. package/views/casa/components/character-count/template.njk +6 -2
  158. package/views/casa/components/checkboxes/README.md +43 -34
  159. package/views/casa/components/checkboxes/template.njk +8 -7
  160. package/views/casa/components/date-input/README.md +11 -1
  161. package/views/casa/components/date-input/template.njk +6 -4
  162. package/views/casa/components/input/README.md +9 -0
  163. package/views/casa/components/input/template.njk +6 -2
  164. package/views/casa/components/postal-address-object/README.md +10 -0
  165. package/views/casa/components/postal-address-object/template.njk +20 -5
  166. package/views/casa/components/radios/README.md +49 -24
  167. package/views/casa/components/radios/template.njk +6 -3
  168. package/views/casa/components/select/README.md +65 -0
  169. package/views/casa/components/select/macro.njk +3 -0
  170. package/views/casa/components/select/template.njk +49 -0
  171. package/views/casa/components/textarea/README.md +9 -0
  172. package/views/casa/components/textarea/template.njk +6 -2
  173. package/views/casa/layouts/journey.njk +1 -1
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @access private
3
+ * @typedef {import('./index').JourneyContext} JourneyContext
4
+ */
5
+
6
+ /** @access private */
7
+ const reUrlProtocolExtract = /^url:\/\/(.+)$/i
8
+
9
+ /**
10
+ * Sanitise a waypoint string.
11
+ *
12
+ * @access private
13
+ * @param {string} w Waypoint
14
+ * @returns {string} Sanitised waypoint
15
+ */
16
+ const sanitiseWaypoint = (w) => w.replace(/[^/a-z0-9_-]/ig, '').replace(/\/+/g, '/');
17
+
18
+ /**
19
+ * Sanitise a waypoint string, with allowed URL parameters:
20
+ * contextid = JourneyContext ID
21
+ *
22
+ * @access private
23
+ * @param {string} w Waypoint and potential URL parameters
24
+ * @returns {string} Sanitised waypoint
25
+ */
26
+ const sanitiseWaypointWithAllowedParams = (w) => {
27
+ // Extract URL params
28
+ const parts = w.split('?');
29
+ if (parts.length !== 2) {
30
+ return sanitiseWaypoint(w);
31
+ }
32
+ const [waypoint, rawParams] = parts;
33
+ const urlSearchParams = new URLSearchParams(rawParams);
34
+
35
+ // Strip all but those parameters allowed
36
+ const validatedUrlSearchParams = new URLSearchParams();
37
+ for (const pk of ['contextid']) {
38
+ if (urlSearchParams.has(pk)) {
39
+ validatedUrlSearchParams.set(pk, urlSearchParams.get(pk));
40
+ }
41
+ }
42
+
43
+ return `${sanitiseWaypoint(waypoint)}?${validatedUrlSearchParams.toString()}`.replace(/\?$/, '');
44
+ }
45
+
46
+ /**
47
+ * Generate a URL pointing at a particular waypoint.
48
+ *
49
+ * @example
50
+ * // generates: /path/details?edit&editorigin=%2Fsomewhere%2Felse
51
+ * waypointUrl({
52
+ * mountUrl: '/path/',
53
+ * waypoint: 'details',
54
+ * edit: true,
55
+ * editOrigin: '/somewhere/else'
56
+ * })
57
+ * @memberof module:@dwp/govuk-casa
58
+ * @param {object} obj Options
59
+ * @param {string} [obj.waypoint=""] Waypoint
60
+ * @param {string} [obj.mountUrl="/"] Mount URL
61
+ * @param {JourneyContext} [obj.journeyContext] JourneyContext
62
+ * @param {boolean} [obj.edit=false] Turn edit mode on or off
63
+ * @param {string} [obj.editOrigin] Edit mode original URL
64
+ * @param {boolean} [obj.skipTo] Skip to this waypoint from the current one
65
+ * @param {string} [obj.routeName=next] Plan route name; next | prev
66
+ * @returns {string} URL
67
+ */
68
+ export default function waypointUrl({
69
+ waypoint = '',
70
+ mountUrl = '/',
71
+ journeyContext,
72
+ edit = false,
73
+ editOrigin,
74
+ skipTo,
75
+ routeName = 'next',
76
+ } = Object.create(null)) {
77
+ const url = new URL('https://placeholder.test');
78
+
79
+ // Handle url:// protocol
80
+ // - This will generate a link to the root handler "_" for the given mount path
81
+ if (String(waypoint).substr(0, 7) === 'url:///') {
82
+ const m = waypoint.match(reUrlProtocolExtract);
83
+
84
+ const u = new URL(sanitiseWaypointWithAllowedParams(m[1]), 'https://placeholder.test/');
85
+ url.pathname = `${sanitiseWaypoint(u.pathname)}/_/`;
86
+
87
+ url.searchParams.set('refmount', `url://${mountUrl}`);
88
+ url.searchParams.set('route', routeName);
89
+ for (const [uk, uv] of u.searchParams.entries()) {
90
+ url.searchParams.append(uk, uv);
91
+ }
92
+ } else {
93
+ const u = new URL(sanitiseWaypointWithAllowedParams(`${mountUrl}${waypoint}`), 'https://placeholder.test/');
94
+ url.pathname = u.pathname;
95
+ url.search = u.search;
96
+ }
97
+
98
+ // Attach context ID as query parameter for non-default contexts.
99
+ // To avoid messy URLs with duplicated content, this parameter will _not_ be
100
+ // added if the context ID already appears in the url path, i.e. to avoid
101
+ // `/path/1234-abcd/waypoint?contextid=1234-abcd` scenarios
102
+ if (
103
+ journeyContext
104
+ && !journeyContext.isDefault()
105
+ && journeyContext.identity.id
106
+ && !mountUrl.includes(`/${journeyContext.identity.id}/`)
107
+ ) {
108
+ url.searchParams.set('contextid', journeyContext.identity.id);
109
+ }
110
+
111
+ // Attach edit mode flag
112
+ if (edit === true) {
113
+ url.searchParams.set('edit', 'true');
114
+ }
115
+
116
+ if (edit && editOrigin) {
117
+ url.searchParams.set('editorigin', sanitiseWaypointWithAllowedParams(editOrigin));
118
+ }
119
+
120
+ // Skipto
121
+ if (skipTo) {
122
+ url.searchParams.set('skipto', sanitiseWaypointWithAllowedParams(skipTo));
123
+ }
124
+
125
+ return `${sanitiseWaypoint(url.pathname)}${url.search}`;
126
+ }
@@ -0,0 +1,31 @@
1
+ import { urlencoded as expressBodyParser } from 'express';
2
+
3
+ const rProto = /__proto__/i;
4
+ const rPrototype = /prototype[='"[\]]/i;
5
+ const rConstructor = /constructor[='"[\]]/i;
6
+
7
+ export function verifyBody(req, res, buf, encoding) {
8
+ const body = decodeURI(buf.toString(encoding)).replace(/[\s\u200B-\u200D\uFEFF]/g, '');
9
+ if (rProto.test(body)) {
10
+ throw new Error('Request body verification failed (__proto__)');
11
+ }
12
+ if (rPrototype.test(body)) {
13
+ throw new Error('Request body verification failed (prototype)');
14
+ }
15
+ if (rConstructor.test(body)) {
16
+ throw new Error('Request body verification failed (constructor)');
17
+ }
18
+ }
19
+
20
+ export default function bodyParserMiddleware() {
21
+ return [
22
+ expressBodyParser({
23
+ extended: true,
24
+ type: 'application/x-www-form-urlencoded',
25
+ inflate: true,
26
+ parameterLimit: 25, // TODO: make configurable?
27
+ limit: 1024 * 50, // TODO: make configurable?
28
+ verify: verifyBody,
29
+ }),
30
+ ];
31
+ }
@@ -0,0 +1,29 @@
1
+ import csurf from 'csurf';
2
+
3
+ // 2 middleware: one to generate the csrf token and check its validity (POST
4
+ // only), and one to provide that token to templates via the `casa.csrfToken`
5
+ // variable.
6
+
7
+ export default function csrfMiddleware() {
8
+ return [
9
+ csurf({
10
+ cookie: false,
11
+ sessionKey: 'session',
12
+ // value: (req) => {
13
+ // // Here we clear the token after extracting to maintain cleaner data. It
14
+ // // is only used for this CSRF purpose.
15
+ // const token = String(req.body._csrf);
16
+ // delete req.body._csrf;
17
+ // return token;
18
+ // /* eslint-enable no-underscore-dangle */
19
+ // },
20
+ }),
21
+ (req, res, next) => {
22
+ res.locals.casa = {
23
+ ...res.locals?.casa,
24
+ csrfToken: req.csrfToken(),
25
+ };
26
+ next();
27
+ },
28
+ ];
29
+ }
@@ -0,0 +1,105 @@
1
+ // Decorates the request with some contextual data about the user's journey
2
+ // through the application. This is used by downstream middleware and templates.
3
+
4
+ import lodash from 'lodash';
5
+ import JourneyContext from '../lib/JourneyContext.js';
6
+ import { validateUrlPath } from '../lib/utils.js';
7
+ import waypointUrl from '../lib/waypoint-url.js';
8
+
9
+ const { has } = lodash;
10
+
11
+ const editOrigin = (req) => {
12
+ if (has(req.query, 'editorigin')) {
13
+ return waypointUrl({ waypoint: req.query.editorigin });
14
+ }
15
+ if (has(req?.body, 'editorigin')) {
16
+ return waypointUrl({ waypoint: req.body.editorigin });
17
+ }
18
+ return '';
19
+ }
20
+
21
+ export default function dataMiddleware({
22
+ plan,
23
+ events,
24
+ }) {
25
+ return [
26
+ (req, res, next) => {
27
+ /* ------------------------------------------------ Request decorations */
28
+
29
+ // CASA
30
+ req.casa = {
31
+ ...req?.casa,
32
+
33
+ // The plan
34
+ plan,
35
+
36
+ // Current journey context, loaded from session, specified by
37
+ // `contextid` request parameter
38
+ journeyContext: JourneyContext.extractContextFromRequest(req).addEventListeners(events),
39
+
40
+ // Edit mode
41
+ editMode: (has(req?.query, 'edit') && has(req?.query, 'editorigin')) || (has(req?.body, 'edit') && has(req?.body, 'editorigin')),
42
+ editOrigin: editOrigin(req),
43
+ };
44
+
45
+ // Grab chosen language from session
46
+ req.casa.journeyContext.nav.language = req.session.language;
47
+
48
+ /* ------------------------------------------------- Template variables */
49
+
50
+ // Capture mount URL that will be used in generating all browser URLs
51
+ const mountUrl = validateUrlPath(`${req.baseUrl}/`.replace(/\/+/g, '/'));
52
+
53
+ // If this CASA app is mounted on a parameterised route, then all of its
54
+ // static assets (served by `staticRouter`) will, by default, be served
55
+ // from that dynamic path, for example:
56
+ // markup: <link href="{{ mountUrl }}css/static.css" />
57
+ // resolved URL: /mount/<some-id-here>/css/static.css
58
+ // baseUrl: /mount/<some-id>
59
+ //
60
+ // From a performance point of view, this is very inefficient as we can't
61
+ // take advantage of any intermediate caches. So we instead provide am
62
+ // alternative URL path in the `staticMountUrl` property that excludes the
63
+ // parameterised element, eg:
64
+ // markup: <link href="{{ staticMountUrl }}css/static.css" />
65
+ // resolved URL: /mount/css/static.css
66
+ // baseUrl: /mount
67
+ //
68
+ // As the staticRouter is mounted on both the CASA app, and its internal
69
+ // Router, the `baseUrl` is different in each case, so we cannot rely
70
+ // on it to be consistent. Hence the need for this property, which will
71
+ // always be the non-parameterised version of the baseUrl.
72
+ const staticMountUrl = validateUrlPath(`${req.unparameterisedBaseUrl}/`.replace(/\/+/g, '/'));
73
+
74
+ // CASA and userland templates
75
+ res.locals.casa = {
76
+ mountUrl,
77
+ staticMountUrl,
78
+ editMode: req.casa.editMode,
79
+ editOrigin: req.casa.editOrigin,
80
+ };
81
+ res.locals.locale = req.language;
82
+
83
+ // Used by govuk-frontend template
84
+ // htmlLang = req.language is provided by i18n-http-middleware
85
+ // assetPath = used for linking to static assets in the govuk-frontend module
86
+ res.locals.htmlLang = req.language;
87
+ res.locals.assetPath = `${staticMountUrl}govuk/assets`;
88
+
89
+ // Function for building URLs. This will be curried with the `mountUrl`,
90
+ // `journeyContext`, `edit` and `editOrigin` for convenience. This means
91
+ // the template author does not have to be concerned about the current
92
+ // "state" when generating URLs, but still has the ability to override
93
+ // these curried defaults if needs be.
94
+ res.locals.waypointUrl = (args) => waypointUrl({
95
+ mountUrl,
96
+ journeyContext: req.casa.journeyContext,
97
+ edit: req.casa.editMode,
98
+ editOrigin: req.casa.editOrigin,
99
+ ...args,
100
+ });
101
+
102
+ next();
103
+ },
104
+ ];
105
+ }
@@ -0,0 +1 @@
1
+ module.exports = __dirname;
@@ -0,0 +1,58 @@
1
+ // Gather the field data from `req.body` into the current JourneyContext
2
+ // - Store in the current session
3
+ // - Update the user's journey context with the new data
4
+ // - Remove validation date of JourneyContext so it can re-evaluted
5
+
6
+ import JourneyContext from '../lib/JourneyContext.js';
7
+ import { REQUEST_PHASE_GATHER } from '../lib/constants.js';
8
+
9
+ /**
10
+ * @access private
11
+ * @typedef {import('../lib/field').PageField} PageField
12
+ */
13
+
14
+ /**
15
+ * Gather the field data from `req.body` into the current JourneyContext
16
+ * - Store in the current session
17
+ * - Update the user's journey context with the new data
18
+ * - Remove validation date of JourneyContext so it can re-evaluted
19
+ *
20
+ * @param {object} obj Options
21
+ * @param {string} obj.waypoint Waypoint
22
+ * @param {PageField[]} [obj.fields=[]] Fields
23
+ * @returns {Array} Array of middleware
24
+ */
25
+ export default ({
26
+ waypoint,
27
+ fields = [],
28
+ }) => [
29
+ (req, res, next) => {
30
+ // Store a copy of the journey context before modifying it. This is useful
31
+ // for any comparison work that may be done in subsequent middleware.
32
+ req.casa.archivedJourneyContext = JourneyContext.fromContext(req.casa.journeyContext);
33
+
34
+ // Ignore data for any non-persistent fields
35
+ // ESLint disabled as `fields`, `i` and `name` are dev-controlled
36
+ /* eslint-disable security/detect-object-injection */
37
+ const persistentBody = Object.create(null);
38
+ for (let i = 0, l = fields.length; i < l; i++) {
39
+ if (fields[i].meta.persist && fields[i].getValue(req.body) !== undefined) {
40
+ persistentBody[fields[i].name] = fields[i].getValue(req.body);
41
+ }
42
+ }
43
+ /* eslint-enable security/detect-object-injection */
44
+
45
+ // Update data and validation context in the current request, and store.
46
+ // The validation state is removed here because we must assume the gathered
47
+ // data is invalid until proven otherwise when the validation step is run.
48
+ req.casa.journeyContext.setDataForPage(waypoint, persistentBody);
49
+ req.casa.journeyContext.removeValidationStateForPage(waypoint);
50
+ JourneyContext.putContext(req.session, req.casa.journeyContext, {
51
+ userInfo: {
52
+ casaRequestPhase: REQUEST_PHASE_GATHER,
53
+ },
54
+ });
55
+
56
+ next();
57
+ },
58
+ ];
@@ -0,0 +1,106 @@
1
+ import i18next from 'i18next';
2
+ import { LanguageDetector, handle } from 'i18next-http-middleware';
3
+ import { resolve, basename } from 'path';
4
+ import { existsSync, readFileSync, readdirSync } from 'fs';
5
+ import deepmerge from 'deepmerge';
6
+ import yaml from 'js-yaml';
7
+ import logger from '../lib/logger.js';
8
+
9
+ const log = logger('middleware:i18n');
10
+
11
+ const loadJson = (file) => {
12
+ // Strip out newlines (this is a legacy feature which we're keeping for
13
+ // backwards compatibility).
14
+ const json = readFileSync(file, 'utf8');
15
+ return JSON.parse(json.replace(/[\r\n]/g, ''));
16
+ }
17
+
18
+ const loadYaml = (file) => yaml.load(readFileSync(file, 'utf8'))
19
+
20
+ const extract = (file) => {
21
+ const ext = /.yaml$/i.test(file) ? '.yaml' : '.json';
22
+ const data = ext === '.yaml' ? loadYaml(file) : loadJson(file);
23
+
24
+ return {
25
+ ns: basename(file, ext),
26
+ data,
27
+ };
28
+ }
29
+
30
+ const loadResources = (languages, directories) => {
31
+ const store = Object.create(null);
32
+
33
+ languages.forEach((language) => {
34
+ // ESLint disabled as `store`, `language` and `ns` are all dev-controlled,
35
+ // and this function is only called once, at boot-time.
36
+ /* eslint-disable security/detect-object-injection */
37
+ store[language] = Object.create(null);
38
+
39
+ directories.forEach((basedir) => {
40
+ const dir = resolve(basedir, language);
41
+ if (!existsSync(dir)) {
42
+ return;
43
+ }
44
+
45
+ log.info('Loading %s language from %s ...', language, dir);
46
+ readdirSync(dir).forEach((file) => {
47
+ const { ns, data } = extract(resolve(dir, file));
48
+
49
+ if (store[language][ns] === undefined) {
50
+ store[language][ns] = Object.create(null);
51
+ }
52
+
53
+ store[language][ns] = deepmerge(store[language][ns], data);
54
+ });
55
+ });
56
+ /* eslint-enable security/detect-object-injection */
57
+ });
58
+
59
+ return store;
60
+ }
61
+
62
+ export default function i18nMiddleware({
63
+ languages = ['en', 'cy'],
64
+ directories = [],
65
+ }) {
66
+ // Load _all_ translations, from all directories into memory.
67
+ const resources = loadResources(languages, directories);
68
+
69
+ // Configure i18next
70
+ const i18nInstance = i18next.createInstance();
71
+ i18nInstance
72
+ .use(LanguageDetector)
73
+ .init({
74
+ initImmediate: false, // because we need synchronous loading
75
+ supportedLngs: languages,
76
+ fallbackLng: false,
77
+ defaultNS: 'common',
78
+ // debug: true,
79
+
80
+ // All translation resources
81
+ resources,
82
+
83
+ // LanguageDetector options
84
+ detection: {
85
+ lookupQuerystring: 'lang',
86
+ lookupSession: 'language',
87
+ order: ['querystring', 'session'],
88
+ },
89
+ });
90
+
91
+ // 2 middleware: one to read/set the session language, and one to enhance the
92
+ // req/res objects with i18n features
93
+ return [
94
+ (req, res, next) => {
95
+ if (!req.session.language) {
96
+ /* eslint-disable-next-line prefer-destructuring */
97
+ req.session.language = languages[0];
98
+ }
99
+ if (req?.query.lang && languages.includes(req.query.lang)) {
100
+ req.session.language = String(req.query.lang);
101
+ }
102
+ next();
103
+ },
104
+ handle(i18nInstance),
105
+ ];
106
+ }
@@ -0,0 +1,61 @@
1
+ // 2 middleware: one as a fallback 404 handler, one to handle thrown errors
2
+ import logger from '../lib/logger.js';
3
+
4
+ const log = logger('middleware:post');
5
+
6
+ export default function postMiddleware() {
7
+ return [
8
+ (req, res) => {
9
+ res.status(404).render('casa/errors/404.njk');
10
+ },
11
+ /* eslint-disable-next-line no-unused-vars */
12
+ (err, req, res, next) => {
13
+ // In some cases, an error may have been thrown before the template assets
14
+ // have had a chance to initialise. So we use a hardcoded template in
15
+ // these cases to ensure the user sees an appropriate message.
16
+ let TEMPLATE = 'casa/errors/500.njk';
17
+ if (!res.locals.t) {
18
+ res.locals.t = () => ('');
19
+ res.locals.casa = {
20
+ ...res.locals?.casa,
21
+ mountUrl: `${req.baseUrl}/`,
22
+ };
23
+ TEMPLATE = 'casa/errors/static.njk';
24
+ }
25
+
26
+ // CSRF token is invalid in some way
27
+ if (err?.code === 'EBADCSRFTOKEN') {
28
+ log.info('CSRF validation has failed. This may be caused by the user submitting a stale form from a previous session [EBADCSRFTOKEN]');
29
+ return res.status(403).render(TEMPLATE, { errorCode: 'bad_csrf_token', error: err });
30
+ }
31
+
32
+ // Body parsing verification check failed
33
+ if (err?.type === 'entity.verify.failed') {
34
+ log.info('Body parser verification has failed. This has been caused by the user submitting a payload containing invalid data [entity.verify.failed]');
35
+ return res.status(403).render(TEMPLATE, { errorCode: 'invalid_payload', error: err });
36
+ }
37
+
38
+ // Too many parameters submitted
39
+ if (err?.type === 'parameters.too.many') {
40
+ log.info('The request contains more parameters than is currently allowed [parameters.too.many]');
41
+ return res.status(413).render(TEMPLATE, { errorCode: 'parameter_limit_exceeded', error: err });
42
+ }
43
+
44
+ // Overall payload too large
45
+ if (err?.type === 'entity.too.large') {
46
+ log.info(`The request payload is too large. Received ${err.length}b with a maximum of ${err.limit}b [parameters.too.many]`);
47
+ return res.status(413).render(TEMPLATE, { errorCode: 'payload_size_exceeded', error: err });
48
+ }
49
+
50
+ // Unaccept request method
51
+ if (err?.code === 'unaccepted_request_method') {
52
+ log.info(err.message);
53
+ return res.status(400).render(TEMPLATE, { errorCode: 'unaccepted_request_method', error: err });
54
+ }
55
+
56
+ // Unknown error
57
+ log.error(`Unknown error: ${err.message}; stacktrace: ${err.stack}`);
58
+ return res.status(200).render(TEMPLATE, { error: err });
59
+ },
60
+ ]
61
+ }
@@ -0,0 +1,91 @@
1
+ import { randomBytes } from 'crypto';
2
+ import helmet from 'helmet';
3
+
4
+ /**
5
+ * @access private
6
+ * @typedef {import('../casa').HelmetConfigurator} HelmetConfigurator
7
+ */
8
+
9
+ const GA_DOMAIN = '*.google-analytics.com';
10
+ const GA_ANALYTICS_DOMAIN = '*.analytics.google.com';
11
+ const GTM_DOMAIN = '*.googletagmanager.com';
12
+ const GTM_PREVIEW_DOMAIN = 'https://tagmanager.google.com';
13
+
14
+ /**
15
+ * Extracts the CSP nonce used in every template, and makes it available as a
16
+ * nonce value in the CSP header.
17
+ *
18
+ * IMPORTANT: Do not rename this function as it _might_ be used in consumer code
19
+ * to identify this function specifically, most likely to remove it from CSP
20
+ * headers for custom purposes.
21
+ *
22
+ * @param {import('express').Request} req Request
23
+ * @param {import('express').Response} res Response
24
+ * @returns {string} nonce value suitable for use in CSP header
25
+ */
26
+ function casaCspNonce(req, res) {
27
+ return `'nonce-${res.locals.cspNonce}'`;
28
+ }
29
+
30
+ /**
31
+ * Pre middleware.
32
+ *
33
+ * @param {object} opts Options
34
+ * @param {HelmetConfigurator} opts.helmetConfigurator Function to customise Helmet configuration
35
+ * @returns {Function[]} List of middleware
36
+ */
37
+ export default ({
38
+ helmetConfigurator = (config) => (config),
39
+ } = {}) => [
40
+ // Only allow certain request methods
41
+ (req, res, next) => {
42
+ if (req.method !== 'GET' && req.method !== 'POST') {
43
+ const err = new Error(`Unaccepted request method, "${String(req.method).substr(0, 7)}"`);
44
+ err.code = 'unaccepted_request_method';
45
+ next(err);
46
+ } else {
47
+ next();
48
+ }
49
+ },
50
+
51
+ // Prevent caching response in any intermediaries by default, in case it
52
+ // contains sensitive data.
53
+ // The `no-store` setting is to specifically disable the bfcache and prevent
54
+ // possible leakage of information.
55
+ (req, res, next) => {
56
+ res.set('cache-control', 'no-cache, no-store, must-revalidate, private');
57
+ res.set('pragma', 'no-cache');
58
+ res.set('expires', 0);
59
+ res.set('x-robots-tag', 'noindex, nofollow');
60
+ next();
61
+ },
62
+
63
+ // Generate nonces ready for use in Content-Security-Policy header and
64
+ // govuk-frontend template. This same none can be used wherever required.
65
+ (req, res, next) => {
66
+ res.locals.cspNonce = randomBytes(16).toString('hex');
67
+ next();
68
+ },
69
+
70
+ // Helmet suite of headers
71
+ helmet(helmetConfigurator({
72
+ // Allows GA which is typically used, and a known inline script nonce
73
+ contentSecurityPolicy: {
74
+ useDefaults: true,
75
+ directives: {
76
+ 'default-src': ["'none'"],
77
+ 'script-src': ["'self'", GA_DOMAIN, GTM_DOMAIN, GTM_PREVIEW_DOMAIN, casaCspNonce],
78
+ 'img-src': ["'self'", GA_DOMAIN, GA_ANALYTICS_DOMAIN, GTM_DOMAIN, 'https://ssl.gstatic.com', 'https://www.gstatic.com'],
79
+ 'connect-src': ["'self'", GA_DOMAIN, GA_ANALYTICS_DOMAIN, GTM_DOMAIN],
80
+ 'frame-src': ["'self'", GTM_DOMAIN],
81
+ 'frame-ancestors': ["'self'"],
82
+ 'form-action': ["'self'"],
83
+ 'style-src': ["'self'", 'https://fonts.googleapis.com', GTM_PREVIEW_DOMAIN, casaCspNonce],
84
+ 'font-src': ["'self'", 'data:', 'https://fonts.gstatic.com'],
85
+ },
86
+ },
87
+
88
+ // // Require referrer to aid navigation
89
+ // referrerPolicy: 'no-referrer, same-origin',
90
+ })),
91
+ ];