Ghost/core/server/services/notifications/notifications.js
Naz c0d59db5be Added filtering of outdated custom notifications
refs https://linear.app/tryghost/issue/CORE-64/resolve-undissmissable-update-notification-banners
refs https://github.com/TryGhost/Team/issues/754
refs https://github.com/TryGhost/Team/issues/204
refs https://github.com/TryGhost/Ghost/issues/10236

- Custom notifications coming form the update check service should not be shown beyond instance's update. Once the notification is received it's marked with the current version number. With an instance upgrade all notification with older version should be hidden.
- This improvement should also resolve the problem of major version notifications with next major update (the code associated with https://github.com/TryGhost/Ghost/issues/10236 can then be removed after 5.0.1)
2021-10-11 16:04:48 +02:00

263 lines
9.9 KiB
JavaScript

const moment = require('moment-timezone');
const semver = require('semver');
const Promise = require('bluebird');
const _ = require('lodash');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const ObjectId = require('bson-objectid');
const messages = {
noPermissionToDismissNotif: 'You do not have permission to dismiss this notification.',
notificationDoesNotExist: 'Notification does not exist.'
};
class Notifications {
/**
*
* @param {Object} options
* @param {Object} options.settingsCache - settings cache instance
* @param {Object} options.ghostVersion
* @param {String} options.ghostVersion.full - Ghost instance version in "full" format - major.minor.patch
* @param {Object} options.SettingsModel - Ghost's Setting model instance
*/
constructor({settingsCache, ghostVersion, SettingsModel}) {
this.settingsCache = settingsCache;
this.ghostVersion = ghostVersion;
this.SettingsModel = SettingsModel;
}
/**
* @returns {Object[]} - all notifications
*/
fetchAllNotifications() {
let allNotifications = this.settingsCache.get('notifications');
// @TODO: this check can be removed to improve read operation perf. It's here only because
// reads are done often and this gives a possibility to self-heal any broken records.
// The check can be removed/moved to write operations once we have the guardrails on that
// level long enough and are confident there's no broken data in the DB (e.g few minors after Ghost v5?)
if (!this.areNotificationsValid(allNotifications)) {
// Not using "await" here and doing the "fire-and-forget" because the result is know beforehand
// We only care for the notifications to ge into "correct" state eventually and work properly with next request
this.dangerousDestroyAll();
return [];
}
allNotifications.forEach((notification) => {
notification.addedAt = moment(notification.addedAt).toDate();
});
return allNotifications;
}
/**
*
* @param {Object[]} notifications - objects to check if they have valid notifications array format
*
* @returns {boolean}
*/
areNotificationsValid(notifications) {
if (!(_.isArray(notifications))) {
return false;
}
return true;
}
wasSeen(notification, user) {
if (notification.seenBy === undefined) {
return notification.seen;
} else {
return notification.seenBy.includes(user.id);
}
}
browse({user}) {
let allNotifications = this.fetchAllNotifications();
allNotifications = _.orderBy(allNotifications, 'addedAt', 'desc');
const blogVersion = this.ghostVersion.full.match(/^(\d+\.)(\d+\.)(\d+)/);
allNotifications = allNotifications.filter((notification) => {
if (notification.createdAtVersion && !this.wasSeen(notification, user)) {
return semver.gte(notification.createdAtVersion, blogVersion[0]);
} else {
// NOTE: Filtering by version below is just a patch for bigger problem - notifications are not removed
// after Ghost update. Logic below should be removed when Ghost upgrade detection
// is done (https://github.com/TryGhost/Ghost/issues/10236) and notifications are
// be removed permanently on upgrade event.
// NOTE: this whole else block can be removed with the first version after Ghost v5.0
// as the "createdAtVersion" mechanism will be enough to detect major version updates.
const ghostMajorRegEx = /Ghost (?<major>\d).0 is now available/gi;
const ghostSec43 = /GHSA-9fgx-q25h-jxrg/gi;
// CASE: do not return old release notification
if (notification.message
&& (!notification.custom || notification.message.match(ghostMajorRegEx) || notification.message.match(ghostSec43))) {
let notificationVersion = notification.message.match(/(\d+\.)(\d+\.)(\d+)/);
if (!notificationVersion && notification.message.match(ghostSec43)) {
// Treating "GHSA-9fgx-q25h-jxrg" notification as 4.3.3 because there's no way to detect version
// from it's message. In the future we should consider having a separate field with version
// coming with each notification
notificationVersion = ['4.3.3'];
}
const ghostMajorMatch = ghostMajorRegEx.exec(notification.message);
if (ghostMajorMatch && ghostMajorMatch.groups && ghostMajorMatch.groups.major) {
notificationVersion = `${ghostMajorMatch.groups.major}.0.0`;
} else if (notificationVersion){
notificationVersion = notificationVersion[0];
}
if (notificationVersion && blogVersion && semver.gt(notificationVersion, blogVersion[0])) {
return true;
} else {
return false;
}
}
}
return !this.wasSeen(notification, user);
});
return allNotifications;
}
add({notifications}) {
const defaults = {
dismissible: true,
location: 'bottom',
status: 'alert',
id: ObjectId().toHexString(),
createdAtVersion: this.ghostVersion.full
};
const overrides = {
seen: false,
addedAt: moment().toDate()
};
let notificationsToCheck = notifications;
let notificationsToAdd = [];
const allNotifications = this.fetchAllNotifications();
notificationsToCheck.forEach((notification) => {
const isDuplicate = allNotifications.find((n) => {
return n.id === notification.id;
});
if (!isDuplicate) {
notificationsToAdd.push(Object.assign({}, defaults, notification, overrides));
}
});
const hasReleaseNotification = notificationsToCheck.find((notification) => {
return !notification.custom;
});
// CASE: remove any existing release notifications if a new release notification comes in
if (hasReleaseNotification) {
_.remove(allNotifications, (el) => {
return !el.custom;
});
}
// CASE: nothing to add, skip
if (!notificationsToAdd.length) {
return {allNotifications, notificationsToAdd};
}
const releaseNotificationsToAdd = notificationsToAdd.filter((notification) => {
return !notification.custom;
});
// CASE: reorder notifications before save
if (releaseNotificationsToAdd.length > 1) {
notificationsToAdd = notificationsToAdd.filter((notification) => {
return notification.custom;
});
notificationsToAdd.push(_.orderBy(releaseNotificationsToAdd, 'created_at', 'desc')[0]);
}
return {allNotifications, notificationsToAdd};
}
/**
*
* @param {Object} options
* @param {string} options.notificationId - UUID of the notification
* @param {Object} options.user
* @param {string} options.user.id
*
* @returns {Promise<Object[]>}
*/
destroy({notificationId, user}) {
const allNotifications = this.fetchAllNotifications();
const notificationToMarkAsSeen = allNotifications.find((notification) => {
return notification.id === notificationId;
});
const notificationToMarkAsSeenIndex = allNotifications.findIndex((notification) => {
return notification.id === notificationId;
});
if (notificationToMarkAsSeenIndex > -1 && !notificationToMarkAsSeen.dismissible) {
return Promise.reject(new errors.NoPermissionError({
message: tpl(messages.noPermissionToDismissNotif)
}));
}
if (notificationToMarkAsSeenIndex < 0) {
return Promise.reject(new errors.NotFoundError({
message: tpl(messages.notificationDoesNotExist)
}));
}
if (this.wasSeen(notificationToMarkAsSeen, user)) {
return Promise.resolve();
}
// @NOTE: We don't remove the notifications, because otherwise we will receive them again from the service.
allNotifications[notificationToMarkAsSeenIndex].seen = true;
if (!allNotifications[notificationToMarkAsSeenIndex].seenBy) {
allNotifications[notificationToMarkAsSeenIndex].seenBy = [];
}
allNotifications[notificationToMarkAsSeenIndex].seenBy.push(user.id);
return allNotifications;
}
destroyAll() {
const allNotifications = this.fetchAllNotifications();
allNotifications.forEach((notification) => {
// @NOTE: We don't remove the notifications, because otherwise we will receive them again from the service.
notification.seen = true;
});
return allNotifications;
}
/**
* Comparing to destroyAll method this one wipes out the notifications data!
* It is only to be used in a situation when notifications data has been corrupted and
* there's a need to self-heal. Wiping out notifications will fetch some of the notifications
* again and repopulate the array with correct data.
*/
async dangerousDestroyAll() {
// Same default as defined in "default-settings.json"
const defaultValue = '[]';
return this.SettingsModel.edit([{
key: 'notifications',
value: defaultValue
}], {context: {internal: true}});
}
}
module.exports = Notifications;