5b77f052d9
closes #5071 - Remove hardcoded notification in admin controller - NOTE: update check notifications are no longer blocking the admin rendering - this is one of the most import changes - we remove the hardcoded release message - we also remove adding a notification manually in here, because this will work differently from now on -> you receive a notification (release or custom) in the update check module and this module adds the notification as is to our database - Change default core settings keys - remove displayUpdateNotification -> this was used to store the release version number send from the UCS -> based on this value, Ghost creates a notification container with self defined values -> not needed anymore - rename seenNotifications to notifications -> the new notifications key will hold both 1. the notification from the USC 2. the information about if a notification was seen or not - this key hold only one release notification - and n custom notifications - Update Check Module: Request to the USC depends on the privacy configuration - useUpdateCheck: true -> does a checkin in the USC (exposes data) - useUpdateCheck: false -> does only a GET query to the USC (does not expose any data) - make the request handling dynamic, so it depends on the flag - add an extra logic to be able to define a custom USC endpoint (helpful for testing) - add an extra logic to be able to force the request to the service (helpful for testing) - Update check module: re-work condition when a check should happen - only if the env is not correct - remove deprecated config.updateCheck - remove isPrivacyDisabled check (handled differently now, explained in last commit) - Update check module: remove `showUpdateNotification` and readability - showUpdateNotification was used in the admin controller to fetch the latest release version number from the db - no need to check against semver in general, the USC takes care of that (no need to double check) - improve readability of `nextUpdateCheck` condition - Update check module: refactor `updateCheckResponse` - remove db call to displayUpdateNotification, not used anymore - support receiving multiple custom notifications - support custom notification groups - the default group is `all` - this will always be consumed - groups can be extended via config e.g. `notificationGroups: ['migration']` - Update check module: refactor createCustomNotification helper - get rid of taking over notification duplication handling (this is not the task of the update check module) - ensure we have good fallback values for non present attributes in a notification - get rid of semver check (happens in the USC) - could be reconsidered later if LTS is gone - Refactor notification API - reason: get rid of in process notification store -> this was an object hold in process -> everything get's lost after restart -> not helpful anymore, because imagine the following case -> you get a notification -> you store it in process -> you mark this notification as seen -> you restart Ghost, you will receive the same notification on the next check again -> because we are no longer have a separate seen notifications object - use database settings key `notification` instead - refactor all api endpoints to support reading and storing into the `notifications` object - most important: notification deletion happens via a `seen` property (the notification get's physically deleted 3 month automatically) -> we have to remember a seen property, because otherwise you don't know which notification was already received/seen - Add listener to remove seen notifications automatically after 3 month - i just decided for 3 month (we can decrease?) - at the end it doesn't really matter, as long as the windows is not tooooo short - listen on updates for the notifications settings - check if notification was seen and is older than 3 month - ignore release notification - Updated our privacy document - Updated docs.ghost.org for privacy config behaviour - contains a migration script to remove old settings keys
273 lines
10 KiB
JavaScript
273 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
// # Update Checking Service
|
|
//
|
|
// Makes a request to Ghost.org to check if there is a new version of Ghost available.
|
|
// The service is provided in return for users opting in to anonymous usage data collection.
|
|
//
|
|
// Blog owners can opt-out of update checks by setting `privacy: { useUpdateCheck: false }` in their config.js
|
|
//
|
|
// The data collected is as follows:
|
|
//
|
|
// - blog id - a hash of the blog hostname, pathname and db_hash. No identifiable info is stored.
|
|
// - ghost version
|
|
// - node version
|
|
// - npm version
|
|
// - env - production or development
|
|
// - database type - SQLite, MySQL
|
|
// - email transport - mail.options.service, or otherwise mail.transport
|
|
// - created date - database creation date
|
|
// - post count - total number of posts
|
|
// - user count - total number of users
|
|
// - theme - name of the currently active theme
|
|
// - apps - names of any active apps
|
|
|
|
const crypto = require('crypto'),
|
|
exec = require('child_process').exec,
|
|
moment = require('moment'),
|
|
Promise = require('bluebird'),
|
|
_ = require('lodash'),
|
|
url = require('url'),
|
|
debug = require('ghost-ignition').debug('update-check'),
|
|
api = require('./api'),
|
|
config = require('./config'),
|
|
urlService = require('./services/url'),
|
|
common = require('./lib/common'),
|
|
request = require('./lib/request'),
|
|
ghostVersion = require('./lib/ghost-version'),
|
|
internal = {context: {internal: true}},
|
|
allowedCheckEnvironments = ['development', 'production'];
|
|
|
|
function nextCheckTimestamp() {
|
|
var now = Math.round(new Date().getTime() / 1000);
|
|
return now + (24 * 3600);
|
|
}
|
|
|
|
function updateCheckError(err) {
|
|
api.settings.edit({
|
|
settings: [{
|
|
key: 'next_update_check',
|
|
value: nextCheckTimestamp()
|
|
}]
|
|
}, internal);
|
|
|
|
err.context = common.i18n.t('errors.updateCheck.checkingForUpdatesFailed.error');
|
|
err.help = common.i18n.t('errors.updateCheck.checkingForUpdatesFailed.help', {url: 'https://docs.ghost.org/v1'});
|
|
common.logging.error(err);
|
|
}
|
|
|
|
/**
|
|
* If the custom message is intended for current version, create and store a custom notification.
|
|
* @param {Object} notification
|
|
* @return {*|Promise}
|
|
*/
|
|
function createCustomNotification(notification) {
|
|
if (!notification) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return Promise.each(notification.messages, function (message) {
|
|
let toAdd = {
|
|
custom: !!notification.custom,
|
|
createdAt: moment(notification.created_at).toDate(),
|
|
status: message.status || 'alert',
|
|
type: message.type || 'info',
|
|
id: message.id,
|
|
dismissible: message.hasOwnProperty('dismissible') ? message.dismissible : true,
|
|
top: !!message.top,
|
|
message: message.content
|
|
};
|
|
|
|
debug('Add Custom Notification', toAdd);
|
|
return api.notifications.add({notifications: [toAdd]}, {context: {internal: true}});
|
|
});
|
|
}
|
|
|
|
function updateCheckData() {
|
|
let data = {},
|
|
mailConfig = config.get('mail');
|
|
|
|
data.ghost_version = ghostVersion.original;
|
|
data.node_version = process.versions.node;
|
|
data.env = config.get('env');
|
|
data.database_type = config.get('database').client;
|
|
data.email_transport = mailConfig &&
|
|
(mailConfig.options && mailConfig.options.service ?
|
|
mailConfig.options.service :
|
|
mailConfig.transport);
|
|
|
|
return Promise.props({
|
|
hash: api.settings.read(_.extend({key: 'db_hash'}, internal)).reflect(),
|
|
theme: api.settings.read(_.extend({key: 'active_theme'}, internal)).reflect(),
|
|
apps: api.settings.read(_.extend({key: 'active_apps'}, internal))
|
|
.then(function (response) {
|
|
var apps = response.settings[0];
|
|
|
|
apps = JSON.parse(apps.value);
|
|
|
|
return _.reduce(apps, function (memo, item) {
|
|
return memo === '' ? memo + item : memo + ', ' + item;
|
|
}, '');
|
|
}).reflect(),
|
|
posts: api.posts.browse().reflect(),
|
|
users: api.users.browse(internal).reflect(),
|
|
npm: Promise.promisify(exec)('npm -v').reflect()
|
|
}).then(function (descriptors) {
|
|
var hash = descriptors.hash.value().settings[0],
|
|
theme = descriptors.theme.value().settings[0],
|
|
apps = descriptors.apps.value(),
|
|
posts = descriptors.posts.value(),
|
|
users = descriptors.users.value(),
|
|
npm = descriptors.npm.value(),
|
|
blogUrl = url.parse(urlService.utils.urlFor('home', true)),
|
|
blogId = blogUrl.hostname + blogUrl.pathname.replace(/\//, '') + hash.value;
|
|
|
|
data.blog_id = crypto.createHash('md5').update(blogId).digest('hex');
|
|
data.theme = theme ? theme.value : '';
|
|
data.apps = apps || '';
|
|
data.post_count = posts && posts.meta && posts.meta.pagination ? posts.meta.pagination.total : 0;
|
|
data.user_count = users && users.users && users.users.length ? users.users.length : 0;
|
|
data.blog_created_at = users && users.users && users.users[0] && users.users[0].created_at ? moment(users.users[0].created_at).unix() : '';
|
|
data.npm_version = npm.trim();
|
|
data.lts = false;
|
|
|
|
return data;
|
|
}).catch(updateCheckError);
|
|
}
|
|
|
|
/**
|
|
* With the privacy setting `useUpdateCheck` you can control if you want to expose data from your blog to the
|
|
* Update Check Service. Enabled or disabled, you will receive the latest notification available from the service.
|
|
*/
|
|
function updateCheckRequest() {
|
|
return updateCheckData()
|
|
.then(function then(reqData) {
|
|
let reqObj = {
|
|
timeout: 1000,
|
|
headers: {}
|
|
},
|
|
checkEndpoint = config.get('updateCheck:url'),
|
|
checkMethod = config.isPrivacyDisabled('useUpdateCheck') ? 'GET' : 'POST';
|
|
|
|
if (checkMethod === 'POST') {
|
|
reqObj.json = true;
|
|
reqObj.body = reqData;
|
|
reqObj.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(reqData));
|
|
reqObj.headers['Content-Type'] = 'application/json';
|
|
} else {
|
|
reqObj.json = true;
|
|
reqObj.query = {
|
|
ghost_version: reqData.ghost_version
|
|
};
|
|
}
|
|
|
|
debug('Request Update Check Service', checkEndpoint);
|
|
|
|
return request(checkEndpoint, reqObj)
|
|
.then(function (response) {
|
|
return response.body;
|
|
})
|
|
.catch(function (err) {
|
|
// CASE: no notifications available, ignore
|
|
if (err.statusCode === 404) {
|
|
return {
|
|
next_check: nextCheckTimestamp(),
|
|
notifications: []
|
|
};
|
|
}
|
|
|
|
if (err.response && err.response.body && typeof err.response.body === 'object') {
|
|
err = common.errors.utils.deserialize(err.response.body);
|
|
}
|
|
|
|
throw err;
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handles the response from the update check
|
|
* Does three things with the information received:
|
|
* 1. Updates the time we can next make a check
|
|
* 2. Create custom notifications is response from UpdateCheck as "messages" array which has the following structure:
|
|
*
|
|
* "messages": [{
|
|
* "id": ed9dc38c-73e5-4d72-a741-22b11f6e151a,
|
|
* "version": "0.5.x",
|
|
* "content": "<p>Hey there! 0.6 is available, visit <a href=\"https://ghost.org/download\">Ghost.org</a> to grab your copy now<!/p>",
|
|
* "dismissible": true | false,
|
|
* "top": true | false
|
|
* ]}
|
|
*
|
|
* Example for grouped custom notifications in config:
|
|
*
|
|
* notificationGroups: ['migration', 'something']
|
|
*
|
|
* 'all' is a reserved name for general custom notifications.
|
|
*
|
|
* @param {Object} response
|
|
* @return {Promise}
|
|
*/
|
|
function updateCheckResponse(response) {
|
|
let notifications = [],
|
|
notificationGroups = (config.get('notificationGroups') || []).concat(['all']);
|
|
|
|
debug('Notification Groups', notificationGroups);
|
|
debug('Response Update Check Service', response);
|
|
|
|
return api.settings.edit({settings: [{key: 'next_update_check', value: response.next_check}]}, internal)
|
|
.then(function () {
|
|
// CASE: Update Check Service returns multiple notifications.
|
|
if (_.isArray(response)) {
|
|
notifications = response;
|
|
} else if ((response.hasOwnProperty('notifications') && _.isArray(response.notifications))) {
|
|
notifications = response.notifications;
|
|
} else {
|
|
notifications = [response];
|
|
}
|
|
|
|
// CASE: Hook into received notifications and decide whether you are allowed to receive custom group messages.
|
|
if (notificationGroups.length) {
|
|
notifications = notifications.filter(function (notification) {
|
|
if (!notification.custom) {
|
|
return true;
|
|
}
|
|
|
|
return _.includes(notificationGroups.map(function (groupIdentifier) {
|
|
if (notification.version.match(new RegExp(groupIdentifier))) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}), true) === true;
|
|
});
|
|
}
|
|
|
|
return Promise.each(notifications, createCustomNotification);
|
|
});
|
|
}
|
|
|
|
function updateCheck() {
|
|
// CASE: The check will not happen if your NODE_ENV is not in the allowed defined environments.
|
|
if (_.indexOf(allowedCheckEnvironments, process.env.NODE_ENV) === -1) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return api.settings.read(_.extend({key: 'next_update_check'}, internal))
|
|
.then(function then(result) {
|
|
var nextUpdateCheck = result.settings[0];
|
|
|
|
// CASE: Next update check should happen now?
|
|
if (!config.get('updateCheck:forceUpdate') && nextUpdateCheck && nextUpdateCheck.value && nextUpdateCheck.value > moment().unix()) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return updateCheckRequest()
|
|
.then(updateCheckResponse)
|
|
.catch(updateCheckError);
|
|
})
|
|
.catch(updateCheckError);
|
|
}
|
|
|
|
module.exports = updateCheck;
|