@tryghost/mw-error-handler 1.0.4 → 1.0.6

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2013-2022 Ghost Foundation
3
+ Copyright (c) 2013-2025 Ghost Foundation
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,11 +1,15 @@
1
1
  const _ = require('lodash');
2
+ const path = require('path');
2
3
  const semver = require('semver');
3
4
  const debug = require('@tryghost/debug')('error-handler');
4
5
  const errors = require('@tryghost/errors');
5
6
  const {prepareStackForUser} = require('@tryghost/errors').utils;
7
+ const {isReqResUserSpecific, cacheControlValues} = require('@tryghost/http-cache-utils');
6
8
  const tpl = require('@tryghost/tpl');
7
9
 
8
10
  const messages = {
11
+ genericError: 'An unexpected error occurred, please try again.',
12
+ invalidValue: 'Invalid value',
9
13
  pageNotFound: 'Page not found',
10
14
  resourceNotFound: 'Resource not found',
11
15
  methodNotAcceptableVersionAhead: {
@@ -18,6 +22,7 @@ const messages = {
18
22
  context: 'Provided client accept-version {acceptVersion} is behind current Ghost version {ghostVersion}.',
19
23
  help: 'Try upgrading your Ghost API client.'
20
24
  },
25
+ badVersion: 'Requested version is not supported.',
21
26
  actions: {
22
27
  images: {
23
28
  upload: 'upload image'
@@ -51,23 +56,32 @@ const messages = {
51
56
  UnknownError: 'Unknown error - {name}, cannot {action}.'
52
57
  };
53
58
 
59
+ function isDependencyInStack(dependency, err) {
60
+ const dependencyPath = path.join('node_modules', dependency);
61
+
62
+ return err?.stack?.match(dependencyPath);
63
+ }
64
+
54
65
  /**
55
66
  * Get an error ready to be shown the the user
56
67
  */
57
- module.exports.prepareError = (err, req, res, next) => {
68
+ module.exports.prepareError = function prepareError(err, req, res, next) {
58
69
  debug(err);
59
70
 
60
71
  if (Array.isArray(err)) {
61
72
  err = err[0];
62
73
  }
63
74
 
75
+ // If the error is already a GhostError, it has been handled and can be returned as-is
76
+ // For everything else, we do some custom handling here
64
77
  if (!errors.utils.isGhostError(err)) {
65
- // We need a special case for 404 errors
66
- if (err.statusCode && err.statusCode === 404) {
78
+ // Catch bookshelf empty errors and other 404s, and turn into a Ghost 404
79
+ if ((err.statusCode && err.statusCode === 404) || err.message === 'EmptyResponse') {
67
80
  err = new errors.NotFoundError({
68
81
  err: err
69
82
  });
70
- } else if (err.stack.match(/node_modules\/handlebars\//)) {
83
+ // Catch handlebars / express-hbs errors, and render them as 400, rather than 500 errors as the server isn't broken
84
+ } else if (isDependencyInStack('handlebars', err) || isDependencyInStack('express-hbs', err)) {
71
85
  // Temporary handling of theme errors from handlebars
72
86
  // @TODO remove this when #10496 is solved properly
73
87
  err = new errors.IncorrectUsageError({
@@ -75,11 +89,33 @@ module.exports.prepareError = (err, req, res, next) => {
75
89
  message: err.message,
76
90
  statusCode: err.statusCode
77
91
  });
92
+ // Catch database errors and turn them into 500 errors, but log some useful data to sentry
93
+ } else if (isDependencyInStack('mysql2', err)) {
94
+ // we don't want to return raw database errors to our users
95
+ err.sqlErrorCode = err.code;
96
+
97
+ if (err.code === 'ER_WRONG_VALUE') {
98
+ err = new errors.ValidationError({
99
+ message: tpl(messages.invalidValue),
100
+ context: err.message,
101
+ err
102
+ });
103
+ } else {
104
+ err = new errors.InternalServerError({
105
+ err: err,
106
+ message: tpl(messages.genericError),
107
+ statusCode: err.statusCode,
108
+ code: 'UNEXPECTED_ERROR'
109
+ });
110
+ }
111
+ // For everything else, create a generic 500 error, with context set to the original error message
78
112
  } else {
79
113
  err = new errors.InternalServerError({
80
114
  err: err,
81
- message: err.message,
82
- statusCode: err.statusCode
115
+ message: tpl(messages.genericError),
116
+ context: err.message,
117
+ statusCode: err.statusCode,
118
+ code: 'UNEXPECTED_ERROR'
83
119
  });
84
120
  }
85
121
  }
@@ -90,21 +126,23 @@ module.exports.prepareError = (err, req, res, next) => {
90
126
  // alternative for res.status();
91
127
  res.statusCode = err.statusCode;
92
128
 
93
- // never cache errors
94
- res.set({
95
- 'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'
96
- });
97
-
98
129
  next(err);
99
130
  };
100
131
 
101
- module.exports.prepareStack = (err, req, res, next) => { // eslint-disable-line no-unused-vars
132
+ module.exports.prepareStack = function prepareStack(err, req, res, next) { // eslint-disable-line no-unused-vars
102
133
  const clonedError = prepareStackForUser(err);
103
134
 
104
135
  next(clonedError);
105
136
  };
106
137
 
107
- const jsonErrorRenderer = (err, req, res, next) => { // eslint-disable-line no-unused-vars
138
+ /**
139
+ * @private the method is exposed for testing purposes only
140
+ * @param {Object} err
141
+ * @param {import('express').Request} req
142
+ * @param {import('express').Response} res
143
+ * @param {import('express').NextFunction} next
144
+ */
145
+ module.exports.jsonErrorRenderer = function jsonErrorRenderer(err, req, res, next) { // eslint-disable-line no-unused-vars
108
146
  const userError = prepareUserMessage(err, req);
109
147
 
110
148
  res.json({
@@ -122,7 +160,32 @@ const jsonErrorRenderer = (err, req, res, next) => { // eslint-disable-line no-u
122
160
  });
123
161
  };
124
162
 
125
- const prepareUserMessage = (err, req) => {
163
+ /**
164
+ *
165
+ * @param {String} [cacheControlHeaderValue] cache-control header value
166
+ */
167
+ module.exports.prepareErrorCacheControl = function prepareErrorCacheControl(cacheControlHeaderValue) {
168
+ return function prepareErrorCacheControlInner(err, req, res, next) {
169
+ let cacheControl = cacheControlHeaderValue;
170
+ if (!cacheControlHeaderValue) {
171
+ // never cache errors unless it's a 404
172
+ cacheControl = cacheControlValues.private;
173
+
174
+ // Do not include 'private' cache-control directive for 404 responses
175
+ if (err.statusCode === 404 && req.method === 'GET' && !isReqResUserSpecific(req, res)) {
176
+ cacheControl = cacheControlValues.noCacheDynamic;
177
+ }
178
+ }
179
+
180
+ res.set({
181
+ 'Cache-Control': cacheControl
182
+ });
183
+
184
+ next(err);
185
+ };
186
+ };
187
+
188
+ const prepareUserMessage = function prepareUserMessage(err, req) {
126
189
  const userError = {
127
190
  message: err.message,
128
191
  context: err.context
@@ -172,66 +235,78 @@ const prepareUserMessage = (err, req) => {
172
235
  return userError;
173
236
  };
174
237
 
175
- module.exports.resourceNotFound = (req, res, next) => {
176
- if (req && req.headers && req.headers['accept-version']
177
- && res.locals && res.locals.safeVersion
178
- && semver.compare(semver.coerce(req.headers['accept-version']), semver.coerce(res.locals.safeVersion)) !== 0) {
179
- const versionComparison = semver.compare(
180
- semver.coerce(req.headers['accept-version']),
181
- semver.coerce(res.locals.safeVersion)
182
- );
183
-
184
- let notAcceptableError;
185
- if (versionComparison === 1) {
186
- notAcceptableError = new errors.RequestNotAcceptableError({
187
- message: tpl(
188
- messages.methodNotAcceptableVersionAhead.message
189
- ),
190
- context: tpl(messages.methodNotAcceptableVersionAhead.context, {
191
- acceptVersion: req.headers['accept-version'],
192
- ghostVersion: `v${res.locals.safeVersion}`
193
- }),
194
- help: tpl(messages.methodNotAcceptableVersionAhead.help),
195
- code: 'UPDATE_GHOST'
196
- });
197
- } else {
198
- notAcceptableError = new errors.RequestNotAcceptableError({
199
- message: tpl(
200
- messages.methodNotAcceptableVersionBehind.message
201
- ),
202
- context: tpl(messages.methodNotAcceptableVersionBehind.context, {
203
- acceptVersion: req.headers['accept-version'],
204
- ghostVersion: `v${res.locals.safeVersion}`
205
- }),
206
- help: tpl(messages.methodNotAcceptableVersionBehind.help),
207
- code: 'UPDATE_CLIENT'
208
- });
238
+ module.exports.resourceNotFound = function resourceNotFound(req, res, next) {
239
+ if (req?.headers?.['accept-version'] && res.locals?.safeVersion) {
240
+ // Protect against invalid `Accept-Version` headers
241
+ const acceptVersionSemver = semver.coerce(req.headers['accept-version']);
242
+ if (!acceptVersionSemver) {
243
+ return next(new errors.BadRequestError({
244
+ message: tpl(messages.badVersion)
245
+ }));
209
246
  }
210
247
 
211
- next(notAcceptableError);
212
- } else {
213
- next(new errors.NotFoundError({message: tpl(messages.resourceNotFound)}));
248
+ if (semver.compare(acceptVersionSemver, semver.coerce(res.locals.safeVersion)) !== 0) {
249
+ const versionComparison = semver.compare(
250
+ acceptVersionSemver,
251
+ semver.coerce(res.locals.safeVersion)
252
+ );
253
+
254
+ let notAcceptableError;
255
+ if (versionComparison === 1) {
256
+ notAcceptableError = new errors.RequestNotAcceptableError({
257
+ message: tpl(
258
+ messages.methodNotAcceptableVersionAhead.message
259
+ ),
260
+ context: tpl(messages.methodNotAcceptableVersionAhead.context, {
261
+ acceptVersion: req.headers['accept-version'],
262
+ ghostVersion: `v${res.locals.safeVersion}`
263
+ }),
264
+ help: tpl(messages.methodNotAcceptableVersionAhead.help),
265
+ code: 'UPDATE_GHOST'
266
+ });
267
+ } else {
268
+ notAcceptableError = new errors.RequestNotAcceptableError({
269
+ message: tpl(
270
+ messages.methodNotAcceptableVersionBehind.message
271
+ ),
272
+ context: tpl(messages.methodNotAcceptableVersionBehind.context, {
273
+ acceptVersion: req.headers['accept-version'],
274
+ ghostVersion: `v${res.locals.safeVersion}`
275
+ }),
276
+ help: tpl(messages.methodNotAcceptableVersionBehind.help),
277
+ code: 'UPDATE_CLIENT'
278
+ });
279
+ }
280
+
281
+ return next(notAcceptableError);
282
+ }
214
283
  }
284
+
285
+ next(new errors.NotFoundError({message: tpl(messages.resourceNotFound)}));
215
286
  };
216
287
 
217
- module.exports.pageNotFound = (req, res, next) => {
288
+ module.exports.pageNotFound = function pageNotFound(req, res, next) {
218
289
  next(new errors.NotFoundError({message: tpl(messages.pageNotFound)}));
219
290
  };
220
291
 
221
292
  module.exports.handleJSONResponse = sentry => [
222
293
  // Make sure the error can be served
223
294
  module.exports.prepareError,
295
+ // Add cache-control header
296
+ module.exports.prepareErrorCacheControl(),
224
297
  // Handle the error in Sentry
225
298
  sentry.errorHandler,
226
299
  // Format the stack for the user
227
300
  module.exports.prepareStack,
228
301
  // Render the error using JSON format
229
- jsonErrorRenderer
302
+ module.exports.jsonErrorRenderer
230
303
  ];
231
304
 
232
305
  module.exports.handleHTMLResponse = sentry => [
233
306
  // Make sure the error can be served
234
307
  module.exports.prepareError,
308
+ // Add cache-control header
309
+ module.exports.prepareErrorCacheControl(cacheControlValues.private),
235
310
  // Handle the error in Sentry
236
311
  sentry.errorHandler,
237
312
  // Format the stack for the user
package/package.json CHANGED
@@ -1,35 +1,36 @@
1
1
  {
2
2
  "name": "@tryghost/mw-error-handler",
3
- "version": "1.0.4",
4
- "repository": "https://github.com/TryGhost/Utils/tree/main/packages/mw-error-handler",
3
+ "version": "1.0.6",
5
4
  "author": "Ghost Foundation",
6
5
  "license": "MIT",
7
6
  "main": "index.js",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
8
10
  "scripts": {
9
11
  "dev": "echo \"Implement me!\"",
10
- "test": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura mocha './test/**/*.test.js'",
11
- "lint": "eslint . --ext .js --cache",
12
- "posttest": "yarn lint"
12
+ "test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura -- mocha --reporter dot './test/**/*.test.js'",
13
+ "test": "yarn test:unit",
14
+ "lint:code": "eslint *.js lib/ --ext .js --cache",
15
+ "lint": "yarn lint:code && yarn lint:test",
16
+ "lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
13
17
  },
14
18
  "files": [
15
19
  "index.js",
16
20
  "lib"
17
21
  ],
18
- "publishConfig": {
19
- "access": "public"
20
- },
21
22
  "devDependencies": {
22
- "c8": "7.11.3",
23
- "mocha": "10.0.0",
24
- "should": "13.2.3",
25
- "sinon": "14.0.0"
23
+ "c8": "8.0.1",
24
+ "mocha": "10.8.2",
25
+ "sinon": "15.2.0"
26
26
  },
27
27
  "dependencies": {
28
- "@tryghost/debug": "^0.1.9",
29
- "@tryghost/errors": "1.2.14",
30
- "@tryghost/tpl": "^0.1.8",
31
- "lodash": "^4.17.21",
32
- "semver": "^7.3.6"
28
+ "@tryghost/debug": "0.1.34",
29
+ "@tryghost/errors": "1.3.7",
30
+ "@tryghost/http-cache-utils": "0.1.19",
31
+ "@tryghost/tpl": "0.1.34",
32
+ "lodash": "4.17.21",
33
+ "semver": "7.7.2"
33
34
  },
34
- "gitHead": "60f77998eee90b3396e0df06ac0cb9879e5d39c8"
35
+ "gitHead": "0de834038d152c839357db3846b117e40cdc3052"
35
36
  }
package/README.md DELETED
@@ -1,39 +0,0 @@
1
- # Mw Error Handler
2
-
3
- ## Install
4
-
5
- `npm install @tryghost/mw-error-handler --save`
6
-
7
- or
8
-
9
- `yarn add @tryghost/mw-error-handler`
10
-
11
-
12
- ## Usage
13
-
14
-
15
- ## Develop
16
-
17
- This is a mono repository, managed with [lerna](https://lernajs.io/).
18
-
19
- Follow the instructions for the top-level repo.
20
- 1. `git clone` this repo & `cd` into it as usual
21
- 2. Run `yarn` to install top-level dependencies.
22
-
23
-
24
- ## Run
25
-
26
- - `yarn dev`
27
-
28
-
29
- ## Test
30
-
31
- - `yarn lint` run just eslint
32
- - `yarn test` run lint and tests
33
-
34
-
35
-
36
-
37
- # Copyright & License
38
-
39
- Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).