Merge pull request #6617 from acburdine/private-blogging-app

Move private-blogging into an internal app
This commit is contained in:
Hannah Wolfe 2016-04-11 15:55:06 +01:00
commit b0ab3f0273
19 changed files with 332 additions and 238 deletions

View File

@ -183,7 +183,8 @@ var _ = require('lodash'),
// #### All Unit tests
unit: {
src: [
'core/test/unit/**/*_spec.js'
'core/test/unit/**/*_spec.js',
'core/server/apps/**/tests/*_spec.js'
]
},

View File

@ -5,6 +5,7 @@ var _ = require('lodash'),
api = require('../api'),
loader = require('./loader'),
i18n = require('../i18n'),
config = require('../config'),
// Holds the available apps
availableApps = {};
@ -20,13 +21,13 @@ function getInstalledApps() {
return Promise.reject(e);
}
return installed;
return installed.concat(config.internalApps);
});
}
function saveInstalledApps(installedApps) {
return getInstalledApps().then(function (currentInstalledApps) {
var updatedAppsInstalled = _.uniq(installedApps.concat(currentInstalledApps));
var updatedAppsInstalled = _.difference(_.uniq(installedApps.concat(currentInstalledApps)), config.internalApps);
return api.settings.edit({settings: [{key: 'installedApps', value: updatedAppsInstalled}]}, {context: {internal: true}});
});
@ -42,6 +43,8 @@ module.exports = {
var aApps = response.settings[0];
appsToLoad = JSON.parse(aApps.value) || [];
appsToLoad = appsToLoad.concat(config.internalApps);
});
} catch (e) {
errors.logError(

View File

@ -10,8 +10,16 @@ var path = require('path'),
i18n = require('../i18n'),
loader;
function isInternalApp(name) {
return _.contains(config.internalApps, name);
}
// Get the full path to an app by name
function getAppAbsolutePath(name) {
if (isInternalApp(name)) {
return path.join(config.paths.corePath, '/server/apps/', name);
}
return path.join(config.paths.appPath, name);
}
@ -20,19 +28,25 @@ function getAppAbsolutePath(name) {
function getAppRelativePath(name, relativeTo) {
relativeTo = relativeTo || __dirname;
return path.relative(relativeTo, getAppAbsolutePath(name));
var relativePath = path.relative(relativeTo, getAppAbsolutePath(name));
if (relativePath.charAt(0) !== '.') {
relativePath = './' + relativePath;
}
return relativePath;
}
// Load apps through a pseudo sandbox
function loadApp(appPath) {
var sandbox = new AppSandbox();
function loadApp(appPath, isInternal) {
var sandbox = new AppSandbox({internal: isInternal});
return sandbox.loadApp(appPath);
}
function getAppByName(name, permissions) {
// Grab the app class to instantiate
var AppClass = loadApp(getAppRelativePath(name)),
var AppClass = loadApp(getAppRelativePath(name), isInternalApp(name)),
appProxy = new AppProxy({
name: name,
permissions: permissions

View File

@ -0,0 +1,30 @@
var config = require('../../config'),
errors = require('../../errors'),
i18n = require('../../i18n'),
middleware = require('./lib/middleware'),
router = require('./lib/router');
module.exports = {
activate: function activate() {
if (config.paths.subdir) {
var paths = config.paths.subdir.split('/');
if (paths.pop() === config.routeKeywords.private) {
errors.logErrorAndExit(
new Error(i18n.t('errors.config.urlCannotContainPrivateSubdir.error')),
i18n.t('errors.config.urlCannotContainPrivateSubdir.description'),
i18n.t('errors.config.urlCannotContainPrivateSubdir.help')
);
}
}
},
setupMiddleware: function setupMiddleware(blogApp) {
blogApp.use(middleware.checkIsPrivate);
blogApp.use(middleware.filterPrivateRoutes);
},
setupRoutes: function setupRoutes(blogRouter) {
blogRouter.use('/' + config.routeKeywords.private + '/', router);
}
};

View File

@ -1,14 +1,16 @@
var _ = require('lodash'),
fs = require('fs'),
config = require('../config'),
config = require('../../../config'),
crypto = require('crypto'),
path = require('path'),
api = require('../api'),
api = require('../../../api'),
Promise = require('bluebird'),
errors = require('../errors'),
errors = require('../../../errors'),
session = require('cookie-session'),
utils = require('../utils'),
i18n = require('../i18n'),
utils = require('../../../utils'),
i18n = require('../../../i18n'),
privateRoute = '/' + config.routeKeywords.private + '/',
protectedSecurity = [],
privateBlogging;
function verifySessionHash(salt, hash) {
@ -45,7 +47,7 @@ privateBlogging = {
},
filterPrivateRoutes: function filterPrivateRoutes(req, res, next) {
if (res.isAdmin || !res.isPrivateBlog || req.url.lastIndexOf('/private/', 0) === 0) {
if (res.isAdmin || !res.isPrivateBlog || req.url.lastIndexOf(privateRoute, 0) === 0) {
return next();
}
@ -55,7 +57,7 @@ privateBlogging = {
(req.path.lastIndexOf('/sitemap', 0) === 0 && req.path.lastIndexOf('.xml') === req.path.length - 4)) {
return errors.error404(req, res, next);
} else if (req.url.lastIndexOf('/robots.txt', 0) === 0) {
fs.readFile(path.join(config.paths.corePath, 'shared', 'private-robots.txt'), function readFile(err, buf) {
fs.readFile(path.resolve(__dirname, '../', 'robots.txt'), function readFile(err, buf) {
if (err) {
return next(err);
}
@ -80,7 +82,7 @@ privateBlogging = {
if (isVerified) {
return next();
} else {
url = config.urlFor({relativeUrl: '/private/'});
url = config.urlFor({relativeUrl: privateRoute});
url += req.url === '/' ? '' : '?r=' + encodeURIComponent(req.url);
return res.redirect(url);
}
@ -133,6 +135,46 @@ privateBlogging = {
return next();
}
});
},
spamPrevention: function spamPrevention(req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
rateProtectedPeriod = config.rateProtectedPeriod || 3600,
rateProtectedAttempts = config.rateProtectedAttempts || 10,
ipCount = '',
message = i18n.t('errors.middleware.spamprevention.tooManyAttempts'),
deniedRateLimit = '',
password = req.body.password;
if (password) {
protectedSecurity.push({ip: remoteAddress, time: currentTime});
} else {
res.error = {
message: i18n.t('errors.middleware.spamprevention.noPassword')
};
return next();
}
// filter entries that are older than rateProtectedPeriod
protectedSecurity = _.filter(protectedSecurity, function filter(logTime) {
return (logTime.time + rateProtectedPeriod > currentTime);
});
ipCount = _.chain(protectedSecurity).countBy('ip').value();
deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts);
if (deniedRateLimit) {
errors.logError(
i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateProtectedAttempts, rfp: rateProtectedPeriod}),
i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
);
message += rateProtectedPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater');
res.error = {
message: message
};
}
return next();
}
};

View File

@ -0,0 +1,39 @@
var path = require('path'),
express = require('express'),
middleware = require('./middleware'),
templates = require('../../../controllers/frontend/templates'),
setResponseContext = require('../../../controllers/frontend/context'),
privateRouter = express.Router();
function controller(req, res) {
var defaultView = path.resolve(__dirname, 'views', 'private.hbs'),
paths = templates.getActiveThemePaths(req.app.get('activeTheme')),
data = {};
if (res.error) {
data.error = res.error;
}
setResponseContext(req, res);
if (paths.hasOwnProperty('private.hbs')) {
return res.render('private', data);
} else {
return res.render(defaultView, data);
}
}
// password-protected frontend route
privateRouter.route('/')
.get(
middleware.isPrivateSessionAuth,
controller
)
.post(
middleware.isPrivateSessionAuth,
middleware.spamPrevention,
middleware.authenticateProtection,
controller
);
module.exports = privateRouter;
module.exports.controller = controller;

View File

@ -0,0 +1,83 @@
/*globals describe, beforeEach, afterEach, it*/
var privateController = require('../lib/router').controller,
path = require('path'),
sinon = require('sinon'),
configUtils = require('../../../../test/utils/configUtils'),
sandbox = sinon.sandbox.create();
describe('Private Controller', function () {
var res, req, defaultPath;
// Helper function to prevent unit tests
// from failing via timeout when they
// should just immediately fail
function failTest(done) {
return function (err) {
done(err);
};
}
beforeEach(function () {
res = {
locals: {version: ''},
render: sandbox.spy()
};
req = {
app: {get: function () { return 'casper'; }},
route: {path: '/private/?r=/'},
query: {r: ''},
params: {}
};
defaultPath = path.join(configUtils.config.paths.appRoot, '/core/server/apps/private-blogging/lib/views/private.hbs');
configUtils.set({
theme: {
permalinks: '/:slug/'
}
});
});
afterEach(function () {
sandbox.restore();
configUtils.restore();
});
it('Should render default password page when theme has no password template', function (done) {
configUtils.set({paths: {availableThemes: {casper: {}}}});
res.render = function (view) {
view.should.eql(defaultPath);
done();
};
privateController(req, res, failTest(done));
});
it('Should render theme password page when it exists', function (done) {
configUtils.set({paths: {availableThemes: {casper: {
'private.hbs': '/content/themes/casper/private.hbs'
}}}});
res.render = function (view) {
view.should.eql('private');
done();
};
privateController(req, res, failTest(done));
});
it('Should render with error when error is passed in', function (done) {
configUtils.set({paths: {availableThemes: {casper: {}}}});
res.error = 'Test Error';
res.render = function (view, context) {
view.should.eql(defaultPath);
context.should.eql({error: 'Test Error'});
done();
};
privateController(req, res, failTest(done));
});
});

View File

@ -1,11 +1,11 @@
/*globals describe, beforeEach, afterEach, it*/
/*globals describe, beforeEach, afterEach, before, it*/
var crypto = require('crypto'),
should = require('should'),
sinon = require('sinon'),
Promise = require('bluebird'),
privateBlogging = require('../../../server/middleware/private-blogging'),
api = require('../../../server/api'),
errors = require('../../../server/errors'),
privateBlogging = require('../lib/middleware'),
api = require('../../../api'),
errors = require('../../../errors'),
fs = require('fs');
should.equal(true, true);
@ -268,4 +268,77 @@ describe('Private Blogging', function () {
});
});
});
describe('spamPrevention', function () {
var error = null,
res, req, spyNext;
before(function () {
spyNext = sinon.spy(function (param) {
error = param;
});
});
beforeEach(function () {
res = sinon.spy();
req = {
connection: {
remoteAddress: '10.0.0.0'
},
body: {
password: 'password'
}
};
});
it ('sets an error when there is no password', function (done) {
req.body = {};
privateBlogging.spamPrevention(req, res, spyNext);
res.error.message.should.equal('No password entered');
spyNext.calledOnce.should.be.true();
done();
});
it ('sets and error message after 10 tries', function (done) {
var ndx;
for (ndx = 0; ndx < 10; ndx = ndx + 1) {
privateBlogging.spamPrevention(req, res, spyNext);
}
should.not.exist(res.error);
privateBlogging.spamPrevention(req, res, spyNext);
should.exist(res.error);
should.exist(res.error.message);
done();
});
it ('allows more tries after an hour', function (done) {
var ndx,
stub = sinon.stub(process, 'hrtime', function () {
return [10, 10];
});
for (ndx = 0; ndx < 11; ndx = ndx + 1) {
privateBlogging.spamPrevention(req, res, spyNext);
}
should.exist(res.error);
process.hrtime.restore();
stub = sinon.stub(process, 'hrtime', function () {
return [3610000, 10];
});
res = sinon.spy();
privateBlogging.spamPrevention(req, res, spyNext);
should.not.exist(res.error);
process.hrtime.restore();
done();
});
});
});

View File

@ -32,6 +32,12 @@ AppSandbox.prototype.loadModule = function loadModuleSandboxed(modulePath) {
// Instantiate a Node Module class
currentModule = new Module(modulePath, parentModulePath);
if (this.opts.internal) {
currentModule.load(currentModule.id);
return currentModule.exports;
}
// Grab the original modules require function
nodeRequire = currentModule.require;

View File

@ -192,6 +192,7 @@ ConfigManager.prototype.set = function (config) {
preview: 'p',
private: 'private'
},
internalApps: ['private-blogging'],
slugs: {
// Used by generateSlug to generate slugs for posts, tags, users, ..
// reserved slugs are reserved but can be extended/removed by apps

View File

@ -6,7 +6,6 @@
var _ = require('lodash'),
api = require('../../api'),
path = require('path'),
config = require('../../config'),
errors = require('../../errors'),
filters = require('../../filters'),
@ -143,22 +142,6 @@ frontendControllers = {
return next();
}
}).catch(handleError(next));
},
private: function private(req, res) {
var defaultPage = path.resolve(config.paths.adminViews, 'private.hbs'),
paths = templates.getActiveThemePaths(req.app.get('activeTheme')),
data = {};
if (res.error) {
data.error = res.error;
}
setResponseContext(req, res);
if (paths.hasOwnProperty('private.hbs')) {
return res.render('private', data);
} else {
return res.render(defaultPage, data);
}
}
};

View File

@ -18,7 +18,6 @@ var bodyParser = require('body-parser'),
checkSSL = require('./check-ssl'),
decideIsAdmin = require('./decide-is-admin'),
oauth = require('./oauth'),
privateBlogging = require('./private-blogging'),
redirectToSetup = require('./redirect-to-setup'),
serveSharedFile = require('./serve-shared-file'),
spamPrevention = require('./spam-prevention'),
@ -26,6 +25,7 @@ var bodyParser = require('body-parser'),
themeHandler = require('./theme-handler'),
uncapitalise = require('./uncapitalise'),
cors = require('./cors'),
privateBlogging = require('../apps/private-blogging'),
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
BearerStrategy = require('passport-http-bearer').Strategy,
@ -37,7 +37,6 @@ middleware = {
upload: multer({dest: tmpdir()}),
cacheControl: cacheControl,
spamPrevention: spamPrevention,
privateBlogging: privateBlogging,
oauth: oauth,
api: {
authenticateClient: auth.authenticateClient,
@ -111,9 +110,8 @@ setupMiddleware = function setupMiddleware(blogApp, adminApp) {
// Theme only config
blogApp.use(staticTheme());
// Check if password protected blog
blogApp.use(privateBlogging.checkIsPrivate); // check if the blog is protected
blogApp.use(privateBlogging.filterPrivateRoutes);
// setup middleware for private blogs
privateBlogging.setupMiddleware(blogApp);
// Serve sitemap.xsl file
blogApp.use(serveSharedFile('sitemap.xsl', 'text/xsl', utils.ONE_DAY_S));
@ -158,8 +156,8 @@ setupMiddleware = function setupMiddleware(blogApp, adminApp) {
adminApp.use(routes.admin());
blogApp.use('/ghost', adminApp);
// Set up Frontend routes
blogApp.use(routes.frontend(middleware));
// Set up Frontend routes (including private blogging routes)
blogApp.use(routes.frontend());
// ### Error handling
// 404 Handler

View File

@ -12,7 +12,6 @@ var _ = require('lodash'),
i18n = require('../i18n'),
loginSecurity = [],
forgottenSecurity = [],
protectedSecurity = [],
spamPrevention;
spamPrevention = {
@ -116,46 +115,6 @@ spamPrevention = {
next();
},
protected: function protected(req, res, next) {
var currentTime = process.hrtime()[0],
remoteAddress = req.connection.remoteAddress,
rateProtectedPeriod = config.rateProtectedPeriod || 3600,
rateProtectedAttempts = config.rateProtectedAttempts || 10,
ipCount = '',
message = i18n.t('errors.middleware.spamprevention.tooManyAttempts'),
deniedRateLimit = '',
password = req.body.password;
if (password) {
protectedSecurity.push({ip: remoteAddress, time: currentTime});
} else {
res.error = {
message: i18n.t('errors.middleware.spamprevention.noPassword')
};
return next();
}
// filter entries that are older than rateProtectedPeriod
protectedSecurity = _.filter(protectedSecurity, function filter(logTime) {
return (logTime.time + rateProtectedPeriod > currentTime);
});
ipCount = _.chain(protectedSecurity).countBy('ip').value();
deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts);
if (deniedRateLimit) {
errors.logError(
i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateProtectedAttempts, rfp: rateProtectedPeriod}),
i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context')
);
message += rateProtectedPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater');
res.error = {
message: message
};
}
return next();
},
resetCounter: function resetCounter(email) {
loginSecurity = _.filter(loginSecurity, function filter(logTime) {
return (logTime.email !== email);

View File

@ -1,16 +1,16 @@
var frontend = require('../controllers/frontend'),
channels = require('../controllers/frontend/channels'),
config = require('../config'),
express = require('express'),
utils = require('../utils'),
var frontend = require('../controllers/frontend'),
channels = require('../controllers/frontend/channels'),
config = require('../config'),
express = require('express'),
utils = require('../utils'),
privateBlogging = require('../apps/private-blogging'),
frontendRoutes;
frontendRoutes = function frontendRoutes(middleware) {
frontendRoutes = function frontendRoutes() {
var router = express.Router(),
subdir = config.paths.subdir,
routeKeywords = config.routeKeywords,
privateRouter = express.Router();
routeKeywords = config.routeKeywords;
// ### Admin routes
router.get(/^\/(logout|signout)\/$/, function redirectToSignout(req, res) {
@ -25,31 +25,18 @@ frontendRoutes = function frontendRoutes(middleware) {
utils.redirect301(res, subdir + '/ghost/');
});
// password-protected frontend route
privateRouter.route('/')
.get(
middleware.privateBlogging.isPrivateSessionAuth,
frontend.private
)
.post(
middleware.privateBlogging.isPrivateSessionAuth,
middleware.spamPrevention.protected,
middleware.privateBlogging.authenticateProtection,
frontend.private
);
// Post Live Preview
router.get('/' + routeKeywords.preview + '/:uuid', frontend.preview);
// Private
router.use('/' + routeKeywords.private + '/', privateRouter);
// Channels
router.use(channels.router());
// Default
router.get('*', frontend.single);
// @TODO: this can be removed once the proper app route hooks have been set up.
privateBlogging.setupRoutes(router);
return router;
};

View File

@ -145,6 +145,11 @@
"description": "Your site url in config.js cannot contain a subdirectory called ghost.",
"help": "Please rename the subdirectory before restarting"
},
"urlCannotContainPrivateSubdir": {
"error": "private subdirectory not allowed",
"description": "Your site url in config.js cannot contain a subdirectory called private.",
"help": "Please rename the subdirectory before restarting"
},
"dbConfigInvalid": {
"error": "invalid database configuration",
"description": "Your database configuration in config.js is invalid.",

View File

@ -4,7 +4,6 @@ var moment = require('moment'),
sinon = require('sinon'),
Promise = require('bluebird'),
_ = require('lodash'),
path = require('path'),
// Stuff we are testing
api = require('../../../../server/api'),
@ -730,69 +729,6 @@ describe('Frontend Controller', function () {
});
});
describe('private', function () {
var res, req, defaultPath;
beforeEach(function () {
res = {
locals: {version: ''},
render: sandbox.spy()
};
req = {
app: {get: function () { return 'casper'; }},
route: {path: '/private/?r=/'},
query: {r: ''},
params: {}
};
defaultPath = path.join(configUtils.config.paths.appRoot, '/core/server/views/private.hbs');
configUtils.set({
theme: {
permalinks: '/:slug/'
}
});
});
it('Should render default password page when theme has no password template', function (done) {
configUtils.set({paths: {availableThemes: {casper: {}}}});
res.render = function (view) {
view.should.eql(defaultPath);
done();
};
frontend.private(req, res, failTest(done));
});
it('Should render theme password page when it exists', function (done) {
configUtils.set({paths: {availableThemes: {casper: {
'private.hbs': '/content/themes/casper/private.hbs'
}}}});
res.render = function (view) {
view.should.eql('private');
done();
};
frontend.private(req, res, failTest(done));
});
it('Should render with error when error is passed in', function (done) {
configUtils.set({paths: {availableThemes: {casper: {}}}});
res.error = 'Test Error';
res.render = function (view, context) {
view.should.eql(defaultPath);
context.should.eql({error: 'Test Error'});
done();
};
frontend.private(req, res, failTest(done));
});
});
describe('preview', function () {
var req, res, mockPosts = [{
posts: [{

View File

@ -151,70 +151,4 @@ describe('Middleware: spamPrevention', function () {
done();
});
});
describe('protected', function () {
var res;
beforeEach(function () {
res = sinon.spy();
req = {
connection: {
remoteAddress: '10.0.0.0'
},
body: {
password: 'password'
}
};
});
it ('sets an error when there is no password', function (done) {
req.body = {};
middleware.spamPrevention.protected(req, res, spyNext);
res.error.message.should.equal('No password entered');
spyNext.calledOnce.should.be.true();
done();
});
it ('sets and error message after 10 tries', function (done) {
var ndx;
for (ndx = 0; ndx < 10; ndx = ndx + 1) {
middleware.spamPrevention.protected(req, res, spyNext);
}
should.not.exist(res.error);
middleware.spamPrevention.protected(req, res, spyNext);
should.exist(res.error);
should.exist(res.error.message);
done();
});
it ('allows more tries after an hour', function (done) {
var ndx,
stub = sinon.stub(process, 'hrtime', function () {
return [10, 10];
});
for (ndx = 0; ndx < 11; ndx = ndx + 1) {
middleware.spamPrevention.protected(req, res, spyNext);
}
should.exist(res.error);
process.hrtime.restore();
stub = sinon.stub(process, 'hrtime', function () {
return [3610000, 10];
});
res = sinon.spy();
middleware.spamPrevention.protected(req, res, spyNext);
should.not.exist(res.error);
process.hrtime.restore();
done();
});
});
});