wheelhouse-resource
Version:
RESTful routes for wheelhouse with permissions and filtering.
541 lines (470 loc) • 18.5 kB
JavaScript
'use strict';
var Backbone
, _ = require('lodash')
, path = require('path')
, urlLib = require('url')
, http = require('http')
, https = require('https')
, SSE = require('sse')
, idPattern = '([._a-zA-Z0-9-%]+)'
, nameMap = {}
, app
, Resource = function(collection, options, callback){
var cb = !callback ? function(){} : callback
this.init(collection, options, cb)
}
, getCollectionName = function(url){
// ensure that we're only looking at the pathname
url = urlLib.parse(url).pathname
if (nameMap[url]) return nameMap[url]
// looks like url has some "noise" at the end
var urlParts = url.split('/')
// remove trailing slashes
while (urlParts.length > 0 && !urlParts[urlParts.length - 1]){
urlParts.pop()
}
do {
// do we have collection already?
url = urlParts.join('/')
if (nameMap[url]) return nameMap[url]
// no? pop one element then
urlParts.pop()
} while (urlParts.length > 0)
// if we can't find a url to map to, bail
return undefined
}
// wrapper for isPermissible becuase we're gonna want to do the same thing for all CRUD
, permissible = function(collOrModel){
if (this.isPermissible.call(this, collOrModel, this.req.body)) return true
app.log.warn('resource: permission:', this.req.method + ' permission denied'
, {url: this.req.url, user: (this.req.user ? this.req.user.id : null)}
)
this.res.writeHead(403, {'Content-Type': 'application/json'})
this.res.json({code: 403, message: 'Permission denied.'})
return false
}
Resource.prototype.init = function(collection, options, callback){
var coll
, collName
, self = this
if (!options.app.router)
throw new Error('Resource needs an app with a router instance')
app = options.app
Backbone = app.Backbone
// establish the collection
if (collection instanceof Backbone.Collection) coll = collection
else if (_.isString(collection) && app.datas[collection]) coll = app.datas[collection]
else if (_.isString(collection)) {
this.Collection = Backbone.Collection.extend({
url: path.join('/', collection)
})
coll = new this.Collection()
}
else {
throw new Error('Resource init needs a collection')
}
coll.resource = {}
coll.resource.permissions = options.permissions
coll.resource.pick = options.pick || function(m){return m}
coll.resource.filter = options.filter || function(c){return c}
coll.resource.nameRegEx = options.nameRegEx
coll.resource.hooks = options.hooks || {}
this.collection = coll
nameMap[_.result(coll, 'url')] = this.getName(coll, options.nameRegEx)
this.name = collName = getCollectionName(_.result(coll, 'url'))
this.options = options
if (!app.router) throw new Error('wheelhouse-resource needs app.router to exist before it can create new resources.')
app.router.attach(function(){
this.collections = this.collections || {}
this.collections[collName] = coll
if (!this.isPermissible) this.isPermissible = self.getPermissions.bind(this)
})
// get collection data
if (this.collection.length === 0) this.collection.fetch({
error: function(collection, err){
app.log.error('resource: fetch:' + collName, {collection: collName, err: new Error()})
if (_.isFunction(callback)) callback(err, collection)
}
, success: function(collection){
app.log.info('resource: found ' + collection.length + ' models for ' + collection.url)
if (_.isFunction(callback)) callback(null, collection)
}
})
else {
app.log.info('resource: found ' + collection.length +
' preexisting models for ' + collection.url + ', not fetching from db')
}
// Optionally let route assignment be disabled
if (options.assignRoutes !== false) this.assignRoutes()
}
Resource.prototype.assignRoutes = function(){
// SSE routes should be installed first, so that they wouldn't be overshadowed by common matches
this.sse()
// default '/:param' syntax doesn't work for urlencoded values, we we need to hack that.
// https://github.com/flatiron/director/pull/211
app.router.get(path.join('/', this.collection.url), this.read)
app.router.get(path.join('/', this.collection.url, '/' + idPattern), this.read)
app.router.post(path.join('/', this.collection.url), this.create)
app.router.put(path.join('/', this.collection.url, '/' + idPattern), this.update)
app.router['delete'](path.join('/', this.collection.url, '/' + idPattern), this.del)
// allow many connections for SSE
http.globalAgent.maxSockets = this.options.maxSockets || 1000
https.globalAgent.maxSockets = this.options.maxSockets || 1000
}
Resource.prototype.getName = function(collection, regex){
var url = _.result(collection, 'url')
, err = {
url: url
, regex: regex
, matches: null
, expecting: 'matches to be an array of regex matches'
}
, matches
if (regex){
matches = url.match(regex)
if (!_.isArray(matches)){
err.matches = matches
app.log.error('resource: init: cannot match name', err)
throw err
}
return matches[1]
}
else return url.substring(1)
}
// should be called in the context of a flatiron route (e.g. this.req, this.res)
Resource.prototype.getPermissions = function(collOrModel, data){
var methodMap = {
'POST': 'create'
, 'GET': 'read'
, 'PUT': 'update'
, 'DELETE': 'del'
}
, collection
, model
, permissions
// find the collection so we can pull of it's permissions
if (collOrModel) {
if (collOrModel.models) collection = collOrModel
else {
collection = collOrModel.collection
model = collOrModel
}
}
else collection = this.collections[getCollectionName(this.req.url)]
// no collection - no access
if (_.isUndefined(collection)) {
app.log.warn('resource: permissions: attempted to access resource with no collection', {resource: this.name})
return false
}
// if no permissions have been set, assume full access
if (_.isUndefined(collection.resource.permissions))
permissions = ['create', 'read', 'update', 'del']
// build permissions
else if (_.isFunction(collection.resource.permissions))
permissions = collection.resource.permissions.call(this, collection, data)
else permissions = collection.resource.permissions
// determine if this request is permissible
// simple permissions
if (_.isArray(permissions)){
if (permissions.indexOf(methodMap[this.req.method]) > -1) return true
else return false
}
// we didn't get anything back, so deny all permissions
else if (!_.isObject(permissions)) return false
// complex permissions
else return permissions[methodMap[this.req.method]].call(this, model ? model.toJSON() : collection.toJSON(), data)
}
Resource.prototype.sse = function(){
var self = this
, resource = self
, responder
, onConnection
// sends model or collection events over the SSE connection if permissible
responder = function(client, collectionOrModel, e){
if (!collectionOrModel || !e){
return app.log.warn('router: see: send: attempted to respond to a non-existant model or collection', {
event: e
, collectionOrModel: collectionOrModel
, url: client.req.url
})
}
client.ears.listenTo.call(this, collectionOrModel, e, function(model){
// ensure the user is allowed to see this model
var permissions = this.getPermissions.call(client, this.collection)
, meta = {resource: this.name}
if (model) meta.model = model.id
if (client.req.user) meta.user = client.req.user.id
app.log.debug('router: sse: send ' + e + ':', meta)
if (permissions === true || _.isArray(permissions) && _.find(permissions, function(permittedModel){
return model.id === permittedModel[model.idAttribute]
})) client.send(e, JSON.stringify(model.toJSON()))
/* TODO: this is kinda hacky, we always send destroys, even if they're not permitted to see them.
This isn't a huge deal because the event will only show ids, but… not super secure.
The reason we have to do this is b/c we're basing this off the model/collection destroy/remove event.
That means that the model is already removed from the collection
by the time complex permissions go to try to see if the model is permissible.
*/
else if (['destroy', 'remove'].indexOf(e) > -1)
client.send(e, JSON.stringify({id: model.id}))
})
}
// called after a SSE connection has been permitted
onConnection = function(collOrModel, events, client){
var timeStart = Date.now()
, keepAlive
app.log.debug('resource: sse: client connect:', {resource: this.name})
client.ears = {}
_.extend(client.ears, Backbone.Events)
events.forEach(function(e){
responder.call(this, client, collOrModel, e)
}.bind(this))
keepAlive = setInterval(function(){
// start with colon so that it's interpreted as a comment
// https://developer.mozilla.org/en-US/docs/Server-sent_events/Using_server-sent_events
client.send(':keepAlive')
app.log.debug('resource: sse: client keepAlive:', {
resource: this.name
, timeConnected: Date.now() - timeStart
, user: client.req.user ? client.req.user.id : null
})
}.bind(this), 20 * 1000)
// shim flatiron's res object which doesn't emit a close event.
client.res.response.once('close', function(){
client.emit('close')
})
client.on('close', function(){
client.ears.stopListening()
clearInterval(keepAlive)
app.log.debug('resource: sse: client disconnect:', {
resource: this.name
, timeConnected: Date.now() - timeStart
, user: client.req.user ? client.req.user.id : null
})
// ensure the client is destroyed.
// might be the cause of a memory leak?
client = null
}.bind(this))
}
// route for whole collection event subscription handling
app.router.get(path.join('/', this.collection.url, '/subscribe'), function(id){
var client
if (!permissible.call(this, resource.collection, {id: id})) return
client = new SSE.Client(this.req, this.res)
client.initialize()
onConnection.call(resource, resource.collection, ['add', 'change', 'remove'], client)
})
// setup route for model event subscription handling
app.router.get(path.join('/', this.collection.url, '/' + idPattern + '/subscribe'), function(id){
var model
, client
model = resource.collection.get(decodeURIComponent(id))
if (!permissible.call(this, model)) return
client = new SSE.Client(this.req, this.res)
client.initialize()
onConnection.call(resource, model, ['change', 'destroy'], client)
})
}
Resource.prototype.read = function(id){
var name = getCollectionName(this.req.url)
, collection = this.collections[name]
, permissibles = this.isPermissible.call(this)
, query = this.req.query
, done = function readHookCallback(modifiedCollection){
this.res.json((isModel && modifiedCollection ? modifiedCollection[0]: modifiedCollection) || returnSet)
}.bind(this)
, sendRes = function sendReadResponse(){
if (collection.resource.hooks.read){
collection.resource.hooks.read.call(this, isModel ? [returnSet] : returnSet, done)
}
else done()
}.bind(this)
, isModel
, model
, filteredSet
, picks
, omits
, where
, returnSet
if (query && query.pick) picks = query.pick.split(',')
if (query && query.omit) omits = query.omit.split(',')
if (query && query.whereKey && query.whereValue) {
where = {}
if (/^true$/.test(query.whereValue)) where[query.whereKey] = true
else if (/^false$/.test(query.whereValue)) where[query.whereKey] = false
else if (/[A-Za-z]/.test(query.whereValue)) where[query.whereKey] = query.whereValue
else {
where[query.whereKey] = query.whereValue.indexOf('.') > -1
? parseFloat(query.whereValue)
: parseInt(query.whereValue, 10)
}
}
// if isPermissible returned false, we don't have permission, bail
if (!permissibles) {
app.log.warn('resource: read: permission: permission denied:', {
collection: name
, user: this.req.user ? this.req.user.id : null
, url: this.req.url
})
this.res.writeHead(403)
this.res.json({code: 403, message: 'No permission for ' + name})
return
}
// single model requested
if (id && !_.isFunction(id)){
model = collection.get(decodeURIComponent(id))
isModel = true
if (!model) {
app.log.warn('resource: read: ' + name + ': Model ' + id + ' does not exist.')
this.res.writeHead(404)
this.res.json({code: 404, message: 'Model ' + id + ' does not exist.'})
}
else {
// if permissions has narrowed down the models we're allowed to present
// ensure the requested model is allowed
if (permissibles !== true) {
filteredSet = _.find(permissibles, function(m){
return m[model.idAttribute] === model.id
})
if (!filteredSet) {
app.log.warn('resource: read: permission: model requested, but permission denied', {
model: model.id
, user: this.req.user ? this.req.user.id : null
, url: this.req.url
})
this.res.writeHead(403)
this.res.json({code: 403, message: 'No permission for ' + model.id})
return
}
}
returnSet = collection.resource.pick.call(this, model.toJSON())
if (picks) returnSet = _.pick(returnSet, function(value, key){
return picks.indexOf(key) > -1
})
if (omits) returnSet = _.omit(returnSet, function(value, key){
return omits.indexOf(key) > -1
})
sendRes()
}
}
// collection requested
else {
// if permissions has narrowed down the models we're allowed to present, use that
filteredSet = permissibles === true
? collection.toJSON()
: permissibles
returnSet = collection.resource.filter.call(this, filteredSet)
if (picks) returnSet = returnSet.map(function(model){
return _.pick(model, function(value, key){
return picks.indexOf(key) > -1
})
})
if (omits) returnSet = returnSet.map(function(model){
return _.omit(model, function(value, key){
return omits.indexOf(key) > -1
})
})
if (where) {
returnSet = _.where(returnSet, where)
}
sendRes()
}
}
Resource.prototype.create = function(){
var name = getCollectionName(this.req.url)
, collection = this.collections[name]
, data = this.req.body
, createdModel
if (!permissible.call(this, collection)) return
createdModel = collection.create(data, {
error: function(model, err){
app.log.error('resource: create: ' + name + ':', {model: model, err: err, stack: new Error(err).stack})
this.res.writeHead(500)
this.res.json({code: 500, message: 'create error', model: model})
}.bind(this)
, success: function(model){
app.log.info('resource: create: ' + name + ':', {model: model.id})
this.res.writeHead(206)
this.res.json(_.pick(model.attributes, function(val, key){
return key.indexOf('_') === 0 || key === model.idAttribute
}))
}.bind(this)
, wait: true
})
// if the model fails to validate
if (createdModel.validationError) {
app.log.warn('resource: create: ' + name + ': validation error:', {
model: createdModel.id
, err: createdModel.validationError
, user: this.req.user ? this.req.user.id : null
})
this.res.writeHead(422)
this.res.json({code: 422, message: createdModel.validationError, model: createdModel.id})
}
}
Resource.prototype.update = function(rawId){
var name = getCollectionName(this.req.url)
, id = decodeURIComponent(rawId)
, model = this.collections[name].get(id)
, data = this.req.body
if (!permissible.call(this, model)) return
if (!model){
app.log.error('resource: update: ' + name + ': Model ' + id + ' does not exist.', new Error())
this.res.writeHead(404)
return this.res.json({code: 404, message: 'Model ' + id + ' does not exist.'})
}
/* set first, then save.
This is effectively what `wait: true` should do, but backbone-associations
doesn't set sub-values until after the save event if we just do a `wait: true`.
Doing a `set` and then a `save` manually fixes the problem.
https://github.com/dhruvaray/backbone-associations/issues/72
*/
model.set(data, {validate: true})
if (model.validationError) {
app.log.warn('resource: update: ' + name + ': validation error:', {
model: model.id
, err: model.validationError
, user: this.req.user ? this.req.user.id : null
})
this.res.writeHead(422)
this.res.json({code: 422, message: model.validationError, model: model.id})
}
else {
// `wait` is important so that we don't trigger events until we're sure the db has saved.
model.save(null, {
error: function(modelUpdated, err){
app.log.error('resource: update: ' + name + ':', {model: id, err: err})
this.res.writeHead(500)
this.res.json({code: 500, message: 'update error', model: modelUpdated})
}.bind(this)
, success: function(modelUpdated){
app.log.info('resource: update: ' + name + ':', {model: modelUpdated.id})
this.res.writeHead(206)
this.res.json(_.pick(model.attributes, function(val, key){
return key.indexOf('_') === 0 || key === model.idAttribute
}))
}.bind(this)
, wait: true
})
}
}
Resource.prototype.del = function(rawId){
var name = getCollectionName(this.req.url)
, id = decodeURIComponent(rawId)
, model = this.collections[name].get(id)
if (!permissible.call(this, model)) return
if (!model) return this.res.json({code: 404, message: 'Model ' + id + ' does not exist.'})
model.destroy({
error: function(modelUpdated, err){
app.log.error('resource: ' + name + ':', {model: id, err: err})
this.res.writeHead(500)
this.res.json({code: 500, message: 'delete error', model: modelUpdated})
}.bind(this)
, success: function(modelUpdated){
app.log.info('resource: delete: ' + name + ':', {model: modelUpdated.id})
this.res.writeHead(204)
this.res.json()
}.bind(this)
, wait: true
})
}
module.exports = Resource