@dwp/govuk-casa 8.7.9 → 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.
- package/README.md +0 -5
- package/dist/casa.d.ts +1 -1
- package/dist/casa.js +3 -2
- package/dist/casa.js.map +1 -0
- package/dist/lib/CasaTemplateLoader.d.ts +1 -7
- package/dist/lib/CasaTemplateLoader.js +6 -1
- package/dist/lib/CasaTemplateLoader.js.map +1 -0
- package/dist/lib/JourneyContext.d.ts +1 -1
- package/dist/lib/JourneyContext.js +2 -1
- package/dist/lib/JourneyContext.js.map +1 -0
- package/dist/lib/MutableRouter.js +1 -0
- package/dist/lib/MutableRouter.js.map +1 -0
- package/dist/lib/Plan.d.ts +2 -0
- package/dist/lib/Plan.js +9 -4
- package/dist/lib/Plan.js.map +1 -0
- package/dist/lib/ValidationError.js +2 -1
- package/dist/lib/ValidationError.js.map +1 -0
- package/dist/lib/ValidatorFactory.d.ts +2 -2
- package/dist/lib/ValidatorFactory.js +3 -2
- package/dist/lib/ValidatorFactory.js.map +1 -0
- package/dist/lib/configuration-ingestor.d.ts +1 -1
- package/dist/lib/configuration-ingestor.js +2 -1
- package/dist/lib/configuration-ingestor.js.map +1 -0
- package/dist/lib/configure.js +2 -1
- package/dist/lib/configure.js.map +1 -0
- package/dist/lib/end-session.js +1 -0
- package/dist/lib/end-session.js.map +1 -0
- package/dist/lib/field.js +1 -0
- package/dist/lib/field.js.map +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/mount.js +3 -2
- package/dist/lib/mount.js.map +1 -0
- package/dist/lib/nunjucks-filters.js +28 -1
- package/dist/lib/nunjucks-filters.js.map +1 -0
- package/dist/lib/nunjucks.js +1 -0
- package/dist/lib/nunjucks.js.map +1 -0
- package/dist/lib/utils.d.ts +46 -28
- package/dist/lib/utils.js +105 -67
- package/dist/lib/utils.js.map +1 -0
- package/dist/lib/validators/dateObject.js +5 -4
- package/dist/lib/validators/dateObject.js.map +1 -0
- package/dist/lib/validators/email.js +1 -0
- package/dist/lib/validators/email.js.map +1 -0
- package/dist/lib/validators/inArray.js +1 -0
- package/dist/lib/validators/inArray.js.map +1 -0
- package/dist/lib/validators/index.js +1 -0
- package/dist/lib/validators/index.js.map +1 -0
- package/dist/lib/validators/nino.js +1 -0
- package/dist/lib/validators/nino.js.map +1 -0
- package/dist/lib/validators/postalAddressObject.d.ts +2 -2
- package/dist/lib/validators/postalAddressObject.js +2 -1
- package/dist/lib/validators/postalAddressObject.js.map +1 -0
- package/dist/lib/validators/regex.js +1 -0
- package/dist/lib/validators/regex.js.map +1 -0
- package/dist/lib/validators/required.d.ts +1 -1
- package/dist/lib/validators/required.js +2 -1
- package/dist/lib/validators/required.js.map +1 -0
- package/dist/lib/validators/strlen.js +1 -0
- package/dist/lib/validators/strlen.js.map +1 -0
- package/dist/lib/validators/wordCount.js +1 -0
- package/dist/lib/validators/wordCount.js.map +1 -0
- package/dist/lib/waypoint-url.js +1 -0
- package/dist/lib/waypoint-url.js.map +1 -0
- package/dist/middleware/body-parser.js +1 -0
- package/dist/middleware/body-parser.js.map +1 -0
- package/dist/middleware/csrf.js +1 -0
- package/dist/middleware/csrf.js.map +1 -0
- package/dist/middleware/data.js +1 -0
- package/dist/middleware/data.js.map +1 -0
- package/dist/middleware/gather-fields.js +2 -1
- package/dist/middleware/gather-fields.js.map +1 -0
- package/dist/middleware/i18n.js +1 -0
- package/dist/middleware/i18n.js.map +1 -0
- package/dist/middleware/post.js +1 -0
- package/dist/middleware/post.js.map +1 -0
- package/dist/middleware/pre.js +1 -0
- package/dist/middleware/pre.js.map +1 -0
- package/dist/middleware/progress-journey.js +1 -0
- package/dist/middleware/progress-journey.js.map +1 -0
- package/dist/middleware/sanitise-fields.js +1 -0
- package/dist/middleware/sanitise-fields.js.map +1 -0
- package/dist/middleware/serve-first-waypoint.js +1 -0
- package/dist/middleware/serve-first-waypoint.js.map +1 -0
- package/dist/middleware/session.js +1 -0
- package/dist/middleware/session.js.map +1 -0
- package/dist/middleware/skip-waypoint.js +1 -0
- package/dist/middleware/skip-waypoint.js.map +1 -0
- package/dist/middleware/steer-journey.js +1 -0
- package/dist/middleware/steer-journey.js.map +1 -0
- package/dist/middleware/strip-proxy-path.js +2 -1
- package/dist/middleware/strip-proxy-path.js.map +1 -0
- package/dist/middleware/validate-fields.js +2 -1
- package/dist/middleware/validate-fields.js.map +1 -0
- package/dist/mjs/esm-wrapper.js +10 -15
- package/dist/routes/ancillary.js +1 -0
- package/dist/routes/ancillary.js.map +1 -0
- package/dist/routes/journey.js +1 -0
- package/dist/routes/journey.js.map +1 -0
- package/dist/routes/static.js +1 -0
- package/dist/routes/static.js.map +1 -0
- package/locales/cy/error.json +1 -1
- package/locales/en/error.json +1 -1
- package/package.json +20 -19
- package/src/casa.js +320 -0
- package/src/lib/CasaTemplateLoader.js +104 -0
- package/src/lib/JourneyContext.js +783 -0
- package/src/lib/MutableRouter.js +310 -0
- package/src/lib/Plan.js +624 -0
- package/src/lib/ValidationError.js +163 -0
- package/src/lib/ValidatorFactory.js +105 -0
- package/src/lib/configuration-ingestor.js +457 -0
- package/src/lib/configure.js +202 -0
- package/src/lib/dirname.cjs +1 -0
- package/src/lib/end-session.js +45 -0
- package/src/lib/field.js +456 -0
- package/src/lib/index.js +33 -0
- package/src/lib/logger.js +16 -0
- package/src/lib/mount.js +127 -0
- package/src/lib/nunjucks-filters.js +150 -0
- package/src/lib/nunjucks.js +53 -0
- package/src/lib/utils.js +232 -0
- package/src/lib/validators/dateObject.js +169 -0
- package/src/lib/validators/email.js +55 -0
- package/src/lib/validators/inArray.js +81 -0
- package/src/lib/validators/index.js +24 -0
- package/src/lib/validators/nino.js +57 -0
- package/src/lib/validators/postalAddressObject.js +162 -0
- package/src/lib/validators/regex.js +48 -0
- package/src/lib/validators/required.js +74 -0
- package/src/lib/validators/strlen.js +66 -0
- package/src/lib/validators/wordCount.js +70 -0
- package/src/lib/waypoint-url.js +93 -0
- package/src/middleware/body-parser.js +31 -0
- package/src/middleware/csrf.js +29 -0
- package/src/middleware/data.js +105 -0
- package/src/middleware/dirname.cjs +1 -0
- package/src/middleware/gather-fields.js +51 -0
- package/src/middleware/i18n.js +106 -0
- package/src/middleware/post.js +61 -0
- package/src/middleware/pre.js +91 -0
- package/src/middleware/progress-journey.js +92 -0
- package/src/middleware/sanitise-fields.js +58 -0
- package/src/middleware/serve-first-waypoint.js +28 -0
- package/src/middleware/session.js +129 -0
- package/src/middleware/skip-waypoint.js +46 -0
- package/src/middleware/steer-journey.js +78 -0
- package/src/middleware/strip-proxy-path.js +56 -0
- package/src/middleware/validate-fields.js +84 -0
- package/src/routes/ancillary.js +29 -0
- package/src/routes/dirname.cjs +1 -0
- package/src/routes/journey.js +212 -0
- package/src/routes/static.js +77 -0
- package/views/casa/components/character-count/README.md +10 -0
- package/views/casa/components/character-count/template.njk +6 -2
- package/views/casa/components/checkboxes/README.md +45 -37
- package/views/casa/components/checkboxes/template.njk +8 -7
- package/views/casa/components/date-input/README.md +15 -3
- package/views/casa/components/date-input/template.njk +6 -4
- package/views/casa/components/input/README.md +9 -0
- package/views/casa/components/input/template.njk +6 -2
- package/views/casa/components/journey-form/README.md +3 -2
- package/views/casa/components/postal-address-object/README.md +10 -0
- package/views/casa/components/postal-address-object/template.njk +20 -5
- package/views/casa/components/radios/README.md +51 -26
- package/views/casa/components/radios/template.njk +6 -3
- package/views/casa/components/select/README.md +65 -0
- package/views/casa/components/select/macro.njk +3 -0
- package/views/casa/components/select/template.njk +49 -0
- package/views/casa/components/textarea/README.md +9 -0
- package/views/casa/components/textarea/template.njk +6 -2
- package/views/casa/layouts/journey.njk +1 -1
- package/views/casa/layouts/main.njk +1 -1
|
@@ -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,51 @@
|
|
|
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
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @access private
|
|
10
|
+
* @typedef {import('../lib/field').PageField} PageField
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Gather the field data from `req.body` into the current JourneyContext
|
|
15
|
+
* - Store in the current session
|
|
16
|
+
* - Update the user's journey context with the new data
|
|
17
|
+
* - Remove validation date of JourneyContext so it can re-evaluted
|
|
18
|
+
*
|
|
19
|
+
* @param {object} obj Options
|
|
20
|
+
* @param {string} obj.waypoint Waypoint
|
|
21
|
+
* @param {PageField[]} [obj.fields=[]] Fields
|
|
22
|
+
* @returns {Array} Array of middleware
|
|
23
|
+
*/
|
|
24
|
+
export default ({
|
|
25
|
+
waypoint,
|
|
26
|
+
fields = [],
|
|
27
|
+
}) => [
|
|
28
|
+
(req, res, next) => {
|
|
29
|
+
// Store a copy of the journey context before modifying it. This is useful
|
|
30
|
+
// for any comparison work that may be done in subsequent middleware.
|
|
31
|
+
req.casa.archivedJourneyContext = JourneyContext.fromContext(req.casa.journeyContext);
|
|
32
|
+
|
|
33
|
+
// Ignore data for any non-persistent fields
|
|
34
|
+
// ESLint disabled as `fields`, `i` and `name` are dev-controlled
|
|
35
|
+
/* eslint-disable security/detect-object-injection */
|
|
36
|
+
const persistentBody = Object.create(null);
|
|
37
|
+
for (let i = 0, l = fields.length; i < l; i++) {
|
|
38
|
+
if (fields[i].meta.persist && fields[i].getValue(req.body) !== undefined) {
|
|
39
|
+
persistentBody[fields[i].name] = fields[i].getValue(req.body);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/* eslint-enable security/detect-object-injection */
|
|
43
|
+
|
|
44
|
+
// Update data and validation context in the current request, and store
|
|
45
|
+
req.casa.journeyContext.setDataForPage(waypoint, persistentBody);
|
|
46
|
+
req.casa.journeyContext.removeValidationStateForPage(waypoint);
|
|
47
|
+
JourneyContext.putContext(req.session, req.casa.journeyContext);
|
|
48
|
+
|
|
49
|
+
next();
|
|
50
|
+
},
|
|
51
|
+
];
|
|
@@ -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
|
+
];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Determine where to take the user next
|
|
2
|
+
// We assume that the waypoint has been validated prior to reaching this
|
|
3
|
+
// middleware.
|
|
4
|
+
|
|
5
|
+
import Plan from '../lib/Plan.js';
|
|
6
|
+
import JourneyContext from '../lib/JourneyContext.js';
|
|
7
|
+
import waypointUrl from '../lib/waypoint-url.js';
|
|
8
|
+
import logger from '../lib/logger.js';
|
|
9
|
+
|
|
10
|
+
const log = logger('middleware:progress-journey');
|
|
11
|
+
|
|
12
|
+
const saveAndRedirect = (session, journeyContext, url, res, next) => {
|
|
13
|
+
JourneyContext.putContext(session, journeyContext);
|
|
14
|
+
|
|
15
|
+
session.save((err) => {
|
|
16
|
+
if (err) {
|
|
17
|
+
next(err);
|
|
18
|
+
}
|
|
19
|
+
res.redirect(302, url);
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default ({
|
|
24
|
+
waypoint,
|
|
25
|
+
plan,
|
|
26
|
+
}) => [
|
|
27
|
+
(req, res, next) => {
|
|
28
|
+
// Determine the next available waypoint after the current one
|
|
29
|
+
const traversed = plan.traverse(req.casa.journeyContext);
|
|
30
|
+
const currentIndex = traversed.indexOf(waypoint);
|
|
31
|
+
const nextIndex = Math.max(
|
|
32
|
+
currentIndex < 0 ? traversed.length - 1 : 0,
|
|
33
|
+
Math.min(currentIndex + 1, traversed.length - 1),
|
|
34
|
+
);
|
|
35
|
+
const nextWaypoint = traversed[parseInt(nextIndex, 10)];
|
|
36
|
+
log.trace(`currentIndex = ${currentIndex}, nextIndex = ${nextIndex}, currentWaypoint = ${waypoint}, nextWaypoint = ${nextWaypoint}`);
|
|
37
|
+
|
|
38
|
+
// Edit mode
|
|
39
|
+
// Attempt to take the user back to their original URL. We rely on the
|
|
40
|
+
// `steer-journey` middleware to prevent the user going too far ahead in
|
|
41
|
+
// their permitted journey. Bear in mind that the `editOrigin` may not be
|
|
42
|
+
// a waypoint at all, but a route path for a custom endpoint, so we can't
|
|
43
|
+
// safely do a traversal check here.
|
|
44
|
+
//
|
|
45
|
+
// The edit mode URL params will be kept on this redirect. This means the
|
|
46
|
+
// user can keep "jumping" to the next _changed_ waypoint, until they get
|
|
47
|
+
// back to the original URL.
|
|
48
|
+
//
|
|
49
|
+
// Devs should use the `events` mechanism to mark waypoints as invalid if
|
|
50
|
+
// they want to force the user to re-visit particular waypoints during this
|
|
51
|
+
// "jumping" phase.
|
|
52
|
+
if (req.casa.editMode && req.casa.editOrigin) {
|
|
53
|
+
const url = new URL(req.casa.editOrigin, 'https://placeholder.test/');
|
|
54
|
+
url.searchParams.append('edit', 'true');
|
|
55
|
+
url.searchParams.append('editorigin', req.casa.editOrigin);
|
|
56
|
+
const redirectUrl = waypointUrl({ waypoint: url.pathname }) + url.search.toString();
|
|
57
|
+
|
|
58
|
+
log.debug(`Edit mode detected; redirecting to ${redirectUrl}`);
|
|
59
|
+
|
|
60
|
+
return saveAndRedirect(req.session, req.casa.journeyContext, redirectUrl, res, next);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// If the next URL is an "exit node", we need to flag that node as
|
|
64
|
+
// being validated so that subsequent traversals of this journey continue
|
|
65
|
+
// correctly to any waypoints leading on from this one.
|
|
66
|
+
// This effectively says that the other Plan linked to by the exit node is
|
|
67
|
+
// complete, but of course that may not be the case.
|
|
68
|
+
// It would be prudent for developers to add a conditions to the route to
|
|
69
|
+
// check is this is the case, eg
|
|
70
|
+
// setRoute('a', 'b');
|
|
71
|
+
// setRoute('b', 'url:///otherapp/')
|
|
72
|
+
// setRoute('url:////otherapp/', 'c', (r, c) => checkIfOtherAppIsFinished())
|
|
73
|
+
if (Plan.isExitNode(nextWaypoint)) {
|
|
74
|
+
log.trace(`Next waypoint is an exit node; clearing validation state on ${nextWaypoint}`);
|
|
75
|
+
req.casa.journeyContext.clearValidationErrorsForPage(nextWaypoint);
|
|
76
|
+
JourneyContext.putContext(req.session, req.casa.journeyContext);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Construct the next url
|
|
80
|
+
const nextUrl = waypointUrl({
|
|
81
|
+
waypoint: nextWaypoint,
|
|
82
|
+
mountUrl: `${req.baseUrl}/`,
|
|
83
|
+
journeyContext: req.casa.journeyContext,
|
|
84
|
+
edit: req.casa.editMode,
|
|
85
|
+
editOrigin: req.casa.editOrigin,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Save and move on
|
|
89
|
+
log.trace(`Redirecting to ${nextUrl}`);
|
|
90
|
+
return saveAndRedirect(req.session, req.casa.journeyContext, nextUrl, res, next);
|
|
91
|
+
},
|
|
92
|
+
];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Sanitise the fields submitted from a form
|
|
2
|
+
// - Coerce each field to its correct type
|
|
3
|
+
// - Remove an extraneous fields that are not know to the application
|
|
4
|
+
|
|
5
|
+
import _ from 'lodash';
|
|
6
|
+
import fieldFactory from '../lib/field.js';
|
|
7
|
+
import JourneyContext from '../lib/JourneyContext.js';
|
|
8
|
+
|
|
9
|
+
export default ({
|
|
10
|
+
waypoint,
|
|
11
|
+
fields = [],
|
|
12
|
+
}) => {
|
|
13
|
+
// Add some common, transient fields to ensure they survive beyond this sanitisation process
|
|
14
|
+
fields.push(fieldFactory('_csrf', { persist: false }).processor((value) => String(value)));
|
|
15
|
+
fields.push(fieldFactory('contextid', { persist: false }).processor((value) => String(value)));
|
|
16
|
+
fields.push(fieldFactory('edit', { persist: false }).processor((value) => String(value)));
|
|
17
|
+
fields.push(fieldFactory('editorigin', { persist: false }).processor((value) => String(value)));
|
|
18
|
+
|
|
19
|
+
// Middleware
|
|
20
|
+
return [
|
|
21
|
+
(req, res, next) => {
|
|
22
|
+
// First, prune all undefined, or unknown fields from `req.body` (i.e.
|
|
23
|
+
// those that do not have an entry in `fields`)
|
|
24
|
+
// EsLint disabled as `fields`, `i` & `name` are only controlled by dev
|
|
25
|
+
/* eslint-disable security/detect-object-injection */
|
|
26
|
+
const prunedBody = Object.create(null);
|
|
27
|
+
for (let i = 0, l = fields.length; i < l; i++) {
|
|
28
|
+
if (_.has(req.body, fields[i].name) && req.body[fields[i].name] !== undefined) {
|
|
29
|
+
prunedBody[fields[i].name] = req.body[fields[i].name];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/* eslint-enable security/detect-object-injection */
|
|
33
|
+
|
|
34
|
+
const journeyContext = JourneyContext.fromContext(req.casa.journeyContext);
|
|
35
|
+
journeyContext.setDataForPage(waypoint, prunedBody);
|
|
36
|
+
|
|
37
|
+
// Second, prune any fields that do not pass the validation conditional,
|
|
38
|
+
// and process those that do.
|
|
39
|
+
const sanitisedBody = Object.create(null);
|
|
40
|
+
for (let i = 0, l = fields.length; i < l; i++) {
|
|
41
|
+
const field = fields[i]; /* eslint-disable-line security/detect-object-injection */
|
|
42
|
+
const fieldValue = field.getValue(prunedBody);
|
|
43
|
+
|
|
44
|
+
if (fieldValue !== undefined && field.testConditions({
|
|
45
|
+
fieldValue,
|
|
46
|
+
waypoint,
|
|
47
|
+
journeyContext,
|
|
48
|
+
})) {
|
|
49
|
+
field.putValue(sanitisedBody, field.applyProcessors(fieldValue));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Finally, write the sanitised body back to the request object
|
|
54
|
+
req.body = sanitisedBody;
|
|
55
|
+
next();
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { validateUrlPath } from '../lib/utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @access private
|
|
5
|
+
* @typedef {import('express').RequestHandler} ExpressRequestHandler
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @access private
|
|
10
|
+
* @typedef {import('../casa').Plan} Plan
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Redirect the user to the first Plan waypoint when they request the root /
|
|
15
|
+
* path.
|
|
16
|
+
*
|
|
17
|
+
* @param {Plan} plan CASA Plan
|
|
18
|
+
* @returns {ExpressRequestHandler[]} Array of middleware
|
|
19
|
+
*/
|
|
20
|
+
export default ({
|
|
21
|
+
plan,
|
|
22
|
+
}) => [(req, res) => {
|
|
23
|
+
const reqUrl = new URL(req.url, 'https://placeholder.test/');
|
|
24
|
+
const reqPath = validateUrlPath(`${req.baseUrl}${reqUrl.pathname}${plan.getWaypoints()[0]}`);
|
|
25
|
+
let reqParams = reqUrl.searchParams.toString();
|
|
26
|
+
reqParams = reqParams ? `?${reqParams}` : '';
|
|
27
|
+
res.redirect(302, `${reqPath}${reqParams}`);
|
|
28
|
+
}];
|