9e5fe475c8
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
363 lines
14 KiB
JavaScript
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;
|