/** * A session store implementation for Connect & Express backed by an NeDB * datastore (either in-memory or file-persisted). * * For implementation requirements for Express 4.x and above, see: * https://github.com/expressjs/session#session-store-implementation */ 'use strict'; // Node.js core modules var path = require('path'); var util = require('util'); // Userland modules var NeDB = require('nedb'); // "Constants" var ONE_DAY = 86400000; var TWO_WEEKS = 14 * ONE_DAY; /** * Returns a constructor with the specified Connect middleware's Store class as * its prototype. * * @param {Function} connect Connect-compatible session middleware (e.g. Express 3.x, express-session) * @api public */ module.exports = function( connect ) { /** * Express and/or Connect's session Store */ // connect.Store => Express 5.x/4.x and Connect 3.x with `require('express-session')` // connect.session.Store => Express 3.x/2.x and Connect 2.x/1.x with `express` var Store = connect.Store || connect.session.Store; /** * Create a new session store, backed by an NeDB datastore * @constructor * @param {Object} options Primarily a subset of the options from https://github.com/louischatriot/nedb#creatingloading-a-database * @param {Number} options.defaultExpiry The default expiry period (max age) in milliseconds to use if the session's expiry is not controlled by the session cookie configuration. Default: 2 weeks. * @param {Boolean} options.inMemoryOnly The datastore will be in-memory only. Overrides `options.filename`. * @param {String} options.filename Relative file path where session data will be persisted; if none, a default of 'data/sessions.db' will be used. * @param {Function} options.afterSerialization Optional serialization callback invoked before writing to file, e.g. for encrypting data. * @param {Function} options.beforeDeserialization Optional deserialization callback invoked after reading from file, e.g. for decrypting data. * @param {Number} options.corruptAlertThreshold Optional threshold after which an error is thrown if too much data read from file is corrupt. Default: 0.1 (10%). * @param {Number} options.autoCompactInterval Optional interval in milliseconds at which to auto-compact file-based datastores. Valid range is 5000ms to 1 day. Pass `null` to disable. * @param {Function} options.onload Optional callback to be invoked when the datastore is loaded and ready. */ function NeDBStore( options ) { var onLoadFn, aci, _this = this; if ( !(_this instanceof NeDBStore) ) { return new NeDBStore( options ); } options = options || {}; // Remove this deprecated NeDB option from the `options` object, moreover because it is irrelevant for use // within Express middleware delete options.nodeWebkitAppName; // Ensure that the `inMemoryOnly` option is a Boolean options.inMemoryOnly = !!options.inMemoryOnly; // If the `inMemoryOnly` option was falsy... if ( !options.inMemoryOnly ) { // ...and the `filename` option is falsy, provide a default value for the `filename` option options.filename = options.filename || path.join('data', 'sessions.db'); } else { // Otherwise (if using an in-memory datastore), clear out the file-based options as they no longer apply options.filename = null; options.afterSerialization = null; options.beforeDeserialization = null; options.corruptAlertThreshold = undefined; options.autoCompactInterval = null; } // Ensure some default expiry period (max age) is specified _this._defaultExpiry = ( typeof options.defaultExpiry === 'number' && Number.isFinite(options.defaultExpiry) && options.defaultExpiry > 0 ) ? parseInt(options.defaultExpiry, 10) : TWO_WEEKS; delete options.defaultExpiry; // Ensure that any file-based datastore is automatically compacted at least once per day, unless specifically // set to `null` if ( options.autoCompactInterval !== null ) { aci = parseInt(options.autoCompactInterval, 10); aci = aci < 5000 ? 5000 : ( aci < ONE_DAY ? aci : ONE_DAY ); } else { aci = null; } delete options.autoCompactInterval; // Ensure that we track the time the record was created (`createdAt`) and last modified (`updatedAt`) options.timestampData = true; // Ensure that any file-based datastore starts loading immediately and signals when it is loaded options.autoload = true; onLoadFn = typeof options.onload === 'function' ? options.onload : function() {}; options.onload = function( err ) { if ( err ) { _this.emit( 'error', err ); } // The "express-session" core module listens to the "connect" and "disconnect" event names _this.emit( ( err ? 'dis' : '' ) + 'connect' ); onLoadFn( err ); }; // Apply the base constructor Store.call( _this, options ); // Create the datastore (basically equivalent to an isolated Collection in MongoDB) _this.datastore = new NeDB( options ); // Ensure that we continually compact the datafile, if using file-based persistence if ( options.filename && aci !== null ) { _this.datastore.persistence.setAutocompactionInterval( aci ); } } // Inherit from Connect/Express's core session store util.inherits( NeDBStore, Store ); /** * Create or update a single session's data */ NeDBStore.prototype.set = function( sessionId, session, callback ) { // Handle rolling expiration dates var expirationDate; if ( session && session.cookie && session.cookie.expires ) { expirationDate = new Date( session.cookie.expires ); } else { expirationDate = new Date( Date.now() + this._defaultExpiry ); } // Ensure that the Cookie in the `session` is safely serialized var sess = {}; Object.keys( session ).forEach(function( key ) { if ( key === 'cookie' && typeof session[key].toJSON === 'function' ) { sess[key] = session[key].toJSON(); } else { sess[key] = session[key]; } }); // IMPORTANT: NeDB datastores auto-buffer their commands until the database is loaded this.datastore.update( { _id: sessionId }, { $set: { session: sess, expiresAt: expirationDate } }, { multi: false, upsert: true }, function( err, numAffected, newDoc ) { if ( !err && numAffected === 0 && !newDoc ) { err = new Error( 'No Session exists with ID ' + JSON.stringify(sessionId) ); } return callback( err ); } ); }; /** * Touch a single session's data to update the time of its last access */ NeDBStore.prototype.touch = function( sessionId, session, callback ) { var touchSetOp = { updatedAt: new Date() }; // Handle rolling expiration dates if ( session && session.cookie && session.cookie.expires ) { touchSetOp.expiresAt = new Date( session.cookie.expires ); } // IMPORTANT: NeDB datastores auto-buffer their commands until the database is loaded this.datastore.update( { _id: sessionId }, { $set: touchSetOp }, { multi: false, upsert: false }, function( err, numAffected ) { if ( !err && numAffected === 0 ) { err = new Error( 'No Session exists with ID ' + JSON.stringify(sessionId) ); } return callback( err ); } ); }; /** * Get a single session's data */ NeDBStore.prototype.get = function( sessionId, callback ) { var _this = this; // IMPORTANT: NeDB datastores auto-buffer their commands until the database is loaded this.datastore.findOne( { _id: sessionId }, function( err, existingDoc ) { if ( err ) { return callback( err, null ); } else if ( existingDoc ) { // If the existing record does not have an expiration and/or has not yet expired, return it if ( existingDoc.session && !existingDoc.expiresAt || new Date() < existingDoc.expiresAt ) { return callback( null, existingDoc.session ); } // Otherwise it is an expired session, so destroy it! else { return _this.destroy( sessionId, function( destroyErr ) { callback( destroyErr, null ); } ); } } return callback( null, null ); } ); }; /** * Get ALL sessions' data */ NeDBStore.prototype.all = function( callback ) { var _this = this; // IMPORTANT: NeDB datastores auto-buffer their commands until the database is loaded _this.datastore.find( {}, function( err, existingDocs ) { if ( err ) { return callback( err, null ); } return callback( null, ( existingDocs || [] ) .filter(function( existingDoc ) { // If the existing record does not have an expiration and/or has not yet expired, keep it in the result list if ( existingDoc.session && !existingDoc.expiresAt || new Date() < existingDoc.expiresAt ) { return true; } // Otherwise it is an expired session, so destroy it! ...AND remove it from the result list else { // NOTE: The following action makes this `filter`-ing callback an impure function as it has side effects (removing stale sessions)! _this.destroy( existingDoc._id, function( destroyErr ) { if ( destroyErr ) { // Give consumers a way to observe these `destroy` failures, if desired _this.emit( 'error', destroyErr ); } } ); return false; } }) .map(function( existingDoc ) { return existingDoc.session; }) ); } ); }; /** * Count ALL sessions */ NeDBStore.prototype.length = function( callback ) { // While using `this.all` is much less performant than using `this.datastore.count`, // it DOES, however, also filter out (and destroy) any stale session records first, // thus resulting in a more accurate final count. // IMPORTANT: NeDB datastores auto-buffer their commands until the database is loaded this.all( function( err, sessions ) { callback( err, ( sessions || [] ).length ); } ); }; /** * Remove a single session */ NeDBStore.prototype.destroy = function( sessionId, callback ) { // IMPORTANT: NeDB datastores auto-buffer their commands until the database is loaded this.datastore.remove( { _id: sessionId }, { multi: false }, function( err /*, numRemoved */ ) { return callback( err ); } ); }; /** * Remove ALL sessions */ NeDBStore.prototype.clear = function( callback ) { // IMPORTANT: NeDB datastores auto-buffer their commands until the database is loaded this.datastore.remove( {}, { multi: true }, function( err /*, numRemoved */ ) { return callback( err ); } ); }; return NeDBStore; };