Ghost/ghost/update-check-service/lib/update-check-service.js
Naz 9e5fe475c8 Improved code safety in update check's check method
refs https://github.com/TryGhost/Team/issues/754

- The api call can also generate an error which would bubble up to a client. This is not a good design if the client has to know to handle internal errors of the module
- By having try block wrap around whole block it makes the error handling behave the same way throughout the check process
2021-06-23 18:57:45 +04:00

363 lines
14 KiB
JavaScript

const _ = require('lodash');
const url = require('url');
const crypto = require('crypto');
const moment = require('moment');
const Promise = require('bluebird');
const exec = require('child_process').exec;
const errors = require('@tryghost/errors');
const debug = require('ghost-ignition').debug('update-check');
const internal = {context: {internal: true}};
/**
* Update Checker Class
*
* Makes a request to Ghost.org to request release & custom notifications.
* 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 file.
*/
class UpdateCheckService {
/**
*
* @param {Object} options
* @param {Object} options.api - set of Ghost's API methods needed for update check to function
* @param {Object} options.api.settings - Settings API methods
* @param {Function} options.api.settings.read - method allowing to read Ghost's settings
* @param {Function} options.api.settings.edit - method allowing to edit Ghost's settings
* @param {Object} options.api.posts - Posts API methods
* @param {Function} options.api.posts.browse - method allowing to read Ghost's posts
* @param {Object} options.api.users - Users API methods
* @param {Function} options.api.users.browse - method allowing to read Ghost's users
* @param {Object} options.api.notifications - Notification API methods
* @param {Function} options.api.notifications.add - method allowing to add Ghost notifications
* @param {Object} options.config
* @param {Object} options.config.mail
* @param {string} options.config.env
* @param {string} options.config.databaseType
* @param {string} options.config.checkEndpoint - update check service URL
* @param {boolean} [options.config.isPrivacyDisabled]
* @param {string[]} [options.config.notificationGroups] - example values ["migration", "something"]
* @param {string} options.config.siteUrl - Ghost instance URL
* @param {boolean} [options.config.forceUpdate]
* @param {string} options.config.ghostVersion - Ghost instance version
* @param {Function} options.request - a HTTP request proxy function
* @param {Function} options.sendEmail - function handling sending an email
*/
constructor({api, config, i18n, logging, request, sendEmail}) {
this.api = api;
this.config = config;
this.i18n = i18n;
this.logging = logging;
this.request = request;
this.sendEmail = sendEmail;
}
nextCheckTimestamp() {
const now = Math.round(new Date().getTime() / 1000);
return now + (24 * 3600);
}
/**
* @description Centralised error handler for the update check unit.
*
* CASES:
* - the update check service returns an error
* - error during collecting blog stats
*
* We still need to ensure that we set the "next_update_check" to a new value, otherwise no more
* update checks will happen.
*
* @param err
*/
updateCheckError(err) {
this.api.settings.edit({
settings: [{
key: 'next_update_check',
value: this.nextCheckTimestamp()
}]
}, internal);
err.context = this.i18n.t('errors.updateCheck.checkingForUpdatesFailed.error');
err.help = this.i18n.t('errors.updateCheck.checkingForUpdatesFailed.help', {url: 'https://ghost.org/docs/'});
this.logging.error(err);
}
/**
* @description Collect stats from your blog.
* @returns {Promise}
*/
async updateCheckData() {
let data = {};
let mailConfig = this.config.mail;
data.ghost_version = this.config.ghostVersion;
data.node_version = process.versions.node;
data.env = this.config.env;
data.database_type = this.config.databaseType;
data.email_transport = mailConfig &&
(mailConfig.options && mailConfig.options.service ?
mailConfig.options.service :
mailConfig.transport);
try {
const hash = (await this.api.settings.read(_.extend({key: 'db_hash'}, internal))).settings[0];
const theme = (await this.api.settings.read(_.extend({key: 'active_theme'}, internal))).settings[0];
const posts = await this.api.posts.browse();
const users = await this.api.users.browse(internal);
const npm = await Promise.promisify(exec)('npm -v');
const blogUrl = this.config.siteUrl;
const parsedBlogUrl = url.parse(blogUrl);
const blogId = parsedBlogUrl.hostname + parsedBlogUrl.pathname.replace(/\//, '') + hash.value;
data.url = blogUrl;
data.blog_id = crypto.createHash('md5').update(blogId).digest('hex');
data.theme = theme ? theme.value : '';
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();
return data;
} catch (err) {
this.updateCheckError(err);
}
}
/**
* @description Perform request to update check service.
*
* With the privacy setting `useUpdateCheck` you can control if you want to expose data/stats from your blog to the
* service. Enabled or disabled, you will receive the latest notification available from the service.
*
* @see https://ghost.org/docs/concepts/config/#privacy
* @returns {Promise}
*/
async updateCheckRequest() {
const reqData = await this.updateCheckData();
let reqObj = {
timeout: 1000,
headers: {}
};
let checkEndpoint = this.config.checkEndpoint;
let checkMethod = this.config.isPrivacyDisabled ? 'GET' : 'POST';
// CASE: Expose stats and do a check-in
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);
try {
const response = await this.request(checkEndpoint, reqObj);
return response.body;
} catch (err) {
// CASE: no notifications available, ignore
if (err.statusCode === 404) {
return {
next_check: this.nextCheckTimestamp(),
notifications: []
};
}
// CASE: service returns JSON error, deserialize into JS error
if (err.response && err.response.body && typeof err.response.body === 'object') {
throw errors.utils.deserialize(err.response.body);
} else {
throw err;
}
}
}
/**
* @description This function handles the response from the update check service.
*
* The helper does three things:
*
* 1. Updates the time in the settings table to know when we can execute the next update check request.
* 2. Iterates over the received notifications and filters them out based on your notification groups.
* 3. Calls a custom helper to generate a Ghost notification for the database.
*
* The structure of the response is:
*
* {
* id: 20,
* version: 'all4',
* messages:
* [{
* id: 'f8ff6c80-aa61-11e7-a126-6119te37e2b8',
* version: '^2',
* content: 'Hallouuuu custom',
* top: true,
* dismissible: true,
* type: 'info'
* }],
* created_at: '2021-10-06T07:00:00.000Z',
* custom: 1,
* next_check: 1555608722
* }
*
*
* Example for grouped custom notifications in config:
*
* "notificationGroups": ["migration", "something"]
*
* The group 'all' is a reserved name for general custom notifications, which every self hosted blog can receive.
*
* @param {Object} response
* @return {Promise}
*/
async updateCheckResponse(response) {
let notifications = [];
let notificationGroups = (this.config.notificationGroups || []).concat(['all']);
debug('Notification Groups', notificationGroups);
debug('Response Update Check Service', response);
await this.api.settings.edit({
settings: [{
key: 'next_update_check',
value: response.next_check
}]
}, internal);
/**
* @NOTE:
*
* When we refactored notifications in Ghost 1.20, the service did not support returning multiple messages.
* But we wanted to already add the support for future functionality.
* That's why this helper supports two ways: returning an array of messages or returning an object with
* a "notifications" key. The second one is probably the best, because we need to support "next_check"
* on the root level of the response.
*/
if (_.isArray(response)) {
notifications = response;
} else if ((Object.prototype.hasOwnProperty.call(response, 'notifications') && _.isArray(response.notifications))) {
notifications = response.notifications;
} else {
// CASE: default right now
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) {
// CASE: release notification, keep
if (!notification.custom) {
return true;
}
// CASE: filter out messages based on your groups
return _.includes(notificationGroups.map(function (groupIdentifier) {
if (notification && notification.version && notification.version.match(new RegExp(groupIdentifier))) {
return true;
}
return false;
}), true) === true;
});
}
for (const notification of notifications) {
await this.createCustomNotification(notification);
}
}
/**
* @description Create a Ghost notification and call the API controller.
*
* @param {Object} notification
* @return {Promise}
*/
async createCustomNotification(notification) {
if (!notification) {
return;
}
const {users} = await this.api.users.browse(Object.assign({
limit: 'all',
include: ['roles']
}, internal));
const adminEmails = users
.filter(user => ['Owner', 'Administrator'].includes(user.roles[0].name))
.map(user => user.email);
const siteUrl = this.config.siteUrl;
for (const message of notification.messages) {
const toAdd = {
// @NOTE: the update check service returns "0" or "1" (https://github.com/TryGhost/UpdateCheck/issues/43)
custom: !!notification.custom,
createdAt: moment(notification.created_at).toDate(),
status: message.status || 'alert',
type: message.type || 'info',
id: message.id,
dismissible: Object.prototype.hasOwnProperty.call(message, 'dismissible') ? message.dismissible : true,
top: !!message.top,
message: message.content
};
if (toAdd.type === 'alert') {
for (const email of adminEmails) {
try {
this.sendEmail({
to: email,
subject: `Action required: Critical alert from Ghost instance ${siteUrl}`,
html: toAdd.message,
forceTextContent: true
});
} catch (err) {
this.logging.err(err);
}
}
}
debug('Add Custom Notification', toAdd);
await this.api.notifications.add({notifications: [toAdd]}, {context: {internal: true}});
}
}
/**
* @description Entry point to trigger the update check unit.
*
* Based on a settings value, we check if `next_update_check` is less than now to decide whether
* we should request the update check service (http://updates.ghost.org) or not.
*
* @returns {Promise}
*/
async check() {
try {
const result = await this.api.settings.read(_.extend({key: 'next_update_check'}, internal));
const nextUpdateCheck = result.settings[0];
// CASE: Next update check should happen now?
// @NOTE: You can skip this check by adding a config value. This is helpful for developing.
if (!this.config.forceUpdate && nextUpdateCheck && nextUpdateCheck.value && nextUpdateCheck.value > moment().unix()) {
return;
}
const response = await this.updateCheckRequest();
return await this.updateCheckResponse(response);
} catch (err) {
this.updateCheckError(err);
}
}
}
module.exports = UpdateCheckService;