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