mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 10:21:36 +03:00
353cad7ed2
refs568e4183e3
refs258f56ded9
- when in test environment add a `X-Test-User` header to API requests that allows the mirage endpoints to check the logged in user without having to cross boundaries into the application or test contexts
371 lines
12 KiB
JavaScript
371 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 {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;
|