UNPKG

forerunnerdb

Version:

A NoSQL document store database for browsers and Node.js.

920 lines (788 loc) 27.5 kB
"use strict"; // NOTE: This class instantiates individually but shares a single // http server after listen() is called on any instance // Import external names locally var Shared = require('./Shared'), express = require('express'), bodyParser = require('body-parser'), cors = require('cors'), async = require('async'), fs = require('fs'), http = require('http'), https = require('https'), url = require('url'), pem = require('pem'), app = express(), server, Core, CoreInit, Db, NodeApiServer, ReactorIO, Overload, _access = {}, _accessOrder = [], _io = {}; /** * The NodeApiServer class. * @class * @constructor */ NodeApiServer = function () { this.init.apply(this, arguments); }; /** * The init method that can be overridden or extended. * @param {Core} core The ForerunnerDB core instance. */ NodeApiServer.prototype.init = function (core) { var self = this; self._core = core; self._app = app; this.name('ApiServer'); this.rootPath('/fdb'); }; Shared.addModule('NodeApiServer', NodeApiServer); Shared.mixin(NodeApiServer.prototype, 'Mixin.Common'); Shared.mixin(NodeApiServer.prototype, 'Mixin.Events'); Shared.mixin(NodeApiServer.prototype, 'Mixin.ChainReactor'); Core = Shared.modules.Core; CoreInit = Core.prototype.init; Db = Shared.modules.Db; ReactorIO = Shared.modules.ReactorIO; Overload = Shared.overload; Shared.synthesize(NodeApiServer.prototype, 'name'); Shared.synthesize(NodeApiServer.prototype, 'rootPath', function (val) { if (val !== undefined && server) { throw('Cannot set rootPath of server after start() has been called!'); } if (val !== undefined && !server) { this._rootSectionCount = (val.split('/').length - 1); if (this._rootSectionCount < 0) { this._rootSectionCount = 0; } } return this.$super.call(this, val); }); /** * Starts the rest server listening for requests against the ip and * port number specified. * @param {String} host The IP address to listen on, set to 0.0.0.0 to * listen on all interfaces. * @param {String} port The port to listen on. * @param {Object} options An options object. * @param {Function=} callback The method to call when the server has * started (or failed to start). * @returns {NodeApiServer} */ NodeApiServer.prototype.start = function (host, port, options, callback) { var self = this; // Start listener if (!server) { this._startData = { host: host, port: port, options: options, callback: callback }; if (options && options.cors === true) { // Enable cors middleware app.use(cors({origin: true})); // Allow preflight CORS app.options('*', cors({origin: true})); } // Parse body in requests app.use(bodyParser.json()); // Parse JSON as a query parameter app.use(function (req, res, next) { var query, jsonData, urlObj = url.parse(req.url); req.json = req.json || {}; if (urlObj.query) { try { // Try to parse the query parameter as URI encoded query = urlObj.query; query = decodeURIComponent(query); if (query) { // We got a query param, try to decode as JSON jsonData = JSON.parse(decodeURIComponent(query)); // Now copy any fields from jsonData to req.json Shared.mixin(req.json, jsonData); } } catch (e) { // Check for normal query params if (req.query && Object.keys(req.query).length > 0) { return next(); } else { res.status(500).send('Error parsing query string ' + query + ' ' + e); return; } } } return next(); }); // Activate routes this._defineRoutes(); self._createHttpServer(options, function (err, httpServer) { if (!err) { server = httpServer.listen(port, host, function (err) { if (!err) { if (options && options.ssl && options.ssl.enable) { console.log('ForerunnerDB REST API listening at https://%s:%s/fdb', host, port); } else { console.log('ForerunnerDB REST API listening at http://%s:%s/fdb', host, port); } if (callback) { callback(false, server); } self.emit('started'); } else { console.log('Listen error', err); callback(err); } }); } }); } else { // Server already running if (callback) { callback(false, server); } } return this; }; /** * Gets the express app (router). * @returns {*} */ NodeApiServer.prototype.serverApp = function () { return app; }; /** * Gets the express library. * @returns {*|exports|module.exports} */ NodeApiServer.prototype.express = function () { return express; }; NodeApiServer.static = function (urlPath, folderPath) { return app.use(urlPath, express.static(folderPath)); }; /** * Creates the http(s) server instance. * @param options * @param callback * @private */ NodeApiServer.prototype._createHttpServer = function (options, callback) { var self = this, ssl, httpServer, i; if (options && options.ssl && options.ssl.enable) { // Check if we were provided certificate data if (options.ssl.key && options.ssl.cert) { ssl = { key: fs.readFileSync(options.ssl.key), cert: fs.readFileSync(options.ssl.cert) }; if (options.ssl.ca) { ssl.ca = []; for (i = 0; i < options.ssl.ca.length; i++) { ssl.ca.push(fs.readFileSync(options.ssl.ca[i])); } } httpServer = https.createServer(ssl, app); callback(false, httpServer); } else { // Check for existing certs we have already generated fs.stat('./forerunnerdbTempCert/privkey.pem', function (err, stat) { if (!err) { fs.stat('./forerunnerdbTempCert/fullchain.pem', function (err, stat) { if (!err) { ssl = { key: fs.readFileSync('./forerunnerdbTempCert/key.pem'), cert: fs.readFileSync('./forerunnerdbTempCert/cert.pem') }; httpServer = https.createServer(ssl, app); callback(false, httpServer); } else { self._generateSelfCertHttpsServer(callback); } }); } else { self._generateSelfCertHttpsServer(callback); } }); } } else { httpServer = http.createServer(app); callback(false, httpServer); } }; /** * Uses PEM module to generate a self-signed SSL certificate and creates * an https server from it, returning it in the callback. * @param callback * @private */ NodeApiServer.prototype._generateSelfCertHttpsServer = function (callback) { var ssl, httpServer; // Generate our own self-signed cert for easy use of https prototyping pem.createCertificate({ days: 1, selfSigned: true }, function(err, keys) { if (!err) { // Write self-signed cert data so we can re-use it fs.writeFile('./forerunnerdbTempCert/key.pem', keys.serviceKey, function () {}); fs.writeFile('./forerunnerdbTempCert/cert.pem', keys.certificate, function () {}); // Assign data to the ssl object ssl = { key: keys.serviceKey, cert: keys.certificate }; httpServer = https.createServer(ssl, app); callback(false, httpServer); } else { throw('Cannot enable SSL, generating a self-signed certificate failed: ' + err); } }); }; /** * Stops the server listener. */ NodeApiServer.prototype.stop = function () { if (server) { server.close(); server = undefined; this.emit('stopped'); return true; } return false; }; /** * Handles requests from clients to the endpoints the database exposes. * @param req * @param res */ NodeApiServer.prototype.handleRequest = function (req, res) { var self = this, method = req.method, dbName = req.params.dbName, objType = req.params.objType, objName = req.params.objName, objId = req.params.objId, query, options, db, obj, urlPath, pathSections; if (self.debug && self.debug()) { console.log(self.logIdentifier() + ' Received REST: ' + method + ' ' + req.url); } // Check permissions self.hasPermission(dbName, objType, objName, method, req, function (err, results) { if (!err) { if (self.debug && self.debug()) { console.log(self.logIdentifier() + ' REST call is allowed: ' + method + ' ' + req.url); } // Check if the database has this type of object // TODO: Do we want to call collectionExists (objType + 'Exists') here? if (typeof self._core.db(dbName)[objType] === 'function') { // Get the query and option params query = req.json && req.json.$query ? req.json.$query : undefined; options = req.json && req.json.$options ? req.json.$options : undefined; db = self._core.db(dbName); obj = db[objType](objName); // Check the object type for special considerations if (objType === 'procedure') { if (obj && obj.exec && typeof obj.exec === 'function') { // Handle procedure if (self.debug && self.debug()) { console.log(self.logIdentifier() + ' REST executing procedure: ' + method + ' ' + req.url); } return obj.exec(req, res); } else { if (self.debug && self.debug()) { console.log(self.logIdentifier() + ' REST procedure not found: ' + method + ' ' + req.url); } return res.status(404).send('Procedure "' + objName + '" not found!'); } } // Get url path urlPath = url.parse(req.url).pathname; // Split path into sections pathSections = urlPath.split('/'); if (self._rootPath) { // Remove the first 5 sections as we already used these (rootPath, dbName, objType, objName, itemId) pathSections.splice(0, 4 + self._rootSectionCount); } else { // Remove the first 4 sections as we already used these (dbName, objType, objName, itemId) pathSections.splice(0, 4); } switch (method) { case 'HEAD': case 'GET': if (obj.isProcessingQueue && obj.isProcessingQueue()) { if (db.debug()) { console.log(db.logIdentifier() + ' Waiting for async queue: ' + objName); } obj.once('ready', function () { if (db.debug()) { console.log(db.logIdentifier() + ' Async queue complete: ' + objName); } self._handleResponse(req, res, dbName, objType, objName, obj, pathSections, obj.find(query, options)); }); } else { self._handleResponse(req, res, dbName, objType, objName, obj, pathSections, obj.find(query, options)); } break; case 'POST': if (obj.isProcessingQueue && obj.isProcessingQueue()) { if (db.debug()) { console.log(db.logIdentifier() + ' Waiting for async queue: ' + objName); } obj.once('ready', function () { if (db.debug()) { console.log(db.logIdentifier() + ' Async queue complete: ' + objName); } obj.insert(req.body, function (result) { res.send(result); }); }); } else { obj.insert(req.body, function (result) { res.send(result); }); } break; case 'PUT': if (obj.isProcessingQueue && obj.isProcessingQueue()) { if (db.debug()) { console.log(db.logIdentifier() + ' Waiting for async queue: ' + objName); } obj.once('ready', function () { if (db.debug()) { console.log(db.logIdentifier() + ' Async queue complete: ' + objName); } res.send(obj.updateById(objId, {$replace: req.body})); }); } else { res.send(obj.updateById(objId, {$replace: req.body})); } break; case 'PATCH': if (obj.isProcessingQueue && obj.isProcessingQueue()) { if (db.debug()) { console.log(db.logIdentifier() + ' Waiting for async queue: ' + objName); } obj.once('ready', function () { if (db.debug()) { console.log(db.logIdentifier() + ' Async queue complete: ' + objName); } res.send(obj.updateById(objId, req.body)); }); } else { res.send(obj.updateById(objId, req.body)); } break; case 'DELETE': if (!objId) { // Remove all if (obj.isProcessingQueue && obj.isProcessingQueue()) { if (db.debug()) { console.log(db.logIdentifier() + ' Waiting for async queue: ' + objName); } obj.once('ready', function () { if (db.debug()) { console.log(db.logIdentifier() + ' Async queue complete: ' + objName); } res.send(obj.remove(query, options)); }); } else { res.send(obj.remove(query, options)); } } else { // Remove one if (obj.isProcessingQueue && obj.isProcessingQueue()) { if (db.debug()) { console.log(db.logIdentifier() + ' Waiting for async queue: ' + objName); } obj.once('ready', function () { if (db.debug()) { console.log(db.logIdentifier() + ' Async queue complete: ' + objName); } res.send(obj.removeById(objId, options)); }); } else { res.send(obj.removeById(objId, options)); } } break; default: res.status(403).send('Unknown method'); break; } if (self.debug && self.debug()) { console.log(self.logIdentifier() + ' REST call finished: ' + method + ' ' + req.url); } } else { if (self.debug && self.debug()) { console.log(self.logIdentifier() + ' REST call object type unknown: ' + method + ' ' + req.url); } res.status(500).send('Unknown object type: ' + objType); } } else { if (self.debug && self.debug()) { console.log(self.logIdentifier() + ' REST call permission denied: ' + method + ' ' + req.url); } res.status(403).send(err); } }); }; NodeApiServer.prototype._handleResponse = function (req, res, dbName, objType, objName, obj, pathSections, responseData) { var self = this, pathSection, tmpParts, pathIdField = obj._primaryKey !== undefined ? obj._primaryKey : '_id', i; // Check if we need to generate a sub-document query if (pathSections.length) { // Get the next path section pathSection = pathSections.shift(); if (responseData instanceof Array) { // Set the id field to default //pathIdField = '_id'; // Check if the patch section has a colon in it, denoting a search field and value if (pathSection.indexOf(':') > -1) { // Split the path section tmpParts = pathSection.split(':'); pathIdField = tmpParts[0]; pathSection = tmpParts[1]; } // The path section must correspond to an id in the array of items for (i = 0; i < responseData.length; i++) { if (responseData[i][pathIdField] === pathSection) { return self._handleResponse(req, res, dbName, objType, objName, obj, pathSections, responseData[i]); } } // Could not find a matching document, send nothing back return self._sendResponse(req, res); } // Grab the document sub-data return self._handleResponse(req, res, dbName, objType, objName, obj, pathSections, responseData[pathSection]); } else { return self._sendResponse(req, res, responseData); } }; NodeApiServer.prototype._sendResponse = function (req, res, data) { var statusCode = data !== undefined ? 200 : 404; switch (req.method) { case 'HEAD': // Only send status codes - head is a unique case in that a // blank data string means we should send 204 (no content) // not 200, and if undefined we can still send 404 if (data !== undefined) { res.sendStatus(204); } else { res.sendStatus(404); } break; case 'GET': // Send status code and data res.status(statusCode).send(data); break; default: res.send(data); break; } }; /** * Handles client requests to open an EventSource connection to our * server-sent events server. * @param req * @param res */ NodeApiServer.prototype.handleSyncRequest = function (req, res) { var self = this, dbName = req.params.dbName, objType = req.params.objType, objName = req.params.objName, db, obj; // Check permissions self.hasPermission(dbName, objType, objName, "SYNC", req, function (err, results) { if (!err) { // Check if the database has this type of object // TODO: Do we want to call collectionExists (objType + 'Exists') here? if (typeof self._core.db(dbName)[objType] === 'function') { db = self._core.db(dbName); obj = db[objType](objName); // Let request last as long as possible req.socket.setTimeout(0x7FFFFFFF); // Ensure we have basic IO objects set up _io[objType] = _io[objType] || {}; _io[objType][objName] = _io[objType][objName] || { messageId: 0, clients: [] }; // Add this resource object the io clients array _io[objType][objName].clients.push({req: req, res: res}); // Check if we already have an io for this object if (!_io[objType][objName].io) { // Setup a chain reactor IO node to intercept CRUD packets // coming from the object (collection, view etc), and then // pass them to the clients _io[objType][objName].io = new ReactorIO(obj, self, function (chainPacket) { self.sendToAll(_io[objType][objName], chainPacket.type, chainPacket.data); // Returning false informs the chain reactor to continue propagation // of the chain packet down the graph tree return false; }); } req.socket.setNoDelay(true); // Send headers for event-stream connection res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); res.write(':ok\n\n'); // Send connected message to the client with messageId zero self.sendToClient(res, 0, 'connected', "{}"); req.on("close", function() { // Remove this client from the array var index = _io[objType][objName].clients.indexOf(res); if (index > -1) { _io[objType][objName].clients.splice(index, 1); } }); } else { res.status(500).send('Unknown object type: ' + objType); } } else { res.status(403).send(err); } }); }; /** * Sends server-sent-events message to all connected clients that are listening * to the changes in the IO that is passed. * @param io * @param eventName * @param data */ NodeApiServer.prototype.sendToAll = function (io, eventName, data) { var self = this, clientArr, cleanData, res, i; // Increment the message counter io.messageId++; clientArr = io.clients; cleanData = self.jStringify(data); // Loop client resource and write data out to socket for (i = 0; i < clientArr.length; i++) { res = clientArr[i].res; self.sendToClient(res, io.messageId, eventName, cleanData); } }; /** * Sends data to individual client. * @param res * @param messageId * @param eventName * @param stringifiedData Data to send in already-stringified format. */ NodeApiServer.prototype.sendToClient = function (res, messageId, eventName, stringifiedData) { res.write('event: ' + eventName + '\n'); res.write('id: ' + messageId + '\n'); res.write("data: " + stringifiedData + '\n\n'); //console.log('EventStream: "' + eventName + '" ' + stringifiedData); }; /** * Checks for permission to access the specified object. * @param {String} dbName * @param {String} objType * @param {String} objName * @param {String} methodName * @param {Object} req * @param {Function} callback * @returns {*} */ NodeApiServer.prototype.hasPermission = function (dbName, objType, objName, methodName, req, callback) { var permissionMethods = this._core.api.access(dbName, objType, objName, methodName); if (!permissionMethods || !permissionMethods.length) { // No permissions set, deny access by default return callback('403 Access Forbidden'); } // Add a method right at the beginning of the waterfall // that passes in the req object so all the access control // methods can analyse the request for info they might need permissionMethods.splice(0, 0, function (cb) { cb(null, dbName, objType, objName, methodName, req); }); // Loop the access methods and call each one in turn until an error // response is found, then callback a failure async.waterfall(permissionMethods, callback); }; /** * Defines an access rule for an object and HTTP method combination. When * access is requested via a REST call, the function provided will be * executed and the callback from that method will determine if the * access will be allowed or denied. Multiple access functions can * be provided for a single model and method allowing authentication * checks to be stacked. * * This call also allows you to pass a wildcard "*" instead of the objType, * objName and methodName parameters which will match all items. This * allows you to set permissions globally. * * If no access permissions are present ForerunnerDB will automatically * deny any request to the resource by default. * @name access * @method NodeApiServer.access * @param {String} dbName The name of the database to set access rules for. * @param {String} objType The type of object that the name refers to * e.g. "collection", "view" etc. This effectively maps to a method name on * the Db class instance. If you can do db.collection() then you can use the * string "collection" since it maps to the db.collection() method. This means * you can also use this to map to custom classes as long as you register * an accessor method on the Db class. * @param {String} objName The object name to apply the access rule to. * @param {String} methodName The name of the HTTP method to apply the access * function to e.g. "GET", "POST", "PUT", "PATCH" etc. * @param {Function|String} checkFunction The function to call when an access * attempt is made against the collection. A callback method is passed to this * function which should be called after the function has finished * processing. If you do not need custom logic to allow or deny access you * can simply pass the string "allow" or "deny" instead of a function and * ForerunnerDB will handle the logic automatically. * @returns {*} */ NodeApiServer.prototype.access = function (dbName, objType, objName, methodName, checkFunction) { var methodArr, accessOrderItem, foundEntry = false, i; if (objType !== undefined && objName !== undefined && methodName !== undefined) { if (checkFunction !== undefined) { if (checkFunction === 'allow') { // Provide default allow method checkFunction = this._allowMethod; } else if (checkFunction === 'deny') { // Provide default deny method checkFunction = this._denyMethod; } if (typeof checkFunction !== 'function') { throw('Cannot create an access rule with a checkFunction argument of a type other than function!'); } // Set new access permission with callback "checkFunction" _access.db = _access.db || {}; _access.db[dbName] = _access.db[dbName] || {}; _access.db[dbName][objType] = _access.db[dbName][objType] || {}; _access.db[dbName][objType][objName] = _access.db[dbName][objType][objName] || {}; _access.db[dbName][objType][objName][methodName] = true; _accessOrder.push({ dbName: dbName, objType: objType, objName: objName, methodName: methodName, checkFunction: checkFunction }); return this; } methodArr = []; // Do quick lookup check if (_access.db && _access.db[dbName] && _access.db[dbName][objType] && _access.db[dbName][objType][objName] && _access.db[dbName][objType][objName][methodName]) { foundEntry = true; } else if (_access.db && _access.db[dbName] && _access.db[dbName][objType] && _access.db[dbName][objType][objName] && _access.db[dbName][objType][objName]['*']) { foundEntry = true; } else if (_access.db && _access.db[dbName] && _access.db[dbName][objType] && _access.db[dbName][objType]['*'] && _access.db[dbName][objType]['*'][methodName]) { foundEntry = true; } else if (_access.db && _access.db[dbName] && _access.db[dbName][objType] && _access.db[dbName][objType]['*'] && _access.db[dbName][objType]['*']['*']) { foundEntry = true; } else if (_access.db && _access.db[dbName] && _access.db[dbName]['*'] && _access.db[dbName]['*']['*'] && _access.db[dbName]['*']['*']['*']) { foundEntry = true; } else { // None of the matching patterns exist, exit without permissions return methodArr; } // Now we know that at least one of the permission rules matches the passed pattern // so we loop through the permissions in order they are registered and add matching // ones to the methodArr array. for (i = 0; i < _accessOrder.length; i++) { accessOrderItem = _accessOrder[i]; // Determine if the access data fits the query if (accessOrderItem.dbName === '*' || accessOrderItem.dbName === dbName) { if (accessOrderItem.objType === '*' || accessOrderItem.objType === objType) { if (accessOrderItem.objName === '*' || accessOrderItem.objName === objName) { if (accessOrderItem.methodName === '*' || accessOrderItem.methodName === methodName) { methodArr.push(accessOrderItem.checkFunction); } } } } } return methodArr; } return []; }; NodeApiServer.prototype._allowMethod = function defaultAllowMethod (dbName, objType, objName, httpMethod, req, callback) { callback(false, dbName, objType, objName, httpMethod, req); }; NodeApiServer.prototype._denyMethod = function defaultDenyMethod (dbName, objType, objName, httpMethod, req, callback) { callback('Access forbidden', dbName, objType, objName, httpMethod, req); }; /** * Creates the routes that express will expose to clients. * @private */ NodeApiServer.prototype._defineRoutes = function () { var self = this, root = this._rootPath; app.get(root + '/', function (req, res) { res.send({ server: 'ForerunnerDB', version: self._core.version() }); }); // Handle sync routes app.get(root + '/:dbName/:objType/:objName/_sync', function () { self.handleSyncRequest.apply(self, arguments); }); // Handle all other routes app.get(root + '/:dbName/:objType/:objName', function () { self.handleRequest.apply(self, arguments); }); app.get(root + '/:dbName/:objType/:objName/:objId', function () { self.handleRequest.apply(self, arguments); }); app.get(root + '/:dbName/:objType/:objName/:objId/*', function () { self.handleRequest.apply(self, arguments); }); app.head(root + '/:dbName/:objType/:objName/:objId', function () { self.handleRequest.apply(self, arguments); }); app.post(root + '/:dbName/:objType/:objName', function () { self.handleRequest.apply(self, arguments); }); app.put(root + '/:dbName/:objType/:objName/:objId', function () { self.handleRequest.apply(self, arguments); }); app.patch(root + '/:dbName/:objType/:objName/:objId', function () { self.handleRequest.apply(self, arguments); }); app.delete(root + '/:dbName/:objType/:objName', function () { self.handleRequest.apply(self, arguments); }); app.delete(root + '/:dbName/:objType/:objName/:objId', function () { self.handleRequest.apply(self, arguments); }); app.get(root + '/:dbName/:objType/:objName/_sync', function () { self.handleRequest.apply(self, arguments); }); app.get(root + '/:dbName/:objType/:objName/:objId/_sync', function () { self.handleRequest.apply(self, arguments); }); }; /* NodeApiServer.prototype._generateCert = function () { pem.createCertificate({ days: 365, selfSigned: true }, function(err, keys) { }); }; */ /** * Override the Core init to instantiate the plugin. * @returns {*} */ Core.prototype.init = function () { this.api = new NodeApiServer(this); return CoreInit.apply(this, arguments); }; Shared.finishModule('NodeApiServer'); module.exports = NodeApiServer;