mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-28 05:14:12 +03:00
dfffa309a8
refs: https://github.com/TryGhost/Team/issues/1121 - This makes several key changes to the way errors are handled in the member importer, to ensure that we only show error messages to users that we wrote. - Fundamentally, we no longer trust all API errors, and instead only trust a set of very specific API errors. Anything outside of that is replaced with a generic error message. - Also switches the server-side error generated for email verification (which can throw during member import) to be a HostLimitError, as that is a more appropriate class. - Note: there are many other parts of Ghost admin that need a similar overhaul, and a similar change we need to introduce server side to fully resolve the underlying issue of bubbling up code errors to the UI.
395 lines
12 KiB
JavaScript
395 lines
12 KiB
JavaScript
import AjaxService from 'ember-ajax/services/ajax';
|
|
import classic from 'ember-classic-decorator';
|
|
import config from 'ghost-admin/config/environment';
|
|
import moment from 'moment-timezone';
|
|
import {AjaxError, isAjaxError, isForbiddenError} from 'ember-ajax/errors';
|
|
import {captureMessage} from '@sentry/ember';
|
|
import {get} from '@ember/object';
|
|
import {inject} from 'ghost-admin/decorators/inject';
|
|
import {isArray as isEmberArray} from '@ember/array';
|
|
import {isNone} from '@ember/utils';
|
|
import {inject as service} from '@ember/service';
|
|
import {timeout} from 'ember-concurrency';
|
|
|
|
const JSON_CONTENT_TYPE = 'application/json';
|
|
const GHOST_REQUEST = /\/ghost\/api\//;
|
|
|
|
function isJSONContentType(header) {
|
|
if (!header || isNone(header)) {
|
|
return false;
|
|
}
|
|
return header.indexOf(JSON_CONTENT_TYPE) === 0;
|
|
}
|
|
|
|
/* Version mismatch error */
|
|
|
|
export class VersionMismatchError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'API server is running a newer version of Ghost, please upgrade.');
|
|
}
|
|
}
|
|
|
|
export function isVersionMismatchError(errorOrStatus, payload) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof VersionMismatchError;
|
|
} else {
|
|
return get(payload || {}, 'errors.firstObject.type') === 'VersionMismatchError';
|
|
}
|
|
}
|
|
|
|
/* DataImport error */
|
|
|
|
export class DataImportError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'he server encountered an error whilst importing data.');
|
|
}
|
|
}
|
|
|
|
export function isDataImportError(errorOrStatus, payload) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof DataImportError;
|
|
} else {
|
|
return get(payload || {}, 'errors.firstObject.type') === 'DataImportError';
|
|
}
|
|
}
|
|
|
|
/* Server unreachable error */
|
|
|
|
export class ServerUnreachableError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'Server was unreachable');
|
|
}
|
|
}
|
|
|
|
export function isServerUnreachableError(error) {
|
|
if (isAjaxError(error)) {
|
|
return error instanceof ServerUnreachableError;
|
|
} else {
|
|
return error === 0 || error === '0';
|
|
}
|
|
}
|
|
|
|
/* Request entity too large error */
|
|
|
|
export class RequestEntityTooLargeError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'Request is larger than the maximum file size the server allows');
|
|
}
|
|
}
|
|
|
|
export function isRequestEntityTooLargeError(errorOrStatus) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof RequestEntityTooLargeError;
|
|
} else {
|
|
return errorOrStatus === 413;
|
|
}
|
|
}
|
|
|
|
/* Unsupported media type error */
|
|
|
|
export class UnsupportedMediaTypeError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'Request contains an unknown or unsupported file type.');
|
|
}
|
|
}
|
|
|
|
export function isUnsupportedMediaTypeError(errorOrStatus) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof UnsupportedMediaTypeError;
|
|
} else {
|
|
return errorOrStatus === 415;
|
|
}
|
|
}
|
|
|
|
/* Maintenance error */
|
|
|
|
export class MaintenanceError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'Ghost is currently undergoing maintenance, please wait a moment then retry.');
|
|
}
|
|
}
|
|
|
|
export function isMaintenanceError(errorOrStatus) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof MaintenanceError;
|
|
} else {
|
|
return errorOrStatus === 503;
|
|
}
|
|
}
|
|
|
|
/* Theme validation error */
|
|
|
|
export class ThemeValidationError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'Theme is not compatible or contains errors.');
|
|
}
|
|
}
|
|
|
|
export function isThemeValidationError(errorOrStatus, payload) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof ThemeValidationError;
|
|
} else {
|
|
return get(payload || {}, 'errors.firstObject.type') === 'ThemeValidationError';
|
|
}
|
|
}
|
|
|
|
/* Host limit reached/exceeded error */
|
|
|
|
export class HostLimitError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'A hosting plan limit was reached or exceeded.');
|
|
}
|
|
}
|
|
|
|
export function isHostLimitError(errorOrStatus, payload) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof HostLimitError;
|
|
} else {
|
|
return get(payload || {}, 'errors.firstObject.type') === 'HostLimitError';
|
|
}
|
|
}
|
|
|
|
/* Email error */
|
|
|
|
export class EmailError extends AjaxError {
|
|
constructor(payload) {
|
|
super(payload, 'Please verify your email settings');
|
|
}
|
|
}
|
|
|
|
export function isEmailError(errorOrStatus, payload) {
|
|
if (isAjaxError(errorOrStatus)) {
|
|
return errorOrStatus instanceof EmailError;
|
|
} else {
|
|
return get(payload || {}, 'errors.firstObject.type') === 'EmailError';
|
|
}
|
|
}
|
|
|
|
/* end: custom error types */
|
|
|
|
export class AcceptedResponse {
|
|
constructor(data) {
|
|
this.data = data;
|
|
}
|
|
}
|
|
|
|
export function isAcceptedResponse(errorOrStatus) {
|
|
if (errorOrStatus === 202) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@classic
|
|
class ajaxService extends AjaxService {
|
|
@service session;
|
|
|
|
@inject config;
|
|
|
|
// flag to tell our ESA authenticator not to try an invalidate DELETE request
|
|
// because it's been triggered by this service's 401 handling which means the
|
|
// DELETE would fail and get stuck in an infinite loop
|
|
// TODO: find a more elegant way to handle this
|
|
skipSessionDeletion = false;
|
|
|
|
get headers() {
|
|
return {
|
|
'X-Ghost-Version': config.APP.version,
|
|
'App-Pragma': 'no-cache'
|
|
};
|
|
}
|
|
|
|
init() {
|
|
super.init(...arguments);
|
|
if (this.isTesting === undefined) {
|
|
this.isTesting = config.environment === 'test';
|
|
}
|
|
}
|
|
|
|
async _makeRequest(hash) {
|
|
// ember-ajax recognizes `application/vnd.api+json` as a JSON-API request
|
|
// and formats appropriately, we want to handle `application/json` the same
|
|
if (isJSONContentType(hash.contentType) && hash.type !== 'GET') {
|
|
if (typeof hash.data === 'object') {
|
|
hash.data = JSON.stringify(hash.data);
|
|
}
|
|
}
|
|
|
|
hash.withCredentials = true;
|
|
|
|
// mocked routes used in development/testing do not have access to the
|
|
// test context so we add a header here to give them access to the logged
|
|
// in user id that can be checked against the mocked database
|
|
if (this.isTesting) {
|
|
hash.headers['X-Test-User'] = this.session.user?.id;
|
|
}
|
|
|
|
// attempt retries for 15 seconds in two situations:
|
|
// 1. Server Unreachable error from the browser (code 0), typically from short internet blips
|
|
// 2. Maintenance error from Ghost, upgrade in progress so API is temporarily unavailable
|
|
|
|
let success = false;
|
|
let errorName = null;
|
|
let attempts = 0;
|
|
let startTime = new Date();
|
|
let retryingMs = 0;
|
|
const maxRetryingMs = 15_000;
|
|
const retryPeriods = [500, 1000];
|
|
const retryErrorChecks = [this.isServerUnreachableError, this.isMaintenanceError];
|
|
|
|
const getErrorData = () => {
|
|
const data = {
|
|
errorName,
|
|
attempts,
|
|
totalSeconds: moment().diff(moment(startTime), 'seconds')
|
|
};
|
|
if (this._responseServer) {
|
|
data.server = this._responseServer;
|
|
}
|
|
return data;
|
|
};
|
|
|
|
const makeRequest = super._makeRequest.bind(this);
|
|
|
|
while (retryingMs <= maxRetryingMs && !success) {
|
|
try {
|
|
const result = await makeRequest(hash);
|
|
success = true;
|
|
|
|
if (attempts !== 0 && this.config.sentry_dsn) {
|
|
captureMessage('Request took multiple attempts', {extra: getErrorData()});
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
errorName = error.response?.constructor?.name;
|
|
retryingMs = (new Date()) - startTime;
|
|
|
|
// avoid retries in tests because it slows things down and is not expected in mocks
|
|
// isTesting can be overridden in individual tests if required
|
|
if (this.isTesting) {
|
|
throw error;
|
|
}
|
|
|
|
if (retryErrorChecks.some(check => check(error.response)) && retryingMs <= maxRetryingMs) {
|
|
await timeout(retryPeriods[attempts] || retryPeriods[retryPeriods.length - 1]);
|
|
attempts += 1;
|
|
} else if (attempts > 0 && this.config.sentry_dsn) {
|
|
captureMessage('Request failed after multiple attempts', {extra: getErrorData()});
|
|
throw error;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
handleResponse(status, headers, payload, request) {
|
|
if (this.isVersionMismatchError(status, headers, payload)) {
|
|
return new VersionMismatchError(payload);
|
|
} else if (this.isServerUnreachableError(status, headers, payload)) {
|
|
return new ServerUnreachableError(payload);
|
|
} else if (this.isRequestEntityTooLargeError(status, headers, payload)) {
|
|
return new RequestEntityTooLargeError(payload);
|
|
} else if (this.isUnsupportedMediaTypeError(status, headers, payload)) {
|
|
return new UnsupportedMediaTypeError(payload);
|
|
} else if (this.isMaintenanceError(status, headers, payload)) {
|
|
return new MaintenanceError(payload);
|
|
} else if (this.isThemeValidationError(status, headers, payload)) {
|
|
return new ThemeValidationError(payload);
|
|
} else if (this.isHostLimitError(status, headers, payload)) {
|
|
return new HostLimitError(payload);
|
|
} else if (this.isEmailError(status, headers, payload)) {
|
|
return new EmailError(payload);
|
|
} else if (this.isAcceptedResponse(status)) {
|
|
return new AcceptedResponse(payload);
|
|
}
|
|
|
|
let isGhostRequest = GHOST_REQUEST.test(request.url);
|
|
let isAuthenticated = this.get('session.isAuthenticated');
|
|
let isUnauthorized = this.isUnauthorizedError(status, headers, payload);
|
|
let isForbidden = isForbiddenError(status, headers, payload);
|
|
|
|
// used when reporting connection errors, helps distinguish CDN
|
|
if (isGhostRequest) {
|
|
this._responseServer = headers.server;
|
|
}
|
|
|
|
if (isAuthenticated && isGhostRequest && (isUnauthorized || (isForbidden && payload.errors?.[0].message === 'Authorization failed'))) {
|
|
this.skipSessionDeletion = true;
|
|
this.session.invalidate();
|
|
}
|
|
|
|
return super.handleResponse(...arguments);
|
|
}
|
|
|
|
normalizeErrorResponse(status, headers, payload) {
|
|
if (payload && typeof payload === 'object') {
|
|
let errors = payload.error || payload.errors || payload.message || undefined;
|
|
|
|
if (errors) {
|
|
if (!isEmberArray(errors)) {
|
|
errors = [errors];
|
|
}
|
|
|
|
payload.errors = errors.map(function (error) {
|
|
if (typeof error === 'string') {
|
|
return {message: error};
|
|
} else {
|
|
return error;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return super.normalizeErrorResponse(status, headers, payload);
|
|
}
|
|
|
|
isVersionMismatchError(status, headers, payload) {
|
|
return isVersionMismatchError(status, payload);
|
|
}
|
|
|
|
isServerUnreachableError(status) {
|
|
return isServerUnreachableError(status);
|
|
}
|
|
|
|
isRequestEntityTooLargeError(status) {
|
|
return isRequestEntityTooLargeError(status);
|
|
}
|
|
|
|
isUnsupportedMediaTypeError(status) {
|
|
return isUnsupportedMediaTypeError(status);
|
|
}
|
|
|
|
isDataImportError(status) {
|
|
return isDataImportError(status);
|
|
}
|
|
|
|
isMaintenanceError(status, headers, payload) {
|
|
return isMaintenanceError(status, payload);
|
|
}
|
|
|
|
isThemeValidationError(status, headers, payload) {
|
|
return isThemeValidationError(status, payload);
|
|
}
|
|
|
|
isHostLimitError(status, headers, payload) {
|
|
return isHostLimitError(status, payload);
|
|
}
|
|
|
|
isEmailError(status, headers, payload) {
|
|
return isEmailError(status, payload);
|
|
}
|
|
|
|
isAcceptedResponse(status) {
|
|
return isAcceptedResponse(status);
|
|
}
|
|
}
|
|
|
|
// we need to reopen so that internal methods use the correct contentType
|
|
ajaxService.reopen({
|
|
contentType: 'application/json; charset=UTF-8'
|
|
});
|
|
|
|
export default ajaxService;
|