Skip to content

Commit dcf4b1e

Browse files
committed
Add update notifications
closes #1464 - adds opt-out via updateCheck:false in config.js - update check is done on admin index, but doesn't interfere with rendering - adds update check module, which gets the usage data, makes the request and handles the response - adds two new settings to default-settings, one for next check time, and one for whether to show the notification - adds a new rejectError method to errorHandling - adds a new helper for displaying the notification Conflicts: core/server/helpers/index.js core/test/unit/server_helpers_index_spec.js
1 parent 80eac65 commit dcf4b1e

File tree

9 files changed

+303
-12
lines changed

9 files changed

+303
-12
lines changed

core/server/api/posts.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ posts = {
1212

1313
// **takes:** filter / pagination parameters
1414
browse: function browse(options) {
15+
options = options || {};
1516

1617
// **returns:** a promise for a page of posts in a json object
1718
//return dataProvider.Post.findPage(options);

core/server/controllers/admin.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ var config = require('../config'),
66
mailer = require('../mail'),
77
errors = require('../errorHandling'),
88
storage = require('../storage'),
9+
updateCheck = require('../update-check'),
910

1011
adminNavbar,
1112
adminControllers,
@@ -278,10 +279,18 @@ adminControllers = {
278279
},
279280
'index': function (req, res) {
280281
/*jslint unparam:true*/
281-
res.render('content', {
282-
bodyClass: 'manage',
283-
adminNav: setSelected(adminNavbar, 'content')
284-
});
282+
function renderIndex() {
283+
res.render('content', {
284+
bodyClass: 'manage',
285+
adminNav: setSelected(adminNavbar, 'content')
286+
});
287+
}
288+
289+
when.join(
290+
updateCheck(res),
291+
when(renderIndex())
292+
// an error here should just get logged
293+
).otherwise(errors.logError);
285294
},
286295
'editor': function (req, res) {
287296
if (req.params.id !== undefined) {

core/server/data/default-settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
},
66
"dbHash": {
77
"defaultValue": null
8+
},
9+
"nextUpdateCheck": {
10+
"defaultValue": null
11+
},
12+
"displayUpdateNotification": {
13+
"defaultValue": false
814
}
915
},
1016
"blog": {

core/server/errorHandling.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ var _ = require('underscore'),
44
fs = require('fs'),
55
configPaths = require('./config/paths'),
66
path = require('path'),
7+
when = require('when'),
78
errors,
89

910
// Paths for views
@@ -34,6 +35,12 @@ errors = {
3435
throw err;
3536
},
3637

38+
// ## Reject Error
39+
// Used to pass through promise errors when we want to handle them at a later time
40+
rejectError: function (err) {
41+
return when.reject(err);
42+
},
43+
3744
logWarn: function (warn, context, help) {
3845
if ((process.env.NODE_ENV === 'development' ||
3946
process.env.NODE_ENV === 'staging' ||

core/server/helpers/index.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,24 @@ coreHelpers.adminUrl = function (options) {
560560
return config.paths.urlFor(context, absolute);
561561
};
562562

563+
coreHelpers.updateNotification = function () {
564+
var output = '';
565+
566+
if (config().updateCheck === false || !this.currentUser) {
567+
return when(output);
568+
}
569+
570+
return api.settings.read('displayUpdateNotification').then(function (display) {
571+
if (display && display.value && display.value === 'true') {
572+
output = '<div class="notification-success">' +
573+
'A new version of Ghost is available! Hot damn. ' +
574+
'<a href="http://ghost.org/download">Upgrade now</a></div>';
575+
}
576+
577+
return output;
578+
});
579+
};
580+
563581
// Register an async handlebars helper for a given handlebars instance
564582
function registerAsyncHelper(hbs, name, fn) {
565583
hbs.registerAsyncHelper(name, function (options, cb) {
@@ -573,7 +591,6 @@ function registerAsyncHelper(hbs, name, fn) {
573591
});
574592
}
575593

576-
577594
// Register a handlebars helper for themes
578595
function registerThemeHelper(name, fn) {
579596
hbs.registerHelper(name, fn);
@@ -651,6 +668,7 @@ registerHelpers = function (adminHbs, assetHash) {
651668

652669
registerAdminHelper('adminUrl', coreHelpers.adminUrl);
653670

671+
registerAsyncAdminHelper('updateNotification', coreHelpers.updateNotification);
654672
};
655673

656674
module.exports = coreHelpers;

core/server/update-check.js

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// # Update Checking Service
2+
//
3+
// Makes a request to Ghost.org to check if there is a new version of Ghost available.
4+
// The service is provided in return for users opting in to anonymous usage data collection
5+
// Blog owners can opt-out of update checks by setting 'updateCheck: false' in their config.js
6+
//
7+
// The data collected is as follows:
8+
// - blog id - a hash of the blog hostname, pathname and dbHash, we do not store URL, IP or other identifiable info
9+
// - ghost version
10+
// - node version
11+
// - npm version
12+
// - env - production or development
13+
// - database type - SQLite, MySQL, pg
14+
// - email transport - mail.options.service, or otherwise mail.transport
15+
// - created date - the date the database was created
16+
// - post count - total number of posts
17+
// - user count - total number of users
18+
// - theme - name of the currently active theme
19+
// - apps - names of any active plugins
20+
21+
var crypto = require('crypto'),
22+
exec = require('child_process').exec,
23+
https = require('https'),
24+
moment = require('moment'),
25+
semver = require('semver'),
26+
when = require('when'),
27+
nodefn = require('when/node/function'),
28+
_ = require('underscore'),
29+
url = require('url'),
30+
31+
api = require('./api'),
32+
config = require('./config'),
33+
errors = require('./errorHandling'),
34+
35+
allowedCheckEnvironments = ['development', 'production'],
36+
checkEndpoint = 'updates.ghost.org',
37+
currentVersion;
38+
39+
function updateCheckError(error) {
40+
errors.logError(
41+
error,
42+
"Checking for updates failed, your blog will continue to function.",
43+
"If you get this error repeatedly, please seek help from http://ghost.org/forum."
44+
);
45+
}
46+
47+
function updateCheckData() {
48+
var data = {},
49+
ops = [],
50+
mailConfig = config().mail;
51+
52+
ops.push(api.settings.read('dbHash').otherwise(errors.rejectError));
53+
ops.push(api.settings.read('activeTheme').otherwise(errors.rejectError));
54+
ops.push(api.settings.read('activePlugins')
55+
.then(function (apps) {
56+
try {
57+
apps = JSON.parse(apps.value);
58+
} catch (e) {
59+
return errors.rejectError(e);
60+
}
61+
62+
return _.reduce(apps, function (memo, item) { return memo === '' ? memo + item : memo + ', ' + item; }, '');
63+
}).otherwise(errors.rejectError));
64+
ops.push(api.posts.browse().otherwise(errors.rejectError));
65+
ops.push(api.users.browse().otherwise(errors.rejectError));
66+
ops.push(nodefn.call(exec, 'npm -v').otherwise(errors.rejectError));
67+
68+
data.ghost_version = currentVersion;
69+
data.node_version = process.versions.node;
70+
data.env = process.env.NODE_ENV;
71+
data.database_type = require('./models/base').client;
72+
data.email_transport = mailConfig.options && mailConfig.options.service ? mailConfig.options.service : mailConfig.transport;
73+
74+
return when.settle(ops).then(function (descriptors) {
75+
var hash = descriptors[0].value,
76+
theme = descriptors[1].value,
77+
apps = descriptors[2].value,
78+
posts = descriptors[3].value,
79+
users = descriptors[4].value,
80+
npm = descriptors[5].value,
81+
blogUrl = url.parse(config().url),
82+
blogId = blogUrl.hostname + blogUrl.pathname.replace(/\//, '') + hash.value;
83+
84+
data.blog_id = crypto.createHash('md5').update(blogId).digest('hex');
85+
data.theme = theme ? theme.value : '';
86+
data.apps = apps || '';
87+
data.post_count = posts && posts.total ? posts.total : 0;
88+
data.user_count = users && users.length ? users.length : 0;
89+
data.blog_created_at = users && users[0] && users[0].created_at ? moment(users[0].created_at).unix() : '';
90+
data.npm_version = _.isArray(npm) && npm[0] ? npm[0].toString().replace(/\n/, '') : '';
91+
92+
return data;
93+
}).otherwise(updateCheckError);
94+
}
95+
96+
function updateCheckRequest() {
97+
return updateCheckData().then(function (reqData) {
98+
var deferred = when.defer(),
99+
resData = '',
100+
headers,
101+
req;
102+
103+
reqData = JSON.stringify(reqData);
104+
105+
headers = {
106+
'Content-Length': reqData.length
107+
};
108+
109+
req = https.request({
110+
hostname: checkEndpoint,
111+
method: 'POST',
112+
headers: headers
113+
}, function (res) {
114+
res.on('error', function (error) { deferred.reject(error); });
115+
res.on('data', function (chunk) { resData += chunk; });
116+
res.on('end', function () {
117+
try {
118+
resData = JSON.parse(resData);
119+
deferred.resolve(resData);
120+
} catch (e) {
121+
deferred.reject('Unable to decode update response');
122+
}
123+
});
124+
});
125+
126+
req.write(reqData);
127+
req.end();
128+
129+
req.on('error', function (error) {
130+
deferred.reject(error);
131+
});
132+
133+
return deferred.promise;
134+
});
135+
}
136+
137+
// ## Update Check Response
138+
// Handles the response from the update check
139+
// Does two things with the information received:
140+
// 1. Updates the time we can next make a check
141+
// 2. Checks if the version in the response is new, and updates the notification setting
142+
function updateCheckResponse(response) {
143+
var ops = [],
144+
displayUpdateNotification = currentVersion && semver.gt(response.version, currentVersion);
145+
146+
ops.push(api.settings.edit('nextUpdateCheck', response.next_check)
147+
.otherwise(errors.rejectError));
148+
149+
ops.push(api.settings.edit('displayUpdateNotification', displayUpdateNotification)
150+
.otherwise(errors.rejectError));
151+
152+
return when.settle(ops).then(function (descriptors) {
153+
descriptors.forEach(function (d) {
154+
if (d.state === 'rejected') {
155+
errors.rejectError(d.reason);
156+
}
157+
});
158+
return when.resolve();
159+
});
160+
}
161+
162+
function updateCheck(res) {
163+
var deferred = when.defer();
164+
165+
// The check will not happen if:
166+
// 1. updateCheck is defined as false in config.js
167+
// 2. we've already done a check this session
168+
// 3. we're not in production or development mode
169+
if (config().updateCheck === false || _.indexOf(allowedCheckEnvironments, process.env.NODE_ENV) === -1) {
170+
// No update check
171+
deferred.resolve();
172+
} else {
173+
api.settings.read('nextUpdateCheck').then(function (nextUpdateCheck) {
174+
if (nextUpdateCheck && nextUpdateCheck.value && nextUpdateCheck.value > moment().unix()) {
175+
// It's not time to check yet
176+
deferred.resolve();
177+
} else {
178+
// We need to do a check, store the current version
179+
currentVersion = res.locals.version;
180+
return updateCheckRequest()
181+
.then(updateCheckResponse)
182+
.otherwise(updateCheckError);
183+
}
184+
}).otherwise(updateCheckError)
185+
.then(deferred.resolve);
186+
}
187+
188+
return deferred.promise;
189+
}
190+
191+
module.exports = updateCheck;

core/server/views/default.hbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
{{/unless}}
3838

3939
<main role="main" id="main">
40+
{{updateNotification}}
41+
4042
<aside id="notifications">
4143
{{> notifications}}
4244
</aside>

0 commit comments

Comments
 (0)