ae88dc8548
fix https://linear.app/tryghost/issue/SLO-85/fix-http-500-on-contentposts - in the event we give the incorrect format in a filter, MySQL will throw an error and we'll throw a HTTP 500 error - we can capture this error and return a more useful error to the user - ideally we'd do this in a validation step before attempting the query, but parsing this out of NQL and detecting which columns are DATETIME could be quite tricky
315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
const _ = require('lodash');
|
|
const path = require('path');
|
|
const semver = require('semver');
|
|
const debug = require('@tryghost/debug')('error-handler');
|
|
const errors = require('@tryghost/errors');
|
|
const {prepareStackForUser} = require('@tryghost/errors').utils;
|
|
const {isReqResUserSpecific, cacheControlValues} = require('@tryghost/http-cache-utils');
|
|
const tpl = require('@tryghost/tpl');
|
|
|
|
const messages = {
|
|
genericError: 'An unexpected error occurred, please try again.',
|
|
invalidValue: 'Invalid value',
|
|
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.'
|
|
},
|
|
badVersion: 'Requested version is not supported.',
|
|
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}.'
|
|
};
|
|
|
|
function isDependencyInStack(dependency, err) {
|
|
const dependencyPath = path.join('node_modules', dependency);
|
|
|
|
return err?.stack?.match(dependencyPath);
|
|
}
|
|
|
|
/**
|
|
* Get an error ready to be shown the the user
|
|
*/
|
|
module.exports.prepareError = function prepareError(err, req, res, next) {
|
|
debug(err);
|
|
|
|
if (Array.isArray(err)) {
|
|
err = err[0];
|
|
}
|
|
|
|
// If the error is already a GhostError, it has been handled and can be returned as-is
|
|
// For everything else, we do some custom handling here
|
|
if (!errors.utils.isGhostError(err)) {
|
|
// Catch bookshelf empty errors and other 404s, and turn into a Ghost 404
|
|
if ((err.statusCode && err.statusCode === 404) || err.message === 'EmptyResponse') {
|
|
err = new errors.NotFoundError({
|
|
err: err
|
|
});
|
|
// Catch handlebars / express-hbs errors, and render them as 400, rather than 500 errors as the server isn't broken
|
|
} else if (isDependencyInStack('handlebars', err) || isDependencyInStack('express-hbs', err)) {
|
|
// 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
|
|
});
|
|
// Catch database errors and turn them into 500 errors, but log some useful data to sentry
|
|
} else if (isDependencyInStack('mysql2', err)) {
|
|
// we don't want to return raw database errors to our users
|
|
err.sqlErrorCode = err.code;
|
|
|
|
if (err.code === 'ER_WRONG_VALUE') {
|
|
err = new errors.ValidationError({
|
|
message: tpl(messages.invalidValue),
|
|
context: err.message,
|
|
err
|
|
});
|
|
} else {
|
|
err = new errors.InternalServerError({
|
|
err: err,
|
|
message: tpl(messages.genericError),
|
|
statusCode: err.statusCode,
|
|
code: 'UNEXPECTED_ERROR'
|
|
});
|
|
}
|
|
// For everything else, create a generic 500 error, with context set to the original error message
|
|
} else {
|
|
err = new errors.InternalServerError({
|
|
err: err,
|
|
message: tpl(messages.genericError),
|
|
context: err.message,
|
|
statusCode: err.statusCode,
|
|
code: 'UNEXPECTED_ERROR'
|
|
});
|
|
}
|
|
}
|
|
|
|
// used for express logging middleware see core/server/app.js
|
|
req.err = err;
|
|
|
|
// alternative for res.status();
|
|
res.statusCode = err.statusCode;
|
|
|
|
next(err);
|
|
};
|
|
|
|
module.exports.prepareStack = function prepareStack(err, req, res, next) { // eslint-disable-line no-unused-vars
|
|
const clonedError = prepareStackForUser(err);
|
|
|
|
next(clonedError);
|
|
};
|
|
|
|
/**
|
|
* @private the method is exposed for testing purposes only
|
|
* @param {Object} err
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
* @param {import('express').NextFunction} next
|
|
*/
|
|
module.exports.jsonErrorRenderer = function jsonErrorRenderer(err, req, res, next) { // eslint-disable-line no-unused-vars
|
|
const userError = prepareUserMessage(err, req);
|
|
|
|
res.json({
|
|
errors: [{
|
|
message: userError.message,
|
|
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,
|
|
ghostErrorCode: err.ghostErrorCode || null
|
|
}]
|
|
});
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @param {String} [cacheControlHeaderValue] cache-control header value
|
|
*/
|
|
module.exports.prepareErrorCacheControl = function prepareErrorCacheControl(cacheControlHeaderValue) {
|
|
return function prepareErrorCacheControlInner(err, req, res, next) {
|
|
let cacheControl = cacheControlHeaderValue;
|
|
if (!cacheControlHeaderValue) {
|
|
// never cache errors unless it's a 404
|
|
cacheControl = cacheControlValues.private;
|
|
|
|
// Do not include 'private' cache-control directive for 404 responses
|
|
if (err.statusCode === 404 && req.method === 'GET' && !isReqResUserSpecific(req, res)) {
|
|
cacheControl = cacheControlValues.noCacheDynamic;
|
|
}
|
|
}
|
|
|
|
res.set({
|
|
'Cache-Control': cacheControl
|
|
});
|
|
|
|
next(err);
|
|
};
|
|
};
|
|
|
|
const prepareUserMessage = function 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 = function resourceNotFound(req, res, next) {
|
|
if (req?.headers?.['accept-version'] && res.locals?.safeVersion) {
|
|
// Protect against invalid `Accept-Version` headers
|
|
const acceptVersionSemver = semver.coerce(req.headers['accept-version']);
|
|
if (!acceptVersionSemver) {
|
|
return next(new errors.BadRequestError({
|
|
message: tpl(messages.badVersion)
|
|
}));
|
|
}
|
|
|
|
if (semver.compare(acceptVersionSemver, semver.coerce(res.locals.safeVersion)) !== 0) {
|
|
const versionComparison = semver.compare(
|
|
acceptVersionSemver,
|
|
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'
|
|
});
|
|
}
|
|
|
|
return next(notAcceptableError);
|
|
}
|
|
}
|
|
|
|
next(new errors.NotFoundError({message: tpl(messages.resourceNotFound)}));
|
|
};
|
|
|
|
module.exports.pageNotFound = function pageNotFound(req, res, next) {
|
|
next(new errors.NotFoundError({message: tpl(messages.pageNotFound)}));
|
|
};
|
|
|
|
module.exports.handleJSONResponse = sentry => [
|
|
// Make sure the error can be served
|
|
module.exports.prepareError,
|
|
// Add cache-control header
|
|
module.exports.prepareErrorCacheControl(),
|
|
// Handle the error in Sentry
|
|
sentry.errorHandler,
|
|
// Format the stack for the user
|
|
module.exports.prepareStack,
|
|
// Render the error using JSON format
|
|
module.exports.jsonErrorRenderer
|
|
];
|
|
|
|
module.exports.handleHTMLResponse = sentry => [
|
|
// Make sure the error can be served
|
|
module.exports.prepareError,
|
|
// Add cache-control header
|
|
module.exports.prepareErrorCacheControl(cacheControlValues.private),
|
|
// Handle the error in Sentry
|
|
sentry.errorHandler,
|
|
// Format the stack for the user
|
|
module.exports.prepareStack
|
|
];
|