@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.
- package/dist/casa.d.ts +12 -1
- package/dist/casa.js +10 -2
- package/dist/casa.js.map +1 -0
- package/dist/lib/CasaTemplateLoader.js +1 -0
- package/dist/lib/CasaTemplateLoader.js.map +1 -0
- package/dist/lib/JourneyContext.d.ts +12 -3
- package/dist/lib/JourneyContext.js +20 -5
- 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 +1 -1
- package/dist/lib/Plan.js +2 -5
- package/dist/lib/Plan.js.map +1 -0
- package/dist/lib/ValidationError.js +1 -0
- 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.js +1 -0
- 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/constants.d.ts +9 -0
- package/dist/lib/constants.js +13 -0
- package/dist/lib/constants.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 +1 -0
- 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 +45 -27
- package/dist/lib/utils.js +105 -67
- package/dist/lib/utils.js.map +1 -0
- package/dist/lib/validators/dateObject.js +4 -3
- 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.js +1 -0
- 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 +40 -9
- 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 +10 -2
- 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 +7 -2
- 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 +2 -0
- package/dist/middleware/steer-journey.js.map +1 -0
- package/dist/middleware/strip-proxy-path.js +1 -0
- package/dist/middleware/strip-proxy-path.js.map +1 -0
- package/dist/middleware/validate-fields.js +7 -1
- package/dist/middleware/validate-fields.js.map +1 -0
- package/dist/mjs/esm-wrapper.js +11 -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 +17 -16
- package/src/casa.js +330 -0
- package/src/lib/CasaTemplateLoader.js +104 -0
- package/src/lib/JourneyContext.js +797 -0
- package/src/lib/MutableRouter.js +310 -0
- package/src/lib/Plan.js +619 -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/constants.js +9 -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 +126 -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 +58 -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 +96 -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 +79 -0
- package/src/middleware/strip-proxy-path.js +56 -0
- package/src/middleware/validate-fields.js +89 -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 +43 -34
- package/views/casa/components/checkboxes/template.njk +8 -7
- package/views/casa/components/date-input/README.md +11 -1
- 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/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 +49 -24
- 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
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
import { REQUEST_PHASE_REDIRECT } from '../lib/constants.js';
|
|
10
|
+
|
|
11
|
+
const log = logger('middleware:progress-journey');
|
|
12
|
+
|
|
13
|
+
const saveAndRedirect = (session, journeyContext, url, res, next) => {
|
|
14
|
+
JourneyContext.putContext(session, journeyContext, {
|
|
15
|
+
userInfo: {
|
|
16
|
+
casaRequestPhase: REQUEST_PHASE_REDIRECT,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
session.save((err) => {
|
|
21
|
+
if (err) {
|
|
22
|
+
next(err);
|
|
23
|
+
}
|
|
24
|
+
res.redirect(302, url);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default ({
|
|
29
|
+
waypoint,
|
|
30
|
+
plan,
|
|
31
|
+
}) => [
|
|
32
|
+
(req, res, next) => {
|
|
33
|
+
// Determine the next available waypoint after the current one
|
|
34
|
+
const traversed = plan.traverse(req.casa.journeyContext);
|
|
35
|
+
const currentIndex = traversed.indexOf(waypoint);
|
|
36
|
+
const nextIndex = Math.max(
|
|
37
|
+
currentIndex < 0 ? traversed.length - 1 : 0,
|
|
38
|
+
Math.min(currentIndex + 1, traversed.length - 1),
|
|
39
|
+
);
|
|
40
|
+
const nextWaypoint = traversed[parseInt(nextIndex, 10)];
|
|
41
|
+
log.trace(`currentIndex = ${currentIndex}, nextIndex = ${nextIndex}, currentWaypoint = ${waypoint}, nextWaypoint = ${nextWaypoint}`);
|
|
42
|
+
|
|
43
|
+
// Edit mode
|
|
44
|
+
// Attempt to take the user back to their original URL. We rely on the
|
|
45
|
+
// `steer-journey` middleware to prevent the user going too far ahead in
|
|
46
|
+
// their permitted journey. Bear in mind that the `editOrigin` may not be
|
|
47
|
+
// a waypoint at all, but a route path for a custom endpoint, so we can't
|
|
48
|
+
// safely do a traversal check here.
|
|
49
|
+
//
|
|
50
|
+
// The edit mode URL params will be kept on this redirect. This means the
|
|
51
|
+
// user can keep "jumping" to the next _changed_ waypoint, until they get
|
|
52
|
+
// back to the original URL.
|
|
53
|
+
//
|
|
54
|
+
// Devs should use the `events` mechanism to mark waypoints as invalid if
|
|
55
|
+
// they want to force the user to re-visit particular waypoints during this
|
|
56
|
+
// "jumping" phase.
|
|
57
|
+
if (req.casa.editMode && req.casa.editOrigin) {
|
|
58
|
+
const url = new URL(req.casa.editOrigin, 'https://placeholder.test/');
|
|
59
|
+
url.searchParams.append('edit', 'true');
|
|
60
|
+
url.searchParams.append('editorigin', req.casa.editOrigin);
|
|
61
|
+
const redirectUrl = waypointUrl({ waypoint: url.pathname }) + url.search.toString();
|
|
62
|
+
|
|
63
|
+
log.debug(`Edit mode detected; redirecting to ${redirectUrl}`);
|
|
64
|
+
|
|
65
|
+
return saveAndRedirect(req.session, req.casa.journeyContext, redirectUrl, res, next);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// If the next URL is an "exit node", we need to flag that node as
|
|
69
|
+
// being validated so that subsequent traversals of this journey continue
|
|
70
|
+
// correctly to any waypoints leading on from this one.
|
|
71
|
+
// This effectively says that the other Plan linked to by the exit node is
|
|
72
|
+
// complete, but of course that may not be the case.
|
|
73
|
+
// It would be prudent for developers to add a conditions to the route to
|
|
74
|
+
// check is this is the case, eg
|
|
75
|
+
// setRoute('a', 'b');
|
|
76
|
+
// setRoute('b', 'url:///otherapp/')
|
|
77
|
+
// setRoute('url:////otherapp/', 'c', (r, c) => checkIfOtherAppIsFinished())
|
|
78
|
+
if (Plan.isExitNode(nextWaypoint)) {
|
|
79
|
+
log.trace(`Next waypoint is an exit node; clearing validation state on ${nextWaypoint}`);
|
|
80
|
+
req.casa.journeyContext.clearValidationErrorsForPage(nextWaypoint);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Construct the next url
|
|
84
|
+
const nextUrl = waypointUrl({
|
|
85
|
+
waypoint: nextWaypoint,
|
|
86
|
+
mountUrl: `${req.baseUrl}/`,
|
|
87
|
+
journeyContext: req.casa.journeyContext,
|
|
88
|
+
edit: req.casa.editMode,
|
|
89
|
+
editOrigin: req.casa.editOrigin,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Save and move on
|
|
93
|
+
log.trace(`Redirecting to ${nextUrl}`);
|
|
94
|
+
return saveAndRedirect(req.session, req.casa.journeyContext, nextUrl, res, next);
|
|
95
|
+
},
|
|
96
|
+
];
|
|
@@ -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
|
+
}];
|
|
@@ -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,79 @@
|
|
|
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
|
+
journeyContext: req.casa.journeyContext,
|
|
71
|
+
waypoint: prevRoute.target,
|
|
72
|
+
routeName: 'prev',
|
|
73
|
+
edit: req.casa.editMode,
|
|
74
|
+
editOrigin: req.casa.editOrigin,
|
|
75
|
+
}) : undefined;
|
|
76
|
+
|
|
77
|
+
return next();
|
|
78
|
+
},
|
|
79
|
+
];
|
|
@@ -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,89 @@
|
|
|
1
|
+
// Validate the data captured in the journey context
|
|
2
|
+
import JourneyContext from '../lib/JourneyContext.js';
|
|
3
|
+
import { REQUEST_PHASE_VALIDATE } from '../lib/constants.js';
|
|
4
|
+
|
|
5
|
+
const updateContext = ({
|
|
6
|
+
waypoint,
|
|
7
|
+
errors = null,
|
|
8
|
+
journeyContext,
|
|
9
|
+
session,
|
|
10
|
+
}) => {
|
|
11
|
+
// Set validation state
|
|
12
|
+
if (errors === null) {
|
|
13
|
+
journeyContext.clearValidationErrorsForPage(waypoint);
|
|
14
|
+
} else {
|
|
15
|
+
journeyContext.setValidationErrorsForPage(waypoint, errors);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Save to session
|
|
19
|
+
JourneyContext.putContext(session, journeyContext, {
|
|
20
|
+
userInfo: {
|
|
21
|
+
casaRequestPhase: REQUEST_PHASE_VALIDATE,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default ({
|
|
27
|
+
waypoint,
|
|
28
|
+
fields = [],
|
|
29
|
+
plan,
|
|
30
|
+
}) => [
|
|
31
|
+
(req, res, next) => {
|
|
32
|
+
const mountUrl = `${req.baseUrl}/`;
|
|
33
|
+
|
|
34
|
+
// Run validators for every field to build up a complete list of errors
|
|
35
|
+
// currently associated with this waypoint.
|
|
36
|
+
let errors = [];
|
|
37
|
+
for (let i = 0, l = fields.length; i < l; i++) {
|
|
38
|
+
/* eslint-disable security/detect-object-injection */
|
|
39
|
+
// Dynamic object keys are only used on known entities (fields, waypoint)
|
|
40
|
+
const field = fields[i];
|
|
41
|
+
const fieldName = field.name;
|
|
42
|
+
const fieldValue = req.casa.journeyContext.data?.[waypoint]?.[fieldName];
|
|
43
|
+
|
|
44
|
+
// (type = ValidateContext)
|
|
45
|
+
const context = {
|
|
46
|
+
fieldName,
|
|
47
|
+
fieldValue,
|
|
48
|
+
waypoint,
|
|
49
|
+
journeyContext: req.casa.journeyContext,
|
|
50
|
+
};
|
|
51
|
+
/* eslint-enable security/detect-object-injection */
|
|
52
|
+
|
|
53
|
+
errors = [
|
|
54
|
+
...errors,
|
|
55
|
+
...field.runValidators(fieldValue, context),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validation passed with no errors
|
|
60
|
+
if (!errors.length) {
|
|
61
|
+
updateContext({
|
|
62
|
+
waypoint,
|
|
63
|
+
session: req.session,
|
|
64
|
+
mountUrl,
|
|
65
|
+
plan,
|
|
66
|
+
journeyContext: req.casa.journeyContext,
|
|
67
|
+
});
|
|
68
|
+
return next();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// If there are any native errors in the list, we need to bail the request
|
|
72
|
+
const nativeError = errors.find((e) => e instanceof Error);
|
|
73
|
+
if (nativeError) {
|
|
74
|
+
return next(nativeError);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Make the errors available to downstream middleware
|
|
78
|
+
updateContext({
|
|
79
|
+
errors,
|
|
80
|
+
waypoint,
|
|
81
|
+
session: req.session,
|
|
82
|
+
mountUrl,
|
|
83
|
+
plan,
|
|
84
|
+
journeyContext: req.casa.journeyContext,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return next();
|
|
88
|
+
},
|
|
89
|
+
];
|
|
@@ -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;
|