TryGhost/Ghost

View on GitHub
ghost/admin/app/services/notifications.js

Summary

Maintainability
C
1 day
Test Coverage
import * as Sentry from '@sentry/ember';
import Service, {inject as service} from '@ember/service';
import {TrackedArray} from 'tracked-built-ins';
import {dasherize} from '@ember/string';
import {htmlSafe} from '@ember/template';
import {inject} from 'ghost-admin/decorators/inject';
import {isArray} from '@ember/array';
import {isBlank} from '@ember/utils';
import {
    isMaintenanceError,
    isVersionMismatchError
} from 'ghost-admin/services/ajax';
import {tracked} from '@glimmer/tracking';

// Notification keys take the form of "noun.verb.message", eg:
//
// "invite.resend.api-error"
// "user.invite.already-invited"
//
// The "noun.verb" part will be used as the "key base" in duplicate checks
// to avoid stacking of multiple error messages whilst leaving enough
// specificity to re-use keys for i18n lookups

// Rather than showing raw JS error messages to users we want to show a generic one.
// This list is used to check obj.name in `showApiError(obj)` as the first line
// of defence, then at the lowest `handleNotification(msg)` level we check the
// first word of the message text as a fallback in case we get JS error messages
// from the API. If there's a match we show the generic message.
const GENERIC_ERROR_NAMES = [
    'AggregateError',
    'EvalError',
    'RangeError',
    'ReferenceError',
    'SyntaxError',
    'TypeError',
    'URIError',
    // ember-ajax errors - https://github.com/ember-cli/ember-ajax/blob/master/addon/errors.ts
    'AjaxError',
    'ServerError'
];

export const GENERIC_ERROR_MESSAGE = 'An unexpected error occurred, please try again.';

export default class NotificationsService extends Service {
    @service upgradeStatus;

    @inject config;

    @tracked delayedNotifications = new TrackedArray([]);
    @tracked content = new TrackedArray([]);

    get alerts() {
        return this.content.filter(n => n.status === 'alert');
    }

    get notifications() {
        return this.content.filter(n => n.status === 'notification');
    }

    handleNotification(message, delayed = false) {
        const wordRegex = /[a-z]+/igm;
        const wordMatches = (message.message.string || message.message).matchAll(wordRegex);

        for (const wordMatch of wordMatches) {
            if (GENERIC_ERROR_NAMES.includes(wordMatch[0])) {
                message.message = GENERIC_ERROR_MESSAGE;
                break;
            }
        }

        // If this is an alert message from the server, treat it as html safe
        if (message.constructor.modelName === 'notification' && message.status === 'alert') {
            message.message = htmlSafe(message.message);
        }

        if (!message.status) {
            message.status = 'notification';
        }

        // close existing duplicate alerts/notifications to avoid stacking
        if (message.key) {
            this._removeItems(message.status, message.key);
        }

        // close existing alerts/notifications which have the same text to avoid stacking
        let newText = message.message.string || message.message;
        this.content = new TrackedArray(this.content.reject((notification) => {
            let existingText = notification.message.string || notification.message;
            return existingText === newText;
        }));

        if (!delayed) {
            this.content.push(message);
        } else {
            this.delayedNotifications.push(message);
        }
    }

    showAlert(message, options = {}) {
        options = options || {};

        if (!options.isApiError && (!options.type || options.type === 'error')) {
            if (this.config.sentry_dsn) {
                // message could be a htmlSafe object rather than a string
                const displayedMessage = message.string || message;

                const contexts = {
                    ghost: {
                        displayed_message: displayedMessage,
                        ghost_error_code: options.ghostErrorCode,
                        full_error: message
                    }
                };

                Sentry.captureMessage(displayedMessage, {
                    contexts,
                    tags: {
                        shown_to_user: true,
                        source: 'showAlert'
                    }
                });
            }
        }

        this.handleNotification({
            message,
            status: 'alert',
            description: options.description,
            icon: options.icon,
            type: options.type,
            key: options.key,
            actions: options.actions
        }, options.delayed);
    }

    showNotification(message, options) {
        options = options || {};

        this.handleNotification({
            message,
            status: 'notification',
            description: options.description,
            icon: options.icon,
            type: options.type,
            key: options.key,
            actions: options.actions
        }, options.delayed);
    }

    showAPIError(resp, options) {
        // handle "global" errors
        if (isVersionMismatchError(resp)) {
            return this.upgradeStatus.requireUpgrade();
        } else if (isMaintenanceError(resp)) {
            return this.upgradeStatus.maintenanceAlert();
        }

        // loop over ember-ajax errors object
        if (isArray(resp?.payload?.errors)) {
            return resp.payload.errors.forEach((error) => {
                this._showAPIError(error, options);
            });
        }

        this._showAPIError(resp, options);
    }

    _showAPIError(resp, options) {
        options = options || {};
        options.type = options.type || 'error';

        // if possible use the title to get a unique key
        // - we only show one alert for each key so if we get multiple errors
        //   only the last one will be shown
        if (!options.key && !isBlank(resp?.title)) {
            options.key = dasherize(resp?.title);
        }
        options.key = ['api-error', options.key].compact().join('.');

        let msg = options.defaultErrorText || GENERIC_ERROR_MESSAGE;

        if (
            resp?.name && GENERIC_ERROR_NAMES.includes(resp.name) ||
            resp?.constructor && GENERIC_ERROR_NAMES.includes(resp.constructor.name)
        ) {
            msg = GENERIC_ERROR_MESSAGE;
        } else if (resp instanceof String) {
            msg = resp;
        } else if (!isBlank(resp?.message)) {
            msg = resp.message;
        }

        if (!isBlank(resp?.context) && resp?.context !== msg) {
            msg = `${msg} ${resp.context}`;
        }

        if (this.config.sentry_dsn) {
            const reportedError = resp instanceof Error ? resp : msg;

            Sentry.captureException(reportedError, {
                contexts: {
                    ghost: {
                        ghost_error_code: resp.ghostErrorCode,
                        displayed_message: msg,
                        full_error: resp
                    }
                },
                tags: {
                    shown_to_user: true,
                    source: 'showAPIError'
                }
            });
        }

        options.isApiError = true;

        this.showAlert(msg, options);
    }

    displayDelayed() {
        this.delayedNotifications.forEach((message) => {
            this.content.push(message);
        });
        this.delayedNotifications = new TrackedArray([]);
    }

    closeNotification(notification) {
        let content = this.content;

        if (notification.constructor.modelName === 'notification') {
            notification.deleteRecord();
            notification.save().finally(() => {
                content.removeObject(notification);
            });
        } else {
            content.removeObject(notification);
        }
    }

    closeNotifications(key) {
        this._removeItems('notification', key);
    }

    closeAlerts(key) {
        this._removeItems('alert', key);
    }

    clearAll() {
        this.content = new TrackedArray([]);
    }

    _removeItems(status, key) {
        if (key) {
            let keyBase = this._getKeyBase(key);
            // TODO: keys should only have . special char but we should
            // probably use a better regexp escaping function/polyfill
            let escapedKeyBase = keyBase.replace('.', '\\.');
            let keyRegex = new RegExp(`^${escapedKeyBase}`);

            this.content = new TrackedArray(this.content.reject((item) => {
                let itemKey = item.key;
                let itemStatus = item.status;

                return itemStatus === status && (itemKey && itemKey.match(keyRegex));
            }));
        } else {
            this.content = new TrackedArray(this.content.rejectBy('status', status));
        }
    }

    // take a key and return the first two elements, eg:
    // "invite.revoke.failed" => "invite.revoke"
    _getKeyBase(key) {
        return key.split('.').slice(0, 2).join('.');
    }
}