Ghost/ghost/mw-error-handler/lib/mw-error-handler.js
Hannah Wolfe 9e6f5c5194 Updated copy for accept-version errors
- Copy has been updated and approved now :)
2022-05-04 13:04:40 +01:00

271 lines
9.5 KiB
JavaScript

const _ = require('lodash');
const semver = require('semver');
const debug = require('@tryghost/debug')('error-handler');
const errors = require('@tryghost/errors');
const {prepareStackForUser} = require('@tryghost/errors').utils;
const tpl = require('@tryghost/tpl');
const messages = {
pageNotFound: 'Page not found',
resourceNotFound: 'Resource not found',
methodNotAcceptableVersionAhead: {
message: 'Request could not be served, the endpoint was not found.',
context: 'Provided client accept-version {acceptVersion} is ahead of current Ghost version {ghostVersion}.',
help: 'Try upgrading your Ghost install.'
},
methodNotAcceptableVersionBehind: {
message: 'Request could not be served, the endpoint was not found.',
context: 'Provided client accept-version {acceptVersion} is behind current Ghost version {ghostVersion}.',
help: 'Try upgrading your Ghost API client.'
},
actions: {
images: {
upload: 'upload image'
}
},
userMessages: {
BookshelfRelationsError: 'Database error, cannot {action}.',
InternalServerError: 'Internal server error, cannot {action}.',
IncorrectUsageError: 'Incorrect usage error, cannot {action}.',
NotFoundError: 'Resource not found error, cannot {action}.',
BadRequestError: 'Request not understood error, cannot {action}.',
UnauthorizedError: 'Authorisation error, cannot {action}.',
NoPermissionError: 'Permission error, cannot {action}.',
ValidationError: 'Validation error, cannot {action}.',
UnsupportedMediaTypeError: 'Unsupported media error, cannot {action}.',
TooManyRequestsError: 'Too many requests error, cannot {action}.',
MaintenanceError: 'Server down for maintenance, cannot {action}.',
MethodNotAllowedError: 'Method not allowed, cannot {action}.',
RequestEntityTooLargeError: 'Request too large, cannot {action}.',
TokenRevocationError: 'Token is not available, cannot {action}.',
VersionMismatchError: 'Version mismatch error, cannot {action}.',
DataExportError: 'Error exporting content.',
DataImportError: 'Duplicated entry, cannot save {action}.',
DatabaseVersionError: 'Database version compatibility error, cannot {action}.',
EmailError: 'Error sending email!',
ThemeValidationError: 'Theme validation error, cannot {action}.',
HostLimitError: 'Host Limit error, cannot {action}.',
DisabledFeatureError: 'Theme validation error, the {{{helperName}}} helper is not available. Cannot {action}.',
UpdateCollisionError: 'Saving failed! Someone else is editing this post.'
},
UnknownError: 'Unknown error - {name}, cannot {action}.'
};
/**
* Get an error ready to be shown the the user
*/
module.exports.prepareError = (err, req, res, next) => {
debug(err);
if (Array.isArray(err)) {
err = err[0];
}
if (!errors.utils.isGhostError(err)) {
// We need a special case for 404 errors
if (err.statusCode && err.statusCode === 404) {
err = new errors.NotFoundError({
err: err
});
} else if (err.stack.match(/node_modules\/handlebars\//)) {
// Temporary handling of theme errors from handlebars
// @TODO remove this when #10496 is solved properly
err = new errors.IncorrectUsageError({
err: err,
message: err.message,
statusCode: err.statusCode
});
} else {
err = new errors.InternalServerError({
err: err,
message: err.message,
statusCode: err.statusCode
});
}
}
// used for express logging middleware see core/server/app.js
req.err = err;
// alternative for res.status();
res.statusCode = err.statusCode;
// never cache errors
res.set({
'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'
});
next(err);
};
module.exports.prepareStack = (err, req, res, next) => { // eslint-disable-line no-unused-vars
const clonedError = prepareStackForUser(err);
next(clonedError);
};
const jsonErrorRenderer = (err, req, res, next) => { // eslint-disable-line no-unused-vars
res.json({
errors: [{
message: err.message,
context: err.context,
help: err.help,
errorType: err.errorType,
errorDetails: err.errorDetails,
ghostErrorCode: err.ghostErrorCode
}]
});
};
const jsonErrorRendererV2 = (err, req, res, next) => { // eslint-disable-line no-unused-vars
const userError = prepareUserMessage(err, req);
res.json({
errors: [{
message: userError.message || null,
context: userError.context || null,
type: err.errorType || null,
details: err.errorDetails || null,
property: err.property || null,
help: err.help || null,
code: err.code || null,
id: err.id || null
// @TODO: add ghostErrorCode here in a major (I thought V2 was for the V2 api, but it's actually the newer/correct handler)
}]
});
};
const prepareUserMessage = (err, req) => {
const userError = {
message: err.message,
context: err.context
};
const docName = _.get(req, 'frameOptions.docName');
const method = _.get(req, 'frameOptions.method');
if (docName && method) {
let action;
const actionMap = {
browse: 'list',
read: 'read',
add: 'save',
edit: 'edit',
destroy: 'delete'
};
if (_.get(messages.actions, [docName, method])) {
action = tpl(messages.actions[docName][method]);
} else if (Object.keys(actionMap).includes(method)) {
let resource = docName;
if (method !== 'browse') {
resource = resource.replace(/s$/, '');
}
action = `${actionMap[method]} ${resource}`;
}
if (action) {
if (err.context) {
userError.context = `${err.message} ${err.context}`;
} else {
userError.context = err.message;
}
if (_.get(messages.userMessages, err.name)) {
userError.message = tpl(messages.userMessages[err.name], {action: action});
} else {
userError.message = tpl(messages.UnknownError, {action, name: err.name});
}
}
}
return userError;
};
module.exports.resourceNotFound = (req, res, next) => {
if (req && req.headers && req.headers['accept-version']
&& res.locals && res.locals.safeVersion
&& semver.compare(semver.coerce(req.headers['accept-version']), semver.coerce(res.locals.safeVersion)) !== 0) {
const versionComparison = semver.compare(
semver.coerce(req.headers['accept-version']),
semver.coerce(res.locals.safeVersion)
);
let notAcceptableError;
if (versionComparison === 1) {
notAcceptableError = new errors.RequestNotAcceptableError({
message: tpl(
messages.methodNotAcceptableVersionAhead.message
),
context: tpl(messages.methodNotAcceptableVersionAhead.context, {
acceptVersion: req.headers['accept-version'],
ghostVersion: `v${res.locals.safeVersion}`
}),
help: tpl(messages.methodNotAcceptableVersionAhead.help),
code: 'UPDATE_GHOST'
});
} else {
notAcceptableError = new errors.RequestNotAcceptableError({
message: tpl(
messages.methodNotAcceptableVersionBehind.message
),
context: tpl(messages.methodNotAcceptableVersionBehind.context, {
acceptVersion: req.headers['accept-version'],
ghostVersion: `v${res.locals.safeVersion}`
}),
help: tpl(messages.methodNotAcceptableVersionBehind.help),
code: 'UPDATE_CLIENT'
});
}
next(notAcceptableError);
} else {
next(new errors.NotFoundError({message: tpl(messages.resourceNotFound)}));
}
};
module.exports.pageNotFound = (req, res, next) => {
next(new errors.NotFoundError({message: tpl(messages.pageNotFound)}));
};
/**
* @deprecated: not used as of Ghost v5 and should be removed in a major bump
*/
module.exports.handleJSONResponse = sentry => [
// Make sure the error can be served
module.exports.prepareError,
// Handle the error in Sentry
sentry.errorHandler,
// Format the stack for the user
module.exports.prepareStack,
// Render the error using JSON format
jsonErrorRenderer
];
/**
* @deprecated with above
* @TODO: rename this without the v2 in a major bump
*/
module.exports.handleJSONResponseV2 = sentry => [
// Make sure the error can be served
module.exports.prepareError,
// Handle the error in Sentry
sentry.errorHandler,
// Format the stack for the user
module.exports.prepareStack,
// Render the error using JSON format
jsonErrorRendererV2
];
module.exports.handleHTMLResponse = sentry => [
// Make sure the error can be served
module.exports.prepareError,
// Handle the error in Sentry
sentry.errorHandler,
// Format the stack for the user
module.exports.prepareStack
];