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 {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'; } } /* 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'; } } 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 config; @service session; // 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.get('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.get('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); } 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;