async = require 'async' mongoose = require('mongoose') Grid = require 'gridfs-stream' module.exports = class Handler # subclasses should override these properties modelClass: null editableProperties: [] postEditableProperties: [] jsonSchema: {} waterfallFunctions: [] # subclasses should override these methods hasAccess: (req) -> true hasAccessToDocument: (req, document, method=null) -> return true if req.user.isAdmin() if @modelClass.schema.uses_coco_permissions return document.hasPermissionsForMethod(req.user, method or req.method) return true formatEntity: (req, document) -> document?.toObject() getEditableProperties: (req, document) -> props = @editableProperties.slice() isBrandNew = req.method is 'POST' and not req.body.original if isBrandNew props = props.concat @postEditableProperties if @modelClass.schema.uses_coco_permissions # can only edit permissions if this is a brand new property, # or you are an owner of the old one isOwner = document.getAccessForUserObjectId(req.user._id) is 'owner' if isBrandNew or isOwner or req.user.isAdmin() props.push 'permissions' if @modelClass.schema.uses_coco_versions props.push 'commitMessage' props # sending functions sendUnauthorizedError: (res) -> @sendError(res, 403, "Unauthorized.") sendNotFoundError: (res) -> @sendError(res, 404, 'Resource not found.') sendMethodNotAllowed: (res) -> @sendError(res, 405, 'Method not allowed.') sendBadInputError: (res, message) -> @sendError(res, 422, message) sendDatabaseError: (res, err) -> @sendError(res, 500, 'Database error.') sendError: (res, code, message) -> console.warn "Sending an error code", code, message res.status(code) res.send(message) res.end() sendSuccess: (res, message) -> res.send(message) res.end() # generic handlers get: (req, res) -> # by default, ordinary users never get unfettered access to the database return @sendUnauthorizedError(res) unless req.user.isAdmin() # admins can send any sort of query down the wire, though conditions = JSON.parse(req.query.conditions || '[]') query = @modelClass.find() try for condition in conditions name = condition[0] f = query[name] args = condition[1..] query = query[name](args...) catch e return @sendError(res, 422, 'Badly formed conditions.') query.exec (err, documents) => return @sendDatabaseError(res, err) if err documents = (@formatEntity(req, doc) for doc in documents) @sendSuccess(res, documents) getById: (req, res, id) -> return @sendUnauthorizedError(res) unless @hasAccess(req) @getDocumentForIdOrSlug id, (err, document) => return @sendDatabaseError(res, err) if err return @sendNotFoundError(res) unless document? return @sendUnauthorizedError(res) unless @hasAccessToDocument(req, document) @sendSuccess(res, @formatEntity(req, document)) getByRelationship: (req, res, args...) -> # this handler should be overwritten by subclasses return @sendNotFoundError(res) search: (req, res) -> unless @modelClass.schema.uses_coco_search return @sendNotFoundError(res) project = {original:1, name:1, version:1, description: 1, slug:1} term = req.query.term matchedObjects = [] filters = [{filter: {index: true}}] if @modelClass.schema.uses_coco_permissions filters.push {filter: {index: req.user.get('id')}} for filter in filters callback = (err, results) => return @sendDatabaseError(res, err) if err for r in results.results ? results obj = r.obj ? r continue if obj in matchedObjects # TODO: probably need a better equality check matchedObjects.push obj filters.pop() # doesn't matter which one unless filters.length res.send matchedObjects res.end() if term filter.project = project if req.query.project @modelClass.textSearch term, filter, callback else args = [filter.filter] args.push filter.project if req.query.project @modelClass.find(args...).limit(100).exec callback versions: (req, res, id) -> # TODO: a flexible system for doing GAE-like cursors for these sort of paginating queries # Keeping it simple for now and just allowing access to the first 100 results. query = {'original': mongoose.Types.ObjectId(id)} sort = {'created': -1} @modelClass.find(query).limit(100).sort(sort).exec (err, results) => for doc in results return @sendUnauthorizedError(res) unless @hasAccessToDocument(req, doc) res.send(results) res.end() files: (req, res, id) -> module = req.path[4..].split('/')[0] query = {'metadata.path': "db/#{module}/#{id}"} Grid.gfs.collection('media').find query, (err, cursor) -> return @sendDatabaseError(res, err) if err results = cursor.toArray (err, results) -> return @sendDatabaseError(res, err) if err res.send(results) res.end() getLatestVersion: (req, res, original, version) -> # can get latest overall version, latest of a major version, or a specific version query = { 'original': mongoose.Types.ObjectId(original) } if version? version = version.split('.') majorVersion = parseInt(version[0]) minorVersion = parseInt(version[1]) query['version.major'] = majorVersion unless _.isNaN(majorVersion) query['version.minor'] = minorVersion unless _.isNaN(minorVersion) sort = { 'version.major': -1, 'version.minor': -1 } @modelClass.findOne(query).sort(sort).exec (err, doc) => return @sendNotFoundError(res) unless doc? return @sendUnauthorizedError(res) unless @hasAccessToDocument(req, doc) res.send(doc) res.end() patch: -> @put(arguments...) put: (req, res, id) -> return @postNewVersion(req, res) if @modelClass.schema.uses_coco_versions return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body) return @sendUnauthorizedError(res) unless @hasAccess(req) @getDocumentForIdOrSlug req.body._id or id, (err, document) => return @sendBadInputError(res, 'Bad id.') if err and err.name is 'CastError' return @sendDatabaseError(res, err) if err return @sendNotFoundError(res) unless document? return @sendUnauthorizedError(res) unless @hasAccessToDocument(req, document) @doWaterfallChecks req, document, (err, document) => return @sendError(res, err.code, err.res) if err @saveChangesToDocument req, document, (err) => return @sendBadInputError(res, err.errors) if err?.valid is false return @sendDatabaseError(res, err) if err @sendSuccess(res, @formatEntity(req, document)) post: (req, res) -> if @modelClass.schema.uses_coco_versions if req.body.original return @postNewVersion(req, res) else return @postFirstVersion(req, res) return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body) return @sendBadInputError(res, 'id should not be included.') if req.body._id return @sendUnauthorizedError(res) unless @hasAccess(req) validation = @validateDocumentInput(req.body) return @sendBadInputError(res, validation.errors) unless validation.valid document = @makeNewInstance(req) @saveChangesToDocument req, document, (err) => return @sendDatabaseError(res, err) if err @sendSuccess(res, @formatEntity(req, document)) ### TODO: think about pulling some common stuff out of postFirstVersion/postNewVersion into a postVersion if we can figure out the breakpoints? ..... actually, probably better would be to do the returns with throws instead and have a handler which turns them into status codes and messages ### postFirstVersion: (req, res) -> return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body) return @sendBadInputError(res, 'id should not be included.') if req.body._id return @sendUnauthorizedError(res) unless @hasAccess(req) validation = @validateDocumentInput(req.body) return @sendBadInputError(res, validation.errors) unless validation.valid document = @makeNewInstance(req) document.set('original', document._id) document.set('creator', req.user._id) @saveChangesToDocument req, document, (err) => return @sendBadInputError(res, err.response) if err?.response return @sendDatabaseError(res, err) if err @sendSuccess(res, @formatEntity(req, document)) postNewVersion: (req, res) -> """ To the client, posting new versions look like this: POST /db/modelname With the input being just the altered structure of the old version, leaving the _id property intact even. No version object means it's a new major version. A version object with a major value means a new minor version. All other properties in version are ignored. """ return @sendBadInputError(res, 'This entity is not versioned') unless @modelClass.schema.uses_coco_versions return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body) return @sendUnauthorizedError(res) unless @hasAccess(req) validation = @validateDocumentInput(req.body) return @sendBadInputError(res, validation.errors) unless validation.valid @getDocumentForIdOrSlug req.body._id, (err, parentDocument) => return @sendBadInputError(res, 'Bad id.') if err and err.name is 'CastError' return @sendDatabaseError(res, err) if err return @sendNotFoundError(res) unless parentDocument? return @sendUnauthorizedError(res) unless @hasAccessToDocument(req, parentDocument) updatedObject = parentDocument.toObject() changes = _.pick req.body, @getEditableProperties(req, parentDocument) _.extend updatedObject, changes delete updatedObject._id major = req.body.version?.major done = (err, newDocument) => return @sendDatabaseError(res, err) if err newDocument.set('creator', req.user._id) newDocument.save (err) => return @sendDatabaseError(res, err) if err @sendSuccess(res, @formatEntity(req, newDocument)) if major? parentDocument.makeNewMinorVersion(updatedObject, major, done) else parentDocument.makeNewMajorVersion(updatedObject, done) makeNewInstance: (req) -> new @modelClass({}) validateDocumentInput: (input) -> tv4 = require('tv4').tv4 res = tv4.validateMultiple(input, @jsonSchema) res getDocumentForIdOrSlug: (idOrSlug, done) -> idOrSlug = idOrSlug+'' try mongoose.Types.ObjectId.createFromHexString(idOrSlug) # throw error if not a valid ID (probably a slug) @modelClass.findById(idOrSlug).exec (err, document) -> done(err, document) catch e @modelClass.findOne {slug: idOrSlug}, (err, document) -> done(err, document) doWaterfallChecks: (req, document, done) -> return done(null, document) unless @waterfallFunctions.length # waterfall doesn't let you pass an initial argument # so wrap the first waterfall function to pass in the document funcs = (f for f in @waterfallFunctions) firstFunc = funcs[0] wrapped = (func, r, doc) -> (callback) -> func(r, doc, callback) funcs[0] = wrapped(firstFunc, req, document) async.waterfall funcs, (err, rrr, document) -> done(err, document) saveChangesToDocument: (req, document, done) -> for prop in @getEditableProperties(req, document) document.set(prop, req.body[prop]) if req.body[prop]? obj = document.toObject() # Hack to get saving of Users to work. Probably should replace these props with strings # so that validation doesn't get hung up on Date objects in the documents. delete obj.dateCreated validation = @validateDocumentInput(obj) return done(validation) unless validation.valid document.save (err) -> done(err)