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

Sign up to get free protection for your applications and to get access to all the features.
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": {