0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-10 23:36:14 -05:00

Merge pull request #1832 from ErisDS/issue-1464

Add update notifications
This commit is contained in:
Hannah Wolfe 2014-01-07 00:49:54 -08:00
commit d5b57a9480
9 changed files with 303 additions and 12 deletions

View file

@ -12,6 +12,7 @@ posts = {
// **takes:** filter / pagination parameters
browse: function browse(options) {
options = options || {};
// **returns:** a promise for a page of posts in a json object
//return dataProvider.Post.findPage(options);

View file

@ -6,6 +6,7 @@ var config = require('../config'),
mailer = require('../mail'),
errors = require('../errorHandling'),
storage = require('../storage'),
updateCheck = require('../update-check'),
adminNavbar,
adminControllers,
@ -278,10 +279,18 @@ adminControllers = {
},
'index': function (req, res) {
/*jslint unparam:true*/
res.render('content', {
bodyClass: 'manage',
adminNav: setSelected(adminNavbar, 'content')
});
function renderIndex() {
res.render('content', {
bodyClass: 'manage',
adminNav: setSelected(adminNavbar, 'content')
});
}
when.join(
updateCheck(res),
when(renderIndex())
// an error here should just get logged
).otherwise(errors.logError);
},
'editor': function (req, res) {
if (req.params.id !== undefined) {

View file

@ -5,6 +5,12 @@
},
"dbHash": {
"defaultValue": null
},
"nextUpdateCheck": {
"defaultValue": null
},
"displayUpdateNotification": {
"defaultValue": false
}
},
"blog": {

View file

@ -4,6 +4,7 @@ var _ = require('underscore'),
fs = require('fs'),
configPaths = require('./config/paths'),
path = require('path'),
when = require('when'),
errors,
// Paths for views
@ -34,6 +35,12 @@ errors = {
throw err;
},
// ## Reject Error
// Used to pass through promise errors when we want to handle them at a later time
rejectError: function (err) {
return when.reject(err);
},
logWarn: function (warn, context, help) {
if ((process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'staging' ||

View file

@ -560,6 +560,24 @@ coreHelpers.adminUrl = function (options) {
return config.paths.urlFor(context, absolute);
};
coreHelpers.updateNotification = function () {
var output = '';
if (config().updateCheck === false || !this.currentUser) {
return when(output);
}
return api.settings.read('displayUpdateNotification').then(function (display) {
if (display && display.value && display.value === 'true') {
output = '<div class="notification-success">' +
'A new version of Ghost is available! Hot damn. ' +
'<a href="http://ghost.org/download">Upgrade now</a></div>';
}
return output;
});
};
// Register an async handlebars helper for a given handlebars instance
function registerAsyncHelper(hbs, name, fn) {
hbs.registerAsyncHelper(name, function (options, cb) {
@ -573,7 +591,6 @@ function registerAsyncHelper(hbs, name, fn) {
});
}
// Register a handlebars helper for themes
function registerThemeHelper(name, fn) {
hbs.registerHelper(name, fn);
@ -651,6 +668,7 @@ registerHelpers = function (adminHbs, assetHash) {
registerAdminHelper('adminUrl', coreHelpers.adminUrl);
registerAsyncAdminHelper('updateNotification', coreHelpers.updateNotification);
};
module.exports = coreHelpers;

191
core/server/update-check.js Normal file
View file

@ -0,0 +1,191 @@
// # 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 'updateCheck: false' in their config.js
//
// The data collected is as follows:
// - blog id - a hash of the blog hostname, pathname and dbHash, we do not store URL, IP or other identifiable info
// - ghost version
// - node version
// - npm version
// - env - production or development
// - database type - SQLite, MySQL, pg
// - email transport - mail.options.service, or otherwise mail.transport
// - created date - the date the database was created
// - post count - total number of posts
// - user count - total number of users
// - theme - name of the currently active theme
// - apps - names of any active plugins
var crypto = require('crypto'),
exec = require('child_process').exec,
https = require('https'),
moment = require('moment'),
semver = require('semver'),
when = require('when'),
nodefn = require('when/node/function'),
_ = require('underscore'),
url = require('url'),
api = require('./api'),
config = require('./config'),
errors = require('./errorHandling'),
allowedCheckEnvironments = ['development', 'production'],
checkEndpoint = 'updates.ghost.org',
currentVersion;
function updateCheckError(error) {
errors.logError(
error,
"Checking for updates failed, your blog will continue to function.",
"If you get this error repeatedly, please seek help from http://ghost.org/forum."
);
}
function updateCheckData() {
var data = {},
ops = [],
mailConfig = config().mail;
ops.push(api.settings.read('dbHash').otherwise(errors.rejectError));
ops.push(api.settings.read('activeTheme').otherwise(errors.rejectError));
ops.push(api.settings.read('activePlugins')
.then(function (apps) {
try {
apps = JSON.parse(apps.value);
} catch (e) {
return errors.rejectError(e);
}
return _.reduce(apps, function (memo, item) { return memo === '' ? memo + item : memo + ', ' + item; }, '');
}).otherwise(errors.rejectError));
ops.push(api.posts.browse().otherwise(errors.rejectError));
ops.push(api.users.browse().otherwise(errors.rejectError));
ops.push(nodefn.call(exec, 'npm -v').otherwise(errors.rejectError));
data.ghost_version = currentVersion;
data.node_version = process.versions.node;
data.env = process.env.NODE_ENV;
data.database_type = require('./models/base').client;
data.email_transport = mailConfig.options && mailConfig.options.service ? mailConfig.options.service : mailConfig.transport;
return when.settle(ops).then(function (descriptors) {
var hash = descriptors[0].value,
theme = descriptors[1].value,
apps = descriptors[2].value,
posts = descriptors[3].value,
users = descriptors[4].value,
npm = descriptors[5].value,
blogUrl = url.parse(config().url),
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.total ? posts.total : 0;
data.user_count = users && users.length ? users.length : 0;
data.blog_created_at = users && users[0] && users[0].created_at ? moment(users[0].created_at).unix() : '';
data.npm_version = _.isArray(npm) && npm[0] ? npm[0].toString().replace(/\n/, '') : '';
return data;
}).otherwise(updateCheckError);
}
function updateCheckRequest() {
return updateCheckData().then(function (reqData) {
var deferred = when.defer(),
resData = '',
headers,
req;
reqData = JSON.stringify(reqData);
headers = {
'Content-Length': reqData.length
};
req = https.request({
hostname: checkEndpoint,
method: 'POST',
headers: headers
}, function (res) {
res.on('error', function (error) { deferred.reject(error); });
res.on('data', function (chunk) { resData += chunk; });
res.on('end', function () {
try {
resData = JSON.parse(resData);
deferred.resolve(resData);
} catch (e) {
deferred.reject('Unable to decode update response');
}
});
});
req.write(reqData);
req.end();
req.on('error', function (error) {
deferred.reject(error);
});
return deferred.promise;
});
}
// ## Update Check Response
// Handles the response from the update check
// Does two things with the information received:
// 1. Updates the time we can next make a check
// 2. Checks if the version in the response is new, and updates the notification setting
function updateCheckResponse(response) {
var ops = [],
displayUpdateNotification = currentVersion && semver.gt(response.version, currentVersion);
ops.push(api.settings.edit('nextUpdateCheck', response.next_check)
.otherwise(errors.rejectError));
ops.push(api.settings.edit('displayUpdateNotification', displayUpdateNotification)
.otherwise(errors.rejectError));
return when.settle(ops).then(function (descriptors) {
descriptors.forEach(function (d) {
if (d.state === 'rejected') {
errors.rejectError(d.reason);
}
});
return when.resolve();
});
}
function updateCheck(res) {
var deferred = when.defer();
// The check will not happen if:
// 1. updateCheck is defined as false in config.js
// 2. we've already done a check this session
// 3. we're not in production or development mode
if (config().updateCheck === false || _.indexOf(allowedCheckEnvironments, process.env.NODE_ENV) === -1) {
// No update check
deferred.resolve();
} else {
api.settings.read('nextUpdateCheck').then(function (nextUpdateCheck) {
if (nextUpdateCheck && nextUpdateCheck.value && nextUpdateCheck.value > moment().unix()) {
// It's not time to check yet
deferred.resolve();
} else {
// We need to do a check, store the current version
currentVersion = res.locals.version;
return updateCheckRequest()
.then(updateCheckResponse)
.otherwise(updateCheckError);
}
}).otherwise(updateCheckError)
.then(deferred.resolve);
}
return deferred.promise;
}
module.exports = updateCheck;

View file

@ -37,6 +37,8 @@
{{/unless}}
<main role="main" id="main">
{{updateNotification}}
<aside id="notifications">
{{> notifications}}
</aside>

View file

@ -17,15 +17,14 @@ var testUtils = require('../utils'),
describe('Core Helpers', function () {
var ghost,
sandbox,
var sandbox,
apiStub;
beforeEach(function (done) {
var adminHbs = hbs.create();
helpers = rewire('../../server/helpers');
sandbox = sinon.sandbox.create();
apiStub = sandbox.stub(api.settings, 'read', function () {
apiStub = sandbox.stub(api.settings, 'read', function (arg) {
return when({value: 'casper'});
});
@ -778,12 +777,14 @@ describe('Core Helpers', function () {
});
// ## Admin only helpers
describe("ghostScriptTags helper", function () {
var rendered,
configStub;
beforeEach(function () {
// set the asset hash
helpers = rewire('../../server/helpers');
helpers.assetHash = 'abc';
});
@ -937,4 +938,59 @@ describe('Core Helpers', function () {
});
});
});
describe('updateNotification', function () {
it('outputs a correctly formatted notification when display is set to true', function (done) {
var output = '<div class="notification-success">' +
'A new version of Ghost is available! Hot damn. ' +
'<a href="http://ghost.org/download">Upgrade now</a></div>';
apiStub.restore();
apiStub = sandbox.stub(api.settings, 'read', function () {
return when({value: 'true'});
});
helpers.updateNotification.call({currentUser: {name: 'bob'}}).then(function (rendered) {
should.exist(rendered);
rendered.should.equal(output);
done();
}).then(null, done);
});
it('does NOT output a correctly formatted notification when display is not set to true', function (done) {
helpers.updateNotification.call({currentUser: {name: 'bob'}}).then(function (rendered) {
should.exist(rendered);
rendered.should.equal('');
done();
}).then(null, done);
});
it('does NOT output a notification if updateCheck is false', function (done) {
helpers.__set__('config', function () { return { updateCheck: false}; });
apiStub.restore();
apiStub = sandbox.stub(api.settings, 'read', function () {
return when({value: 'true'});
});
helpers.updateNotification.call({currentUser: {name: 'bob'}}).then(function (rendered) {
should.exist(rendered);
rendered.should.equal('');
done();
}).then(null, done);
});
it('does NOT output a notification if the user is not logged in', function (done) {
apiStub.restore();
apiStub = sandbox.stub(api.settings, 'read', function () {
return when({value: 'true'});
});
helpers.updateNotification.call().then(function (rendered) {
should.exist(rendered);
rendered.should.equal('');
done();
}).then(null, done);
});
});
});

View file

@ -7,11 +7,12 @@ var _ = require('underscore'),
expectedProperties = {
posts: ['posts', 'page', 'limit', 'pages', 'total'],
post: ['id', 'uuid', 'title', 'slug', 'markdown', 'html', 'meta_title', 'meta_description',
'featured', 'image', 'status', 'language', 'author_id', 'created_at', 'created_by', 'updated_at', 'updated_by',
'published_at', 'published_by', 'page', 'author', 'user', 'tags'],
'featured', 'image', 'status', 'language', 'author_id', 'created_at', 'created_by', 'updated_at',
'updated_by', 'published_at', 'published_by', 'page', 'author', 'user', 'tags'],
// TODO: remove databaseVersion, dbHash
settings: ['databaseVersion', 'dbHash', 'title', 'description', 'email', 'logo', 'cover', 'defaultLang', "permalinks",
'postsPerPage', 'forceI18n', 'activeTheme', 'activePlugins', 'installedPlugins', 'availableThemes'],
settings: ['databaseVersion', 'dbHash', 'title', 'description', 'email', 'logo', 'cover', 'defaultLang',
"permalinks", 'postsPerPage', 'forceI18n', 'activeTheme', 'activePlugins', 'installedPlugins',
'availableThemes', 'nextUpdateCheck', 'displayUpdateNotification'],
tag: ['id', 'uuid', 'name', 'slug', 'description', 'parent_id',
'meta_title', 'meta_description', 'created_at', 'created_by', 'updated_at', 'updated_by'],
user: ['id', 'uuid', 'name', 'slug', 'email', 'image', 'cover', 'bio', 'website',