hof 20.0.0-beta.3 → 20.0.0-beta.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.github/workflows/automate-publish.yml +1 -1
  2. package/.github/workflows/automate-tag.yml +1 -1
  3. package/README.md +329 -256
  4. package/build/lib/mkdir.js +2 -2
  5. package/components/date/index.js +37 -26
  6. package/components/date/templates/date.html +3 -3
  7. package/components/emailer/index.js +49 -41
  8. package/components/emailer/transports/debug.js +1 -2
  9. package/components/index.js +2 -1
  10. package/components/notify/index.js +60 -0
  11. package/components/notify/notify.js +25 -0
  12. package/components/summary/index.js +18 -0
  13. package/config/hof-defaults.js +5 -3
  14. package/config/rate-limits.js +20 -0
  15. package/config/sanitisation-rules.js +29 -0
  16. package/controller/base-controller.js +26 -8
  17. package/controller/controller.js +23 -17
  18. package/frontend/govuk-template/build/config.js +1 -1
  19. package/frontend/template-mixins/mixins/template-mixins.js +12 -8
  20. package/frontend/template-mixins/partials/forms/checkbox-group.html +13 -4
  21. package/frontend/template-mixins/partials/forms/input-text-date.html +1 -1
  22. package/frontend/template-mixins/partials/forms/input-text-group.html +6 -4
  23. package/frontend/template-mixins/partials/forms/option-group.html +12 -3
  24. package/frontend/template-mixins/partials/forms/select.html +3 -3
  25. package/frontend/template-mixins/partials/forms/textarea-group.html +3 -3
  26. package/frontend/template-mixins/partials/mixins/panel.html +1 -2
  27. package/frontend/template-partials/translations/src/en/errors.json +12 -0
  28. package/frontend/template-partials/views/layout.html +10 -3
  29. package/frontend/template-partials/views/partials/cookie-banner.html +1 -1
  30. package/frontend/template-partials/views/partials/form.html +2 -1
  31. package/frontend/template-partials/views/partials/warn.html +7 -0
  32. package/frontend/template-partials/views/rate-limit-error.html +10 -0
  33. package/frontend/themes/gov-uk/client-js/govuk-cookies.js +43 -44
  34. package/frontend/themes/gov-uk/client-js/index.js +2 -2
  35. package/frontend/themes/gov-uk/client-js/skip-to-main.js +18 -17
  36. package/frontend/themes/gov-uk/styles/govuk.scss +4 -0
  37. package/frontend/themes/gov-uk/styles/modules/_validation.scss +2 -2
  38. package/frontend/toolkit/assets/javascript/form-focus.js +10 -1
  39. package/frontend/toolkit/assets/javascript/validation.js +6 -1
  40. package/index.js +9 -4
  41. package/lib/router.js +2 -1
  42. package/lib/settings.js +9 -8
  43. package/middleware/errors.js +32 -0
  44. package/middleware/index.js +2 -1
  45. package/middleware/rate-limiter.js +98 -0
  46. package/package.json +6 -6
  47. package/sandbox/apps/sandbox/fields.js +12 -12
  48. package/sandbox/apps/sandbox/index.js +1 -5
  49. package/sandbox/apps/sandbox/translations/en/default.json +24 -0
  50. package/sandbox/assets/scss/app.scss +0 -52
  51. package/sandbox/package.json +2 -0
  52. package/sandbox/public/css/app.css +4908 -4965
  53. package/sandbox/public/js/bundle.js +79 -65
  54. package/sandbox/server.js +7 -1
  55. package/sandbox/yarn.lock +39 -564
  56. package/transpiler/lib/write-files.js +1 -2
  57. package/utilities/helpers/index.js +16 -1
  58. package/wizard/index.js +1 -0
  59. package/.nyc_output/65af88d9-aebe-4d1b-a21d-6fbf7f2bbda4.json +0 -1
  60. package/.nyc_output/processinfo/65af88d9-aebe-4d1b-a21d-6fbf7f2bbda4.json +0 -1
  61. package/.nyc_output/processinfo/index.json +0 -1
  62. package/.vscode/settings.json +0 -6
  63. package/sandbox/.env +0 -1
package/lib/settings.js CHANGED
@@ -7,19 +7,20 @@ const hoganExpressStrict = require('hogan-express-strict');
7
7
  const expressPartialTemplates = require('express-partial-templates');
8
8
  const bodyParser = require('body-parser');
9
9
 
10
- const filterEmptyViews = (views) => {
11
- return views.filter(view => dirExists(view))
12
- }
13
-
14
- const dirExists = (dir) => {
10
+ const dirExists = dir => {
15
11
  try {
16
12
  if (fs.existsSync(dir)) {
17
13
  return true;
18
14
  }
15
+ return false;
19
16
  } catch(err) {
20
- throw new Error(`${err}: Cannot check if the directory path exists`)
17
+ throw new Error(`${err}: Cannot check if the directory path exists`);
21
18
  }
22
- }
19
+ };
20
+
21
+ const filterEmptyViews = views => {
22
+ return views.filter(view => dirExists(view));
23
+ };
23
24
 
24
25
  module.exports = async (app, config) => {
25
26
  const viewEngine = config.viewEngine || 'html';
@@ -31,7 +32,7 @@ module.exports = async (app, config) => {
31
32
 
32
33
  app.use(config.theme());
33
34
 
34
- const filteredViews = filterEmptyViews(config.theme.views)
35
+ const filteredViews = filterEmptyViews(config.theme.views);
35
36
  const viewPaths = [].concat(filteredViews);
36
37
  app.set('view engine', viewEngine);
37
38
  app.enable('view cache');
@@ -1,6 +1,8 @@
1
1
  /* eslint-disable no-unused-vars */
2
2
  'use strict';
3
3
 
4
+ const rateLimitsConfig = require('../config/rate-limits');
5
+
4
6
  const errorTitle = code => `${code}_ERROR`;
5
7
  const errorMsg = code => `There is a ${code}_ERROR`;
6
8
  // eslint-disable-next-line complexity
@@ -21,6 +23,36 @@ const getContent = (err, translate) => {
21
23
  content.message = (translate && translate('errors.cookies-required.message'));
22
24
  }
23
25
 
26
+ if (err.code === 'DDOS_RATE_LIMIT') {
27
+ err.status = 429;
28
+ err.template = 'rate-limit-error';
29
+ err.title = (translate && translate('errors.ddos-rate-limit.title'));
30
+ err.message = (translate && translate('errors.ddos-rate-limit.message'));
31
+ err.preTimeToWait = (translate && translate('errors.ddos-rate-limit.pre-time-to-wait'));
32
+ err.timeToWait = rateLimitsConfig.rateLimits.requests.windowSizeInMinutes;
33
+ err.postTimeToWait = (translate && translate('errors.ddos-rate-limit.post-time-to-wait'));
34
+ content.title = (translate && translate('errors.ddos-rate-limit.title'));
35
+ content.message = (translate && translate('errors.ddos-rate-limit.message'));
36
+ content.preTimeToWait = (translate && translate('errors.ddos-rate-limit.pre-time-to-wait'));
37
+ content.timeToWait = rateLimitsConfig.rateLimits.requests.windowSizeInMinutes;
38
+ content.postTimeToWait = (translate && translate('errors.ddos-rate-limit.post-time-to-wait'));
39
+ }
40
+
41
+ if (err.code === 'SUBMISSION_RATE_LIMIT') {
42
+ err.status = 429;
43
+ err.template = 'rate-limit-error';
44
+ err.title = (translate && translate('errors.submission-rate-limit.title'));
45
+ err.message = (translate && translate('errors.submission-rate-limit.message'));
46
+ err.preTimeToWait = (translate && translate('errors.submission-rate-limit.pre-time-to-wait'));
47
+ err.timeToWait = rateLimitsConfig.rateLimits.submissions.windowSizeInMinutes;
48
+ err.postTimeToWait = (translate && translate('errors.submission-rate-limit.post-time-to-wait'));
49
+ content.title = (translate && translate('errors.submission-rate-limit.title'));
50
+ content.message = (translate && translate('errors.submission-rate-limit.message'));
51
+ content.preTimeToWait = (translate && translate('errors.submission-rate-limit.pre-time-to-wait'));
52
+ content.timeToWait = rateLimitsConfig.rateLimits.submissions.windowSizeInMinutes;
53
+ content.postTimeToWait = (translate && translate('errors.submission-rate-limit.post-time-to-wait'));
54
+ }
55
+
24
56
  err.code = err.code || 'UNKNOWN';
25
57
  err.status = err.status || 500;
26
58
 
@@ -4,5 +4,6 @@ module.exports = {
4
4
  cookies: require('./cookies'),
5
5
  errors: require('./errors'),
6
6
  notFound: require('./not-found'),
7
- deepTranslate: require('./deep-translate')
7
+ deepTranslate: require('./deep-translate'),
8
+ rateLimiter: require('./rate-limiter')
8
9
  };
@@ -0,0 +1,98 @@
1
+
2
+ const moment = require('moment');
3
+ const redis = require('redis');
4
+ const config = require('./../config/hof-defaults');
5
+
6
+ module.exports = (options, rateLimitType) => {
7
+ // eslint-disable-next-line no-console
8
+ const logger = options.logger || { log: (func, msg) => console[func](msg) };
9
+ const rateLimits = options.rateLimits[rateLimitType];
10
+ const timestampName = `${rateLimitType}TimeStamp`;
11
+ const countName = `${rateLimitType}Count`;
12
+
13
+ const WINDOW_SIZE_IN_MINUTES = rateLimits.windowSizeInMinutes;
14
+ const MAX_WINDOW_REQUEST_COUNT = rateLimits.maxWindowRequestCount;
15
+ const WINDOW_LOG_INTERVAL_IN_MINUTES = rateLimits.windowLogIntervalInMinutes;
16
+ const ERROR_CODE = rateLimits.errCode;
17
+
18
+ return async (req, res, next) => {
19
+ const redisClient = redis.createClient(config.redis);
20
+
21
+ // check that redis client exists
22
+ if (!redisClient) {
23
+ logger.log('error', 'Redis client does not exist!');
24
+ return next();
25
+ }
26
+
27
+ const closeConnection = async err => {
28
+ await redisClient.quit();
29
+ return next(err);
30
+ };
31
+
32
+ try {
33
+ // fetch records of current user using IP address, returns null when no record is found
34
+ return await redisClient.get(req.ip, async (err, record) => {
35
+ if (err) {
36
+ logger.log('error', `Error with requesting redis session for rate limiting: ${err}`);
37
+ return await closeConnection();
38
+ }
39
+ const currentRequestTime = moment();
40
+ const windowStartTimestamp = moment().subtract(WINDOW_SIZE_IN_MINUTES, 'minutes').unix();
41
+ let oldRecord = false;
42
+ let data;
43
+ // if no record is found , create a new record for user and store to redis
44
+ if (record) {
45
+ data = JSON.parse(record);
46
+ oldRecord = data[data.length - 1][timestampName] < windowStartTimestamp;
47
+ }
48
+
49
+ if (!record || oldRecord) {
50
+ const newRecord = [];
51
+ const requestLog = {
52
+ [timestampName]: currentRequestTime.unix(),
53
+ [countName]: 1
54
+ };
55
+ newRecord.push(requestLog);
56
+ await redisClient.set(req.ip, JSON.stringify(newRecord));
57
+ return await closeConnection();
58
+ }
59
+ // if record is found, parse it's value and calculate number of requests users has made within the last window
60
+ const requestsWithinWindow = data.filter(entry => entry[timestampName] > windowStartTimestamp);
61
+
62
+ const totalWindowRequestsCount = requestsWithinWindow.reduce((accumulator, entry) => {
63
+ return accumulator + entry[countName];
64
+ }, 0);
65
+
66
+ if (!options.rateLimits.env || options.rateLimits.env === 'development') {
67
+ const requestsRemaining = MAX_WINDOW_REQUEST_COUNT - totalWindowRequestsCount;
68
+ const msg = `Requests made by client: ${totalWindowRequestsCount}\nRequests remaining: ${requestsRemaining}`;
69
+ logger.log('info', msg);
70
+ }
71
+ // if number of requests made is greater than or equal to the desired maximum, return error
72
+ if (totalWindowRequestsCount >= MAX_WINDOW_REQUEST_COUNT) {
73
+ return await closeConnection({ code: ERROR_CODE });
74
+ }
75
+ // if number of requests made is less than allowed maximum, log new entry
76
+ const lastRequestLog = data[data.length - 1];
77
+ const potentialCurrentWindowIntervalStartTimeStamp = currentRequestTime
78
+ .subtract(WINDOW_LOG_INTERVAL_IN_MINUTES, 'minutes')
79
+ .unix();
80
+ // if interval has not passed since last request log, increment counter
81
+ if (lastRequestLog[timestampName] > potentialCurrentWindowIntervalStartTimeStamp) {
82
+ lastRequestLog[countName]++;
83
+ data[data.length - 1] = lastRequestLog;
84
+ } else {
85
+ // if interval has passed, log new entry for current user and timestamp
86
+ data.push({
87
+ [timestampName]: currentRequestTime.unix(),
88
+ [countName]: 1
89
+ });
90
+ }
91
+ await redisClient.set(req.ip, JSON.stringify(data));
92
+ return await closeConnection();
93
+ });
94
+ } catch (err) {
95
+ return await closeConnection(err);
96
+ }
97
+ };
98
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hof",
3
3
  "description": "A bootstrap for HOF projects",
4
- "version": "20.0.0-beta.3",
4
+ "version": "20.0.0-beta.31",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
7
7
  "author": "HomeOffice",
@@ -70,16 +70,16 @@
70
70
  "lodash": "^4.17.21",
71
71
  "markdown-it": "^12.3.2",
72
72
  "minimatch": "^3.0.3",
73
- "minimist": "^1.2.0",
73
+ "minimist": "^1.2.6",
74
74
  "mixwith": "^0.1.1",
75
- "mkdirp": "^0.5.1",
76
- "moment": "^2.24.0",
75
+ "moment": "^2.29.2",
77
76
  "morgan": "^1.10.0",
78
77
  "mustache": "^2.3.0",
79
78
  "nodemailer": "^6.6.3",
80
79
  "nodemailer-ses-transport": "^1.5.0",
81
80
  "nodemailer-smtp-transport": "^2.7.4",
82
81
  "nodemailer-stub-transport": "^1.1.0",
82
+ "notifications-node-client": "^5.1.1",
83
83
  "redis": "^3.1.2",
84
84
  "reqres": "^3.0.1",
85
85
  "request": "^2.79.0",
@@ -88,8 +88,8 @@
88
88
  "serve-static": "^1.14.1",
89
89
  "uglify-js": "^3.14.3",
90
90
  "underscore": "^1.12.1",
91
- "urijs": "^1.19.10",
92
- "winston": "^3.3.3"
91
+ "urijs": "^1.19.11",
92
+ "winston": "^3.7.2"
93
93
  },
94
94
  "devDependencies": {
95
95
  "@cucumber/cucumber": "^7.3.0",
@@ -8,7 +8,7 @@ module.exports = {
8
8
  'landing-page-radio': {
9
9
  mixin: 'radio-group',
10
10
  validate: ['required'],
11
- noHeading: true,
11
+ isPageHeading: true,
12
12
  // Design system says to avoid in-line unless it's two options,
13
13
  // so just added as an example below but by default it isn't
14
14
  className: ['govuk-radios--inline'],
@@ -16,12 +16,13 @@ module.exports = {
16
16
  },
17
17
  name: {
18
18
  validate: ['required', 'notUrl', { type: 'maxlength', arguments: 200 }],
19
+ // need to remove this for the heading to go
19
20
  labelClassName: ['govuk-label--l'],
20
- // noHeading: 'true'
21
+ isPageHeading: 'true'
21
22
  },
22
23
  'dateOfBirth': dateComponent('dateOfBirth', {
23
- // noHeading: 'true',
24
- controlType: 'date-input',
24
+ mixin: 'input-date',
25
+ isPageHeading: 'true',
25
26
  validate: [
26
27
  'required',
27
28
  'date',
@@ -29,28 +30,24 @@ module.exports = {
29
30
  ]
30
31
  }),
31
32
  building: {
32
- noHeading: 'true',
33
33
  validate: ['required', 'notUrl', { type: 'maxlength', arguments: 100 }]
34
34
  },
35
35
  street: {
36
- noHeading: 'true',
37
36
  validate: ['notUrl', { type: 'maxlength', arguments: 50 }],
38
37
  labelClassName: 'visuallyhidden'
39
38
  },
40
39
  townOrCity: {
41
- noHeading: 'true',
42
40
  validate: ['required', 'notUrl',
43
41
  { type: 'regex', arguments: /^([^0-9]*)$/ },
44
42
  { type: 'maxlength', arguments: 100 }
45
43
  ]
46
44
  },
47
45
  postcode: {
48
- noHeading: 'true',
49
46
  validate: ['required', 'postcode'],
50
47
  formatter: ['removespaces', 'uppercase']
51
48
  },
52
49
  incomeTypes: {
53
- // noHeading: 'true',
50
+ isPageHeading: 'true',
54
51
  mixin: 'checkbox-group',
55
52
  labelClassName: 'visuallyhidden',
56
53
  validate: ['required'],
@@ -63,7 +60,7 @@ module.exports = {
63
60
  ]
64
61
  },
65
62
  countryOfHearing: {
66
- // noHeading: 'true',
63
+ isPageHeading: 'true',
67
64
  mixin: 'radio-group',
68
65
  validate: ['required'],
69
66
  options: [
@@ -73,7 +70,7 @@ module.exports = {
73
70
  ]
74
71
  },
75
72
  email: {
76
- // noHeading: 'true',
73
+ isPageHeading: 'true',
77
74
  labelClassName: ['govuk-label--l'],
78
75
  validate: ['required', 'email']
79
76
  },
@@ -86,6 +83,7 @@ module.exports = {
86
83
  },
87
84
  countrySelect: {
88
85
  mixin: 'select',
86
+ isPageHeading: 'true',
89
87
  className: ['typeahead'],
90
88
  options:[''].concat(require('homeoffice-countries').allCountries),
91
89
  legend: {
@@ -97,12 +95,13 @@ module.exports = {
97
95
  mixin: 'textarea',
98
96
  // we want to ignore default formatters as we want
99
97
  // to preserve white space
98
+ isPageHeading: 'true',
100
99
  'ignore-defaults': true,
101
100
  // apply the other default formatters
102
101
  formatter: ['trim', 'hyphens'],
103
102
  labelClassName: ['govuk-label--l'],
104
103
  // attributes here are passed to the field element
105
- validate: ['required', { type: 'maxlength', arguments: 100 }],
104
+ validate: ['required', { type: 'maxlength', arguments: 10 }],
106
105
  attributes: [{
107
106
  attribute: 'rows',
108
107
  value: 8
@@ -110,6 +109,7 @@ module.exports = {
110
109
  },
111
110
  appealStages: {
112
111
  mixin: 'select',
112
+ isPageHeading: 'true',
113
113
  validate: ['required'],
114
114
  options: [{
115
115
  value: '',
@@ -30,11 +30,7 @@ module.exports = {
30
30
  },
31
31
  '/dob': {
32
32
  fields: ['dateOfBirth'],
33
- next: '/address',
34
- locals: {
35
- step: 'dob',
36
- labelClassName: 'govuk-input'
37
- }
33
+ next: '/address'
38
34
  },
39
35
  '/address': {
40
36
  fields: ['building', 'street', 'townOrCity', 'postcode'],
@@ -94,6 +94,30 @@
94
94
  "options": {
95
95
  "null": "Select..."
96
96
  }
97
+ },
98
+ "purposeTypes": {
99
+ "legend": "What will you use the loan for?",
100
+ "hint": "Select all options that apply to you.",
101
+ "options": {
102
+ "housing": {
103
+ "label": "Housing",
104
+ "hint": "Deposit, rent payment or moving costs."
105
+ },
106
+ "essential_items": {
107
+ "label": "Essential items",
108
+ "hint": "For example, furniture, fridge, curtains or carpets."
109
+ },
110
+ "basic_living_costs": {
111
+ "label": "Basic living costs",
112
+ "hint": "For example, food or household bills."
113
+ },
114
+ "training_or_retraining": {
115
+ "label": "Training or education"
116
+ },
117
+ "work_clothing_and_equipment": {
118
+ "label": "Work clothing and equipment"
119
+ }
120
+ }
97
121
  }
98
122
  },
99
123
  "journey": {
@@ -1,6 +1,5 @@
1
1
 
2
2
  @import "../../../frontend/themes/gov-uk/styles/govuk";
3
- @import "../../../node_modules/govuk-frontend/govuk/all";
4
3
 
5
4
  //autocomplete styling
6
5
  .tt-menu {
@@ -26,54 +25,3 @@
26
25
  .twitter-typeahead {
27
26
  width: 100%;
28
27
  }
29
-
30
- // govuk cookie banner styling
31
- .govuk-banner--success {
32
- border-color: #00703c;
33
- color: #00703c;
34
- }
35
-
36
- .govuk-banner {
37
- border: 5px solid #1d70b8;
38
- font-size: 0;
39
- margin-bottom: 30px;
40
- padding: 10px;
41
- }
42
-
43
- .govuk-banner__icon{
44
- display: inline-block;
45
- }
46
-
47
- .govuk-banner__message {
48
- font-family: "GDS Transport", Arial, sans-serif;
49
- -webkit-font-smoothing: antialiased;
50
- font-weight: 400;
51
- font-size: 1rem;
52
- line-height: 1.25;
53
- color: #0b0c0c;
54
- display: block;
55
- overflow: hidden;
56
- display: inline-block;
57
- margin-left: 10px;
58
- }
59
-
60
- .govuk-banner__assistive {
61
- position: absolute !important;
62
- width: 1px !important;
63
- height: 1px !important;
64
- margin: 0 !important;
65
- padding: 0 !important;
66
- overflow: hidden !important;
67
- clip: rect(0 0 0 0) !important;
68
- clip-path: inset(50%) !important;
69
- border: 0 !important;
70
- white-space: nowrap !important;
71
- }
72
-
73
- .cookie-table-holder > table > tbody > tr > td:first-child{
74
- font-weight:bold;
75
- }
76
-
77
- .js-enabled #global-cookie-message {
78
- display: none;
79
- }
@@ -15,7 +15,9 @@
15
15
  },
16
16
  "author": "",
17
17
  "dependencies": {
18
+ "govuk-frontend": "3.14",
18
19
  "jquery": "^3.6.0",
20
+ "sass": "^1.53.0",
19
21
  "typeahead-aria": "^1.0.4"
20
22
  },
21
23
  "devDependencies": {