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 not acceptable for provided Accept-Version header.', context: 'Provided client version {acceptVersion} is ahead of current Ghost instance version {ghostVersion}.', help: 'Upgrade your Ghost instance.' }, methodNotAcceptableVersionBehind: { message: 'Request not acceptable for provided Accept-Version header.', context: 'Provided client version {acceptVersion} is outdated and is behind current Ghost version {ghostVersion}.', help: 'Upgrade 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 ];