diff --git a/bin/createtemplate/events/helpers.js b/bin/createtemplate/events/helpers.js new file mode 100644 index 0000000..cf40084 --- /dev/null +++ b/bin/createtemplate/events/helpers.js @@ -0,0 +1,20 @@ +/** + * Add any helpers you need in other events. + * This is useful to expose node modules to events + * or to reuse code between events. + */ + +// var request = require('request'); +// +// helpers.request = function (url, options, fn) { +// request(url, options, function (err, res, body) { +// if(err) throw err; +// +// if(res.statusCode >= 400) { +// fn(null, body); +// } else { +// fn(body); +// } +// }); +// } + diff --git a/bin/createtemplate/events/request.js b/bin/createtemplate/events/request.js new file mode 100644 index 0000000..10d6393 --- /dev/null +++ b/bin/createtemplate/events/request.js @@ -0,0 +1,17 @@ +/** + * Use the request event to define global business logic. + * This will execute whenever a resource is requested. + */ + +/** + * Use prevent() and allow() to change your default permissions. + * The following disables any unauthorized access to your app. + */ + +// only allow logged in users any permissions +// if(!me) { +// prevent('*'); +// } + +// with the logging in being the only exception +// allow('login'); \ No newline at end of file diff --git a/lib/context.js b/lib/context.js index 844877d..1617cd5 100644 --- a/lib/context.js +++ b/lib/context.js @@ -32,8 +32,21 @@ function Context(resource, req, res, server) { this.query = req.query || {}; this.server = server; this.session = req.session; + this.resource = resource; this.method = req && req.method; + if(resource && resource.getDefaultPermissions) { + this.permissions = resource.getDefaultPermissions(this); + } else { + this.permissions = {}; + } + + if(resource && resource.getRequiredPermissions) { + this.requiredPermissions = resource.getRequiredPermissions(this); + } else { + this.requiredPermissions = {}; + } + // always bind done to this var done = this.done; this.done = function() { @@ -111,4 +124,75 @@ Context.prototype.done = function(err, res) { } }; +/** + * Permissions + */ + +Context.prototype.allow = function (permission) { + var ctx = this; + + if(permission === '*' || permission === 'all') { + Object.keys(this.requiredPermissions).forEach(function (key) { + ctx.permissions[key] = true; + }); + } else { + ctx.permissions[permission] = true; + } +} + +Context.prototype.prevent = function (permission) { + var ctx = this; + + if(permission === '*' || permission === 'all') { + ctx.permissions = {}; + } else { + ctx.permissions[permission] = false; + } +} + +Context.prototype.isAllowed = function (permission) { + return !!this.permissions[permission]; +} + +Context.prototype.allowByDefault = function (permission) { + this.permissions[permission] = true; +} + +Context.prototype.requirePermission = function (permission) { + this.requiredPermissions[permission] = true; +} + +Context.prototype.verifyPermissions = function (fn) { + if(this.req.internal || this.req.isRoot) return fn(); + + var ctx = this; + var requiredKeys = Object.keys(this.requiredPermissions); + var requested = this.permissions; + var failed; + + if(requiredKeys.length) { + requiredKeys.forEach(function (permission) { + if(!requested[permission]) { + if(ctx.server.options.env === 'development') { + error('permission denied when ' + permission + ' - to allow this action, include `allow("'+ permission +'")` in an event script'); + } else { + error('permission denied when ' + permission); + } + } + }); + + if(!failed) { + fn(); + } + } else { + fn(); + } + + function error(msg) { + failed = true; + ctx.res.statusCode = 401; + ctx.done(new Error(msg)); + } +} + module.exports = Context; \ No newline at end of file diff --git a/lib/internal-resources/files.js b/lib/internal-resources/files.js index 7567ee5..0e9a3a9 100644 --- a/lib/internal-resources/files.js +++ b/lib/internal-resources/files.js @@ -61,13 +61,27 @@ Files.prototype.load = function(fn) { Files.prototype.handle = function (ctx, next) { if(ctx.req && ctx.req.method !== 'GET') return next(); - send(ctx.req, url.parse(ctx.url).pathname) - .root(path.resolve(this['public'])) - .on('error', function (err) { - ctx.res.statusCode = 404; - respond('Resource Not Found', ctx.req, ctx.res); - }) - .pipe(ctx.res); + ctx.verifyPermissions(function () { + send(ctx.req, url.parse(ctx.url).pathname) + .root(path.resolve(this['public'])) + .on('error', function (err) { + ctx.res.statusCode = 404; + respond('Resource Not Found', ctx.req, ctx.res); + }) + .pipe(ctx.res); + }.bind(this)); }; + +Files.prototype.getDefaultPermissions = function (ctx) { + return {'reading a file': true}; +} + +Files.prototype.getRequiredPermissions = function (ctx) { + // a resource should return an object defining + // required permissions for the given context + return {'reading a file': true}; +} + + module.exports = Files; \ No newline at end of file diff --git a/lib/internal-resources/internal-modules.js b/lib/internal-resources/internal-modules.js index a226820..a7ae05b 100644 --- a/lib/internal-resources/internal-modules.js +++ b/lib/internal-resources/internal-modules.js @@ -23,7 +23,8 @@ module.exports = Resource.extend("InternalModules", { exclude: { Collection: 1, UserCollection: 1, - internal: 1 + internal: 1, + 'global-events': 1 }, getModuleConfig: function(moduleId, fn) { diff --git a/lib/modules/collection/index.js b/lib/modules/collection/index.js index b0cd872..5a1e76c 100644 --- a/lib/modules/collection/index.js +++ b/lib/modules/collection/index.js @@ -37,49 +37,13 @@ function Collection(name, options) { 'creating an object': true, 'deleting an object by id': true, 'updating an object by id': true - }; - - this.requiredPermissions = { - 'GET': { - multi: { - 'querying multiple objects': true - }, - single: { - 'querying an object by id': true - } - }, - 'POST': { - multi: { - 'creating multiple objects': true - }, - single: { - 'creating an object': true - } - }, - 'PUT': { - multi: { - 'querying multiple objects': true, - 'updating multiple objects': true - }, - single: { - 'updating an object by id': true, - 'querying an object by id': true - } - }, - 'DELETE': { - multi: { - 'deleting multiple objects': true - }, - single: { - 'deleting an object by id': true - } - } - }; + }; } + util.inherits(Collection, Resource); Collection.prototype.external = {}; Collection.prototype.clientGeneration = true; -Collection.events = ['Get', 'Validate', 'Post', 'Put', 'Delete', 'Query']; +Collection.events = ['Get', 'Validate', 'Post', 'Put', 'Delete', 'Query', 'Request']; Collection.prototype.eventNames = ['Get', 'Validate', 'Post', 'Put', 'Delete']; Collection.prototype.dashboard = { @@ -205,6 +169,64 @@ Collection.prototype.sanitizeQuery = function (query) { return sanitized; }; +Collection.prototype.getRequiredPermissions = function (ctx) { + var requiredPermissions = {} + , hasId = !!(ctx.query.id || this.parseId(ctx) || (ctx.body && ctx.body.id)); + + if(hasId) { + requiredPermissions['querying an object by id'] = true; + } + + switch(ctx.method) { + case 'GET': + if(hasId) { + requiredPermissions['querying an object by id'] = true; + } else { + requiredPermissions['querying multiple objects'] = true; + } + break; + case 'POST': + // TODO ~ account for custom methods + if(Array.isArray(ctx.body)) { + requiredPermissions['creating multiple objects'] = true; + } else if(hasId) { + requiredPermissions['updating an object by id'] = true; + } else { + requiredPermissions['creating an object'] = true; + } + break; + case 'PUT': + if(hasId) { + requiredPermissions['updating an object by id'] = true; + requiredPermissions['querying an object by id'] = true; + } else { + requiredPermissions['querying multiple objects'] = true; + requiredPermissions['updating multiple objects'] = true; + } + break; + case 'DELETE': + if(hasId) { + requiredPermissions['deleting an object by id'] = true; + } else { + requiredPermissions['deleting multiple objects'] = true; + } + break; + } + + return requiredPermissions; +} + + +Collection.prototype.getDefaultPermissions = function (ctx) { + return { + 'querying multiple objects': true, + 'querying an object by id': true, + 'creating an object': true, + 'deleting an object by id': true, + 'updating an object by id': true + }; +} + /** * Handle an incoming http `req` and `res` and execute * the correct `Store` proxy function based on `req.method`. @@ -283,10 +305,10 @@ Collection.prototype.beforeQuery = function (ctx, fn) { queryScript.run(ctx, domain, function (err) { if(err) return ctx.done(err); - collection.verifyPermissions(ctx, fn); + ctx.verifyPermissions(fn); }); } else { - collection.verifyPermissions(ctx, fn); + ctx.verifyPermissions(fn); } } @@ -455,7 +477,7 @@ Collection.prototype.remove = function (ctx, fn) { function done(err) { if(err) return fn(err); - collection.verifyPermissions(ctx, function (err) { + ctx.verifyPermissions(function (err) { if(err) return fn(err); store.remove(sanitizedQuery, fn); @@ -593,7 +615,7 @@ Collection.prototype.save = function (ctx, fn) { return done(err || errors); } - collection.verifyPermissions(ctx, function (err) { + ctx.verifyPermissions(function (err) { if(err) { return done(err); } @@ -637,14 +659,14 @@ Collection.prototype.save = function (ctx, fn) { } if(err || domain.hasErrors()) return done(err || errors); debug('inserting item', item); - collection.verifyPermissions(ctx, function (err) { + ctx.verifyPermissions(function (err) { if(err) return done(err); store.insert(item, done); if(session && session.emitToAll) session.emitToAll(collection.name + ':changed'); }); }); } else { - collection.verifyPermissions(ctx, function (err) { + ctx.verifyPermissions(function (err) { if(err) return done(err); store.insert(item, done); if(session && session.emitToAll) session.emitToAll(collection.name + ':changed'); @@ -746,7 +768,7 @@ Collection.prototype.saveAll = function (ctx, fn) { function add(err) { if(err) return done(err); - collection.verifyPermissions(ctx, function (err) { + ctx.verifyPermissions(function (err) { if(err) return done(err); updateBatch.push(updated); @@ -780,24 +802,8 @@ Collection.prototype.saveAll = function (ctx, fn) { function createDomain(collection, ctx, data, errors) { var hasErrors = false; var domain = { - allow: function (permission) { - if(permission === '*') { - collection.forEachPermission('required', function (perm, event) { - ctx.permissions[perm] = true; - }); - } else { - ctx.permissions[permission] = true; - } - }, - prevent: function (permission) { - if(permission === '*') { - collection.forEachPermission('required', function (perm, type, event) { - ctx.permissions[perm] = false; - }); - } else { - delete ctx.permissions[permission]; - } - }, + allow: ctx.allow.bind(ctx), + prevent: ctx.prevent.bind(ctx), error: function(key, val) { debug('error %s %s', key, val); errors[key] = val || true; diff --git a/lib/modules/global-events.js b/lib/modules/global-events.js new file mode 100644 index 0000000..045f0b6 --- /dev/null +++ b/lib/modules/global-events.js @@ -0,0 +1,13 @@ +var Module = require('../module') + , path = require('path') + , Script = require('../script'); + +module.exports = Module.extend({ + + load: function(fn) { + var events = this.server.events = this.events = {}; + + Script.loaddir(path.join(this.server.options.dir, 'events'), events, fn); + } + +}); \ No newline at end of file diff --git a/lib/resource.js b/lib/resource.js index 3628688..000b5c9 100644 --- a/lib/resource.js +++ b/lib/resource.js @@ -158,68 +158,6 @@ Resource.prototype.parseEvent = function (ctx) { if(ctx.url) return ctx.url.split('/')[1]; } -Resource.prototype.verifyPermissions = function (ctx, fn) { - if(ctx.req.internal || ctx.req.isRoot) return fn(); - - var required = this.requiredPermissions[ctx.method || ctx.req.method]; - var requested = ctx.permissions || this.defaultPermissions; - var isSingle = !!ctx.query.id || ctx.method === 'POST'; - var failed = false; - - if(required) { - Object.keys(required[isSingle ? 'single' : 'multi']).forEach(function (permission) { - if(!requested[permission]) { - if(ctx.server.options.env === 'development') { - error('permission denied when ' + permission + ' - to allow this action, include `allow("'+ permission +'")` in an event script'); - } else { - error('permission denied when ' + permission); - } - } - }); - - if(!failed) { - fn(); - } - } else { - error('an unkown error has occured'); - } - - function error(msg) { - failed = true; - ctx.res.statusCode = 401; - ctx.done(new Error(msg)); - } -} - -Resource.prototype.setDefaultPermissions = function (ctx) { - if(ctx.permissions) return; - ctx.permissions = {}; - if(!this.defaultPermissions) return; - Object.keys(this.defaultPermissions).forEach(function (key) { - ctx.permissions[key] = true; - }); -} - -Resource.prototype.forEachPermission = function (type, fn) { - var resource = this; - - if(type === 'default') { - if(this.defaultPermissions) { - Object.keys(this.defaultPermissions).forEach(fn); - } - } else { - if(this.requiredPermissions) { - Object.keys(resource.requiredPermissions).forEach(function (event) { - Object.keys(resource.requiredPermissions[event]).forEach(function (type) { - Object.keys(resource.requiredPermissions[event][type]).forEach(function (permission) { - fn(permission, type, event); - }); - }); - }); - } - } -} - /** * Handle an incoming request. This gets called by the router. * Call `next()` if the resource cannot handle the request. @@ -264,8 +202,20 @@ Resource.prototype.handle = function (ctx, next) { } }; +Resource.prototype.getDefaultPermissions = function (ctx) { + // a resource should return an object defining + // default permissions for the given context + return {}; +} + +Resource.prototype.getRequiredPermissions = function (ctx) { + // a resource should return an object defining + // required permissions for the given context + return {}; +} + Resource.prototype.beforeHandle = function (ctx) { - this.setDefaultPermissions(ctx); + // hook } Resource.prototype.afterHandle = function (ctx) { diff --git a/lib/router.js b/lib/router.js index 0bc9db5..ce5e4e4 100644 --- a/lib/router.js +++ b/lib/router.js @@ -73,16 +73,23 @@ Router.prototype.route = function (req, res) { // default root to false if(ctx.session) ctx.session.isRoot = req.isRoot || false; - - // external functions - var furl = ctx.url.replace('/', ''); - if(resource.external && resource.external[furl]) { - resource.external[furl](ctx.body, ctx, ctx.done); - } else { - resource.beforeHandle(ctx); - resource.handle(ctx, nextResource); - resource.afterHandle(ctx); - } + + router.runRequestEvent(ctx, function (err) { + if(err) { + ctx.done(err); + return; + } + + // external functions + var furl = ctx.url.replace('/', ''); + if(resource.external && resource.external[furl]) { + resource.external[furl](ctx.body, ctx, ctx.done); + } else { + resource.beforeHandle(ctx); + resource.handle(ctx, nextResource); + resource.afterHandle(ctx); + } + }); } else { debug('404 %s', req.url); res.statusCode = 404; @@ -94,6 +101,34 @@ Router.prototype.route = function (req, res) { }; +/** + * Run the global request event using the given context. + */ + +Router.prototype.runRequestEvent = function (ctx, fn) { + var domain = { + prevent: ctx.prevent.bind(ctx), + allow: ctx.allow.bind(ctx), + url: ctx.req.url + }; + + if(this.server.events && this.server.events.request) { + this.server.events.request.run(ctx, domain, resource); + } else { + resource(); + } + + function resource(err) { + if(err) return fn(err); + + if(ctx.resource && ctx.resource.events && ctx.resource.events.request) { + ctx.resource.events.request.run(ctx, domain, fn); + } else { + fn(); + } + } +} + /** * Get resources whose base path matches the incoming URL, and order by specificness. diff --git a/lib/script.js b/lib/script.js index cafe770..c89d046 100644 --- a/lib/script.js +++ b/lib/script.js @@ -140,7 +140,7 @@ Script.loaddir = function (configPath, events, fn) { fs.readdir(configPath, function (err, files) { if(err) { - console.error(err); + // console.error(err); return fn(); } diff --git a/lib/server.js b/lib/server.js index c0a9de1..a29d05d 100644 --- a/lib/server.js +++ b/lib/server.js @@ -57,7 +57,8 @@ function Server(options) { // defaults this.options = options = extend({ port: 2403, - db: {port: 27017, host: '127.0.0.1', name: 'deployd'} + db: {port: 27017, host: '127.0.0.1', name: 'deployd'}, + dir: process.cwd() }, options); debug('started with options %j', options); diff --git a/lib/start.js b/lib/start.js index 853561e..0b97836 100644 --- a/lib/start.js +++ b/lib/start.js @@ -3,6 +3,8 @@ var Server = require('./server') , Monitor = require('./monitor') , commands = {}; + require('longjohn'); + /** * Commands exposed to parent process. */ diff --git a/test-app/events/request.js b/test-app/events/request.js new file mode 100644 index 0000000..e69de29 diff --git a/test/collection.unit.js b/test/collection.unit.js index ccda029..3337081 100644 --- a/test/collection.unit.js +++ b/test/collection.unit.js @@ -1,5 +1,23 @@ var Collection = require('../lib/modules/collection') - , db = require('../lib/db'); + , db = require('../lib/db') + , Context = require('../lib/context'); + + +function createMockContext(options) { + options.query = options.query || {}; + options.path = options.path || '/'; + options.req.url = options.req.url || '/'; + var ctx = new Context({path: options.path}, options.req, options.res, {}); + ctx.session = options.session || {}; + ctx.done = function () { + options.res.end(); + } + + Object.keys(options).forEach(function (key) { + ctx[key] = options[key]; + }); + return ctx; +} describe('collection', function(){ function createCollection(properties) { @@ -97,7 +115,7 @@ describe('collection', function(){ // faux body req.body = body; req.query = query; - c.handle({req: req, res: res, query: query || {}, session: {}, done: function() {res.end();}}); + c.handle(createMockContext({path: path, req: req, res: res, query: query})); }, function (req, res) { test(req, res, method, path, properties, body, query); // cleanup @@ -171,7 +189,7 @@ describe('collection', function(){ it('should save the provided data', function(done) { var c = new Collection('counts', {db: db.create(TEST_DB), config: { properties: {count: {type: 'number'}}}}); - c.save({session: {}, body: {count: 1}, query: {}, dpd: {}, req: {}, res: {}, done: done, method: 'POST'}, function (err, item) { + c.save(createMockContext({session: {}, body: {count: 1}, query: {}, dpd: {}, req: {}, res: {}, done: done, method: 'POST'}), function (err, item) { expect(item.id).to.exist; expect(err).to.not.exist; done(); @@ -181,10 +199,10 @@ describe('collection', function(){ it('should pass commands like $inc', function(done) { var c = new Collection('counts', {db: db.create(TEST_DB), config: { properties: {count: {type: 'number'}}}}); - c.save({body: {count: 1}, req: {}, res: {}, method: 'POST', query: {}}, function (err, item) { + c.save(createMockContext({body: {count: 1}, req: {}, res: {}, method: 'POST', query: {}}), function (err, item) { expect(item.id).to.exist; expect(err).to.not.exist; - c.save({body: {count: {$inc: 100}}, query: {id: item.id}, req: {}, res: {}, done: done, method: 'PUT'}, function (err, updated) { + c.save(createMockContext({body: {count: {$inc: 100}}, query: {id: item.id}, req: {}, res: {}, done: done, method: 'PUT'}), function (err, updated) { expect(err).to.not.exist; expect(updated).to.exist; expect(updated.count).to.equal(101); @@ -209,7 +227,7 @@ describe('collection', function(){ it('should return the provided data', function(done) { var c = new Collection('foo', {db: db.create(TEST_DB), config: { properties: {count: {type: 'number'}}}}); - c.save({body: {count: 1}, query: {}, req: {}, res: {}, done: done, method: 'POST'}, function (err, item) { + c.save(createMockContext({body: {count: 1}, query: {}, req: {}, res: {}, done: done, method: 'POST'}), function (err, item) { c.find({query: {}, req: {}, res: {}, done: done, method: 'GET'}, function (err, items) { expect(items.length).to.equal(1); done(err); @@ -220,10 +238,10 @@ describe('collection', function(){ it('should return the provided data in sorted order', function(done) { var c = new Collection('sort', { db: db.create(TEST_DB), config: { properties: {count: {type: 'number'}}}}); - c.save({body: {count: 1}, query: {}, req: {}, res: {}, done: done, method: 'POST'}, function (err, item) { - c.save({body: {count: 3}, query: {}, req: {}, res: {}, done: done, method: 'POST'}, function (err, item) { - c.save({body: {count: 2}, query: {}, req: {}, res: {}, done: done, method: 'POST'}, function (err, item) { - c.find({query: {$sort: {count: 1}}, req: {}, res: {}, done: done, method: 'GET'}, function (err, items) { + c.save(createMockContext({body: {count: 1}, query: {}, req: {}, res: {}, done: done, method: 'POST'}), function (err, item) { + c.save(createMockContext({body: {count: 3}, query: {}, req: {}, res: {}, done: done, method: 'POST'}), function (err, item) { + c.save(createMockContext({body: {count: 2}, query: {}, req: {}, res: {}, done: done, method: 'POST'}), function (err, item) { + c.find(createMockContext({query: {$sort: {count: 1}}, req: {}, res: {}, done: done, method: 'GET'}), function (err, items) { expect(items.length).to.equal(3); for(var i = 0; i < 3; i++) { delete items[i].id; diff --git a/test/config-loader.unit.js b/test/config-loader.unit.js index c957179..a3140b6 100644 --- a/test/config-loader.unit.js +++ b/test/config-loader.unit.js @@ -37,8 +37,8 @@ describe('config-loader', function() { if (err) return done(err); var resources = result.resources; expect(resources).to.have.length(7); - expect(resources.filter(function(r) { return r.name == 'foo';})).to.have.length(1); - expect(resources.filter(function(r) { return r.name == 'bar';})).to.have.length(1); + expect(resources.filter(function(r) { return r.name == 'foo'; })).to.have.length(1); + expect(resources.filter(function(r) { return r.name == 'bar'; })).to.have.length(1); done(); }); });