diff --git a/lib/acl-schema.json b/lib/acl-schema.json index e402caf..2a33f58 100644 --- a/lib/acl-schema.json +++ b/lib/acl-schema.json @@ -3,30 +3,32 @@ "id": "/", "type": "array", "items": { - "id": "1", + "id": "items", "type": "object", "properties": { - "path": { - "id": "path", - "type": "string" + "role": { + "id": "role", + "type": "string", + "default": "public" }, - "roles": { - "id": "roles", + "paths": { + "id": "paths", "type": "array", "items": { - "id": "1", + "id": "items", "type": "object", "properties": { - "role": { - "id": "role", - "type": "string" + "path": { + "id": "path", + "type": "string", + "default": "/" }, "verbs": { "id": "verbs", "type": "array", "items": { - "id": "0", - "type": "string" + "type": "string", + "default": "GET" } } } diff --git a/lib/index.js b/lib/index.js index 7bca607..724ef1d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,7 @@ /*jshint -W121 */ 'use strict'; var assert = require('assert'); +var objectAssign = require('object-assign'); var debug = require('debug')('thalisalti:acl'); var fast = require('./indexOf'); @@ -8,9 +9,23 @@ var fast = require('./indexOf'); var NOTFOUND = -1; var IDTOKEN = '{:id}'; +var defaults = { + stripTrailingSlash: true +}; + + +function stripTrailingSlash(path) { + // single slash '/' is a valid path, it should be ignored + var lastIndex = path.length - 1; + if (lastIndex > 0 && path[lastIndex] === '/') { + debug('ignoring trailing slash for %s', path); + return path.substring(0, lastIndex); + } + return path; +} /** Gets the req object, collection of paths, and path to validate */ -function lookupPathVerb(req, paths, lookupPath) { +function lookupPathVerb(req, paths, path, lookupPath) { var pathExists = fast.indexOf(paths, lookupPath, 'path'); if (NOTFOUND === pathExists) { @@ -21,7 +36,7 @@ function lookupPathVerb(req, paths, lookupPath) { var verbExists = fast.indexOf(verbs, req.method); if (NOTFOUND === verbExists) { - debug('verb and path not found: req.Path: %s lookupPath: %s', req.path, lookupPath); + debug('verb and path not found: req.Path: %s lookupPath: %s', path, lookupPath); return false; } @@ -50,7 +65,7 @@ if (!String.prototype.startsWith) { -module.exports = function(dbname, inAcl, callback) { +module.exports = function(dbname, inAcl, callback, options) { if (!dbname || typeof dbname !== 'string') { throw new Error('invalid configuration - missing dbname'); @@ -59,13 +74,15 @@ module.exports = function(dbname, inAcl, callback) { if (!inAcl || !(inAcl instanceof Array)) { throw new Error('invalid configuration - inAcl is not an array'); } - + if(!callback || typeof callback !== 'function'){ throw new Error('invalid configuration - callback is NOT a function'); } + options = objectAssign({}, defaults, options); + var thaliPrefix = '/' + dbname + '/_local/thali_'; - + var acl = JSON.parse(JSON.stringify(inAcl).replace(/{:db}/g, dbname)); //path.role.verb @@ -75,8 +92,13 @@ module.exports = function(dbname, inAcl, callback) { var okPathVerb = false; debug('method: %s url: %s path: %s', req.method, req.url, req.path); + var path = req.path; + if (options.stripTrailingSlash) { + path = stripTrailingSlash(req.path); + } + if (!req.connection.pskRole) { - debug('unauthorized0: no role: %s ', req.path); + debug('unauthorized0: no role: %s ', path); return res.status(401).send(msg401); } @@ -86,14 +108,14 @@ module.exports = function(dbname, inAcl, callback) { var roleExists = fast.indexOf(acl, pskRole, 'role'); if (NOTFOUND === roleExists) { - debug('unauthorized1: %s : %s', pskRole, req.path); + debug('unauthorized1: %s : %s', pskRole, path); return res.status(401).send(msg401); } var paths = acl[roleExists].paths; //here we're doing a strict simple RAW path lookup on ACLs - var rawExists = fast.indexOf(paths, req.path, 'path'); + var rawExists = fast.indexOf(paths, path, 'path'); if (NOTFOUND !== rawExists) { //verbs are just an array of strings @@ -101,7 +123,7 @@ module.exports = function(dbname, inAcl, callback) { var verbExists = fast.indexOf(verbs, req.method); if (NOTFOUND === verbExists) { - debug('unauthorized3: %s : %s', pskRole, req.path); + debug('unauthorized3: %s : %s', pskRole, path); return res.status(401).send(msg401); } else { @@ -112,21 +134,21 @@ module.exports = function(dbname, inAcl, callback) { else { //this section deals with /db/id, /db/_local/id, and /db/id/attachment stuff. - var pathParts = req.path.split('/'); + var pathParts = path.split('/'); if (pathParts[0] !== '') { //sanity check.. the first element should always be empty. - debug('unauthorized4.0: %s : %s', pskRole, req.path); + debug('unauthorized4.0: %s : %s', pskRole, path); return res.status(401).send(msg401); } - var lookupPath = req.path.substring(0, req.path.lastIndexOf('/') + 1) + IDTOKEN; + var lookupPath = path.substring(0, path.lastIndexOf('/') + 1) + IDTOKEN; if (pathParts.length === 3) { //we have just a path and ID //do a search on /db/id and bail if NO GOOD - if (!lookupPathVerb(req, paths, lookupPath)) { - debug('unauthorized4.1: req.Path: %s req.path: %s', req.path, lookupPath); + if (!lookupPathVerb(req, paths, path, lookupPath)) { + debug('unauthorized4.1: req.Path: %s req.path: %s', path, lookupPath); return res.status(401).send(msg401); } else { @@ -138,19 +160,19 @@ module.exports = function(dbname, inAcl, callback) { //is this a /thali_ one? //TODO: - var isThaliPrefix = req.path.startsWith(thaliPrefix); - if (isThaliPrefix && ! lookupPathVerb(req, paths, thaliPrefix + IDTOKEN)){ - debug('unauthorized4.thaliPrefix: req.Path: %s path: %s', req.path, thaliPrefix); - return res.status(401).send(msg401); + var isThaliPrefix = path.startsWith(thaliPrefix); + if (isThaliPrefix && ! lookupPathVerb(req, paths, path, thaliPrefix + IDTOKEN)){ + debug('unauthorized4.thaliPrefix: req.Path: %s path: %s', path, thaliPrefix); + return res.status(401).send(msg401); } - - if (isThaliPrefix && !getThaliId(req.path, dbname, callback)) { - debug('unauthorized4.thaliCallback: req.Path: %s path: %s', req.path, thaliPrefix); + + if (isThaliPrefix && !getThaliId(path, dbname, callback)) { + debug('unauthorized4.thaliCallback: req.Path: %s path: %s', path, thaliPrefix); return res.status(401).send(msg401); } - if (!lookupPathVerb(req, paths, lookupPath)) { - debug('unauthorized4.2: req.Path: %s req.path: %s', req.path, lookupPath); + if (!lookupPathVerb(req, paths, path, lookupPath)) { + debug('unauthorized4.2: req.Path: %s req.path: %s', path, lookupPath); return res.status(401).send(msg401); } else { @@ -161,8 +183,8 @@ module.exports = function(dbname, inAcl, callback) { else if (pathParts.length === 4 && pathParts[3] === 'attachment') { var attachmentPath = '/' + pathParts[1] + '/' + IDTOKEN + '/attachment'; - if (!lookupPathVerb(req, paths, attachmentPath)) { - debug('unauthorized4.3: req.Path: %s req.path: %s', req.path, attachmentPath); + if (!lookupPathVerb(req, paths, path, attachmentPath)) { + debug('unauthorized4.3: req.Path: %s req.path: %s', path, attachmentPath); return res.status(401).send(msg401); } else { @@ -170,7 +192,7 @@ module.exports = function(dbname, inAcl, callback) { } } else { - debug('unauthorized5: %s : %s', pskRole, req.path); + debug('unauthorized5: %s : %s', pskRole, path); return res.status(401).send(msg401); } } diff --git a/package.json b/package.json index 6de3ba4..36f1293 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "homepage": "https://github.com/thaliproject/salti#readme", "dependencies": { + "object-assign": "^4.1.0", "debug": "^2.2.0" }, "devDependencies": { diff --git a/sample/acl-example.js b/sample/acl-example.js index f413e87..31d4172 100644 --- a/sample/acl-example.js +++ b/sample/acl-example.js @@ -2,36 +2,40 @@ //go here with the schema and build away... //http://jeremydorn.com/json-editor/ -//http://bit.ly/1Qs3l66 -//ideally - 1 less check if path has a trailing /.. -// ie. /foo/ is faster than /bar - -module.exports = [{ - "path": "/", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }, { - "role": "user", - "verbs": ["GET"] - }] -}, { - "path": "/foo/", - "roles": [{ - "role": "public", - "verbs": ["GET", "POST"] - }, { - "role": "user", - "verbs": ["GET", "PUR", "POST"] - }] -}, { - "path": "/bar", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }, { - "role": "user", - "verbs": ["GET"] - }] -}]; +module.exports = [ + { + 'role': 'public', + 'paths': [ + { + 'path': '/', + 'verbs': ['GET'] + }, + { + 'path': '/foo', + 'verbs': ['GET', 'POST'] + }, + { + 'path': '/bar', + 'verbs': ['GET'] + } + ] + }, + { + 'role': 'user', + 'paths': [ + { + 'path': '/', + 'verbs': ['GET'] + }, + { + 'path': '/foo', + 'verbs': ['GET', 'PUT', 'POST'] + }, + { + 'path': '/bar', + 'verbs': ['GET'] + } + ] + } +]; diff --git a/sample/app/guid.js b/sample/app/guid.js index d0b21e4..361c271 100644 --- a/sample/app/guid.js +++ b/sample/app/guid.js @@ -1,6 +1,6 @@ 'use strict'; -module.exports = function guid() { +function guid() { function s4() { return Math.floor((1 + Math.random()) * 0x10000) .toString(16) @@ -9,3 +9,10 @@ module.exports = function guid() { return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); } + +if (typeof module != 'undefined') { + module.exports = guid; +} +if (typeof angular != 'undefined') { + angular.module('myApp').constant('$guid', guid); +} diff --git a/sample/app/validate.js b/sample/app/validate.js index 70459e4..c5ec404 100644 --- a/sample/app/validate.js +++ b/sample/app/validate.js @@ -1,6 +1,6 @@ var myApp = angular.module('myApp', []); -myApp.controller('ValidateController', ['$scope', '$http', function ($scope, $http) { +myApp.controller('ValidateController', ['$scope', '$http', '$guid', function ($scope, $http, $guid) { $scope.greeting = 'Welcome to SALTI!'; $scope.errors = 'no errors'; $scope.messages = 'no messages'; @@ -26,10 +26,8 @@ myApp.controller('ValidateController', ['$scope', '$http', function ($scope, $ht }); } + function genDoc(){ + return JSON.stringify({ _id: $guid(), 'type': 'foobar' }) + } }]); - -function genDoc(){ - return JSON.stringify({ _id: guid(), "type": "foobar" }) -} - diff --git a/sample/fauxton.js b/sample/fauxton.js deleted file mode 100644 index 6585b5b..0000000 --- a/sample/fauxton.js +++ /dev/null @@ -1,91 +0,0 @@ -"use strict"; - -module.exports = [{ - "path": "/_utils", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }, { - "role": "user", - "verbs": ["GET"] - }] -}, - { - "path": "/db", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }] - }, - { - "path": "/_utils/css", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }] - }, - { - "path": "/_utils/js", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }] - }, { - "path": "/_utils/img", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }] - }, { - "path": "/_session", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }] - }, { - "path": "/_utils/fonts", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }] - }, { - "path": "/foobardb", - "roles": [{ - "role": "public", - "verbs": ["GET", "PUT", "POST"] - }] - }, { - "path": "/_all_dbs", - "roles": [{ - "role": "public", - "verbs": ["GET", "PUT", "POST"] - }] - }, { - "path": "/_utils/js/zeroclipboard", - "roles": [{ - "role": "public", - "verbs": ["GET", "PUT", "POST"] - }] - }, - { - "path": "/_uuids", - "roles": [{ - "role": "public", - "verbs": ["GET", "PUT", "POST"] - }] - }, - { - "path": "/favicon.ico", - "roles": [{ - "role": "public", - "verbs": ["GET", "PUT", "POST"] - }] - }]; - - -///_uuids - -///_all_dbs -// /_utils/js/zeroclipboard/Z -//db_utils -//db/css/ diff --git a/sample/pouchdb.js b/sample/pouchdb.js index d55316e..e5401e4 100644 --- a/sample/pouchdb.js +++ b/sample/pouchdb.js @@ -1,35 +1,86 @@ "use strict"; module.exports = [{ - "path": "/_validate", - "roles": [{ - "role": "public", - "verbs": ["GET", "PUT", "POST"] - }] - }, - { - "path": "/foobarrepl", - "roles": [{ - "role": "public", - "verbs": ["GET", "POST", "PUT"] - }] - }, - { - "path": "/foobarrepl/_local", - "roles": [{ - "role": "public", - "verbs": ["GET", "POST", "PUT"] - }] - }, { - "path": "/_session", - "roles": [{ - "role": "public", - "verbs": ["GET"] - }] - }, { - "path": "/_all_dbs", - "roles": [{ - "role": "public", - "verbs": ["GET", "PUT", "POST"] - }] - }]; + "role": "public", + "paths": [ + { + "path": "/_validate", + "verbs": ["GET", "POST", "PUT"] + }, + { + "path": "/_validate/_bulk_docs", + "verbs": ["GET", "POST", "PUT"] + }, + { + "path": "/_validate/_all_docs", + "verbs": ["GET", "POST", "PUT"] + }, + + { + "path": "/foobarrepl", + "verbs": ["GET", "POST", "PUT"] + }, + { + "path": "/foobarrepl/_local/{:id}", + "verbs": ["GET", "POST", "PUT"] + }, + { + "path": "/foobarrepl/_bulk_docs", + "verbs": ["GET", "POST", "PUT"] + }, + { + "path": "/foobarrepl/_revs_diff", + "verbs": ["GET", "POST", "PUT"] + }, + { + "path": "/foobarrepl/_all_docs", + "verbs": ["GET", "POST", "PUT"] + }, + + { + "path": "/foobar", + "verbs": ["GET", "POST", "PUT"] + }, + { + "path": "/foobar/_all_docs", + "verbs": ["GET", "POST", "PUT"] + }, + + { + "path": "/_utils", + "verbs": ["GET"] + }, + { + "path": "/_utils/css/index-6688cd4426ead40b085270f8cb007530.css", + "verbs": ["GET"] + }, + { + "path": "/_utils/js/require-34a7e370ea98389d118cc328682c0939.js", + "verbs": ["GET"] + }, + { + "path": "/_utils/img/pouchdb-site.png", + "verbs": ["GET"] + }, + { + "path": "/_utils/fonts/fontawesome-webfont.woff", + "verbs": ["GET"] + }, + { + "path": "/_utils/fonts/fauxtonicon.woff", + "verbs": ["GET"] + }, + { + "path": "/favicon.ico", + "verbs": ["GET"] + }, + { + "path": "/_all_dbs", + "verbs": ["GET"] + }, + { + "path": "/_session", + "verbs": ["GET"] + } + ] +}]; diff --git a/sample/routes/replicate.js b/sample/routes/replicate.js index 6c5f536..e4d9468 100644 --- a/sample/routes/replicate.js +++ b/sample/routes/replicate.js @@ -2,6 +2,7 @@ var express = require('express'); var router = express.Router(); var debug = require('debug')('thalisalti:replicate'); var http = require('http'); +var guid = require('../app/guid'); var pouchDBBase = require('pouchdb'); var PouchDB = pouchDBBase.defaults({ prefix: './db/' }); @@ -19,7 +20,7 @@ router.post('/', function (req, res, next) { var pouchDbOptions = { ajax : { agentOptions:{ rejectUnauthorized: false - } + } }}; var remoteDB = new PouchDB('http://localhost:3001/foobarrepl', pouchDbOptions) diff --git a/sample/routes/validate.js b/sample/routes/validate.js index 117ecd1..051dc5f 100644 --- a/sample/routes/validate.js +++ b/sample/routes/validate.js @@ -5,10 +5,10 @@ var router = express.Router(); var PouchDB = require('pouchdb'); var pbsetup = PouchDB.defaults({ prefix: './db/' }); -var pouchDbOptions = { ajax : { - agentOptions:{ - rejectUnauthorized: false - } +var pouchDbOptions = { ajax: { + agentOptions: { + rejectUnauthorized: false + } }}; @@ -22,30 +22,28 @@ router.post('/', function (req, res, next) { debug('invalid request on post - missing doc'); return res.status(400).send ({ error: 'invalid request - missing doc'}); } - + if (! req.body.secret) { debug('no secret provided - continuing...'); } - + var theDoc = JSON.parse (req.body.doc); - + debug('PUT a newdoc: ' + JSON.stringify(theDoc)); - + pouchDbOptions.ajax.headers = { 'User-Agent': 'request' - } - - var remoteDB = new PouchDB('http://localhost:3001/_validate', pouchDbOptions) - + }; + + var remoteDB = new PouchDB('http://localhost:3001/_validate', pouchDbOptions); remoteDB.put(theDoc, function (err, response) { if (err) { debug(err); - - return res.status(500).send( { error: err, message: 'failed to put new doc' }); + return res.status(500).send({ error: err, message: 'failed to put new doc' }); } - return res.status(200).send( response ); + return res.status(200).send(response); }); -}) +}); module.exports = router; diff --git a/sample/server.js b/sample/server.js index 785c753..543c6b6 100644 --- a/sample/server.js +++ b/sample/server.js @@ -3,12 +3,12 @@ var express = require('express'), http = require('http'), + fs = require('fs'), app = express(), PouchDB = require('pouchdb'), router = express.Router(), debug = require('debug')('thalisalti:express'); - //this is our sample UI site on port http://localhost:3000 // the 2 express apps: var webApp = require('./app'); @@ -21,7 +21,11 @@ webServer.listen(webAppPort, function () { }); webServer.on('error', onError); -var pbsetup = PouchDB.defaults({ prefix: './db/' }); +var pouchDBName = 'db'; +if (! fs.existsSync('./' + pouchDBName)){ + fs.mkdirSync('./' + pouchDBName); +} +var pbsetup = PouchDB.defaults({ prefix: './' + pouchDBName + '/' }); var pouchPort = normalizePort(process.env.PORT2 || '3001'); var opts = { @@ -33,8 +37,15 @@ var opts = { var acllib = require('../lib/index'); var acl = require('./pouchdb'); +//mocker.. +router.all('*', function(req, res, next) { + req.connection.pskRole = 'public'; + next(); +}); //Norml middleware usage.. -router.all('*', acllib(acl)); +router.all('*', acllib(pouchDBName, acl, function (thaliId) { + debug('thaliId %s', thaliId); +})); var pouchApp = require('express-pouchdb')(pbsetup, opts); @@ -44,6 +55,8 @@ app.use('/', router); app.listen(pouchPort); +// various utility functions... + function normalizePort(val) { var port = parseInt(val, 10); @@ -65,18 +78,18 @@ function onError(error, parent) { throw error; } - // var bind = typeof port === 'string' - // ? 'Pipe ' + port - // : 'Port ' + port; + var bind = typeof error.port === 'string' + ? 'Pipe ' + error.port + : 'Port ' + error.port; // handle specific listen errors with friendly messages switch (error.code) { case 'EACCES': - console.error('port requires elevated privileges'); + console.error(bind + ' requires elevated privileges'); process.exit(1); break; case 'EADDRINUSE': - console.error('port is already in use'); + console.error(bind + ' is already in use'); process.exit(1); break; default: diff --git a/sample/views/replicate.ejs b/sample/views/replicate.ejs index 7af808f..6969d77 100644 --- a/sample/views/replicate.ejs +++ b/sample/views/replicate.ejs @@ -6,7 +6,7 @@

Replication

-
View Fauxton Utils
+
View Fauxton Utils
Click the below to start an on-demand replication...
@@ -21,4 +21,4 @@
- \ No newline at end of file + diff --git a/sample/views/validate.ejs b/sample/views/validate.ejs index effb3d1..4a2c4bb 100644 --- a/sample/views/validate.ejs +++ b/sample/views/validate.ejs @@ -41,6 +41,7 @@ + - \ No newline at end of file + diff --git a/test/test-trailing-slash.js b/test/test-trailing-slash.js new file mode 100644 index 0000000..0c06297 --- /dev/null +++ b/test/test-trailing-slash.js @@ -0,0 +1,154 @@ +'use strict'; + +var request = require('supertest'), + express = require('express'), + fspath = require('path'), + colors = require('colors'), + assert = require('assert'); + +var lib = require(fspath.join(__dirname, '../lib/index')); + +function genericHandlers(router) { + var handlers = require('./handlers2'); + router.get('*', handlers.get); + return router; +} + +var acl = [{ + 'role': 'user', + 'paths': [ + { + 'path': '/', + 'verbs': ['GET'] + }, + { + 'path': '/foo', + 'verbs': ['GET'] + }, + { + 'path': '/bar/', + 'verbs': ['GET'] + } + ] +}]; + +describe('test-trailing-slash.js - should let all through', function() { + describe('strip trailing slash - true', function() { + var app, router; + app = express(); + router = express.Router(); + + before(function() { + router.all('*', function(req, res, next) { + req.connection.pskRole = 'user'; + next(); + }) + router.all('*', lib('foobar', acl, function(){}, { stripTrailingSlash: true })); + app.use('/', genericHandlers(router)); + }) + it('/ should be 200', function(done) { + request(app) + .get('/') + .expect(200, done); + }) + it('/\/ should be 200', function(done) { + request(app) + .get('/\/') + .expect(200, done); + }) + it('/%2F should be 401', function(done) { + request(app) + .get('/%2F') + .expect(401, done); + }) + it('/foo should be 200', function(done) { + request(app) + .get('/foo') + .expect(200, done); + }) + it('/foo/ should be 200', function(done) { + request(app) + .get('/foo/') + .expect(200, done); + }) + it('/foo%2F should be 401', function(done) { + request(app) + .get('/foo%2F') + .expect(401, done); + }) + it('/bar should be 401', function(done) { + request(app) + .get('/bar') + .expect(401, done); + }) + it('/bar/ should be 401', function(done) { + request(app) + .get('/bar/') + .expect(401, done); + }) + it('/bar%2F should be 401', function(done) { + request(app) + .get('/bar%2F') + .expect(401, done); + }) + }); + describe('strip trailing slash - false', function() { + var app, router; + app = express(); + router = express.Router(); + + before(function() { + router.all('*', function(req, res, next) { + req.connection.pskRole = 'user'; + next(); + }) + router.all('*', lib('foobar', acl, function(){}, { stripTrailingSlash: false })); + app.use('/', genericHandlers(router)); + }) + it('/ should be 200', function(done) { + request(app) + .get('/') + .expect(200, done); + }) + it('/\/ should be 401', function(done) { + request(app) + .get('/\/') + .expect(401, done); + }) + it('/%2F should be 401', function(done) { + request(app) + .get('/%2F') + .expect(401, done); + }) + it('/foo should be 200', function(done) { + request(app) + .get('/foo') + .expect(200, done); + }) + it('/foo/ should be 401', function(done) { + request(app) + .get('/foo/') + .expect(401, done); + }) + it('/foo%2F should be 401', function(done) { + request(app) + .get('/foo%2F') + .expect(401, done); + }) + it('/bar should be 401', function(done) { + request(app) + .get('/bar') + .expect(401, done); + }) + it('/bar/ should be 200', function(done) { + request(app) + .get('/bar/') + .expect(200, done); + }) + it('/bar%2F should be 401', function(done) { + request(app) + .get('/bar%2F') + .expect(401, done); + }) + }); +});