diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..96c0ecc --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +docs/ \ No newline at end of file diff --git a/docs/Examples.md b/docs/Examples.md new file mode 100644 index 0000000..785a804 --- /dev/null +++ b/docs/Examples.md @@ -0,0 +1,64 @@ +# Examples & Snippets +Below are various examples and snippets that make use of `SessionEngine` and `Session` components. + +#### Example: Initializing & Binding A Session Engine With Redis Store Implementation +```javascript +const SessionEngine = require('hyper-express-session'); +const TestEngine = new session_engine({ + duration: 1000 * 60 * 45, // Default duration is 45 Minutes + cookie: { + name: 'example_sess', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + secret: 'SomeSuperSecretForSigningCookies' + } +}); + +// Bind session engine handlers for storing sessions in Redis store +TestEngine.use('read', async (session) => { + const data = await redis.get('session:' + session.id); + if(typeof data == 'string') return JSON.parse(data); +}); + +TestEngine.use('touch', async (session) => { + return await redis.pexpireat('session:' + session.id, session.expires_at); +}); + +TestEngine.use('write', async (session) => { + const key = 'session:' + session.id; + + // We use redis pipeline to perform two operations in one go + return await redis.pipeline() + .set(key, JSON.stringify(session.get())) + .pexpireat(key, session.expires_at) + .exec(); +}); + +TestEngine.on('destroy', async (session) => { + return await redis.del('session:' + session.id); +}); + +// Use middleware from TestEngine in a HyperExpress webserver instance +webserver.use(TestEngine.middleware); +``` + +#### Example: Initiating and storing visits in a session +```js +// Assume a SessionEngine instance has already been setup for this route +webserver.get('/dashboard/news', async (request, response) => { + // Initiate a session asynchronously + await request.session.start(); + + // Read session for visits property and iterate + let visits = request.session.get('visits'); + if (visits == undefined){ + request.session.set('visits', 1); // Initiate visits property in session + } else { + request.session.set('visits', visits + 1); // Iterate visists by 1 + } + + return response.html(news_html); +}); +``` \ No newline at end of file diff --git a/docs/Session.md b/docs/Session.md new file mode 100644 index 0000000..f0cf363 --- /dev/null +++ b/docs/Session.md @@ -0,0 +1,49 @@ +## Session +Below is a breakdown of the `session` object made available through the `request.session` property in route/middleware handler(s). + +#### Session Properties +| Property | Type | Description | +| :-------- | :------- | :------------------------- | +| `id` | `Number` | Raw session id for current request. | +| `signed_id` | `Number` | Signed session id for current request. | +| `ready` | `Boolean` | Specifies whether session has been started. | +| `stored` | `Boolean` | Specifies whether session is already stored in database. | +| `duration` | `Number` | Duration in **milliseconds** of current session. | +| `expires_at` | `Number` | Expiry timestamp in **milliseconds** of current session. | + +#### Session Methods +Methods that return `Session` are chainable to allow for cleaner code. + +* `generate_id()`: Asynchronously generates and returns a new session id from `'id'` session engine event. + * **Returns** `Promise`->`String` +* `set_id(String: session_id)`: Overwrites/Sets session id for current request session. + * **Returns** `Session` + * **Note** this method is not recommended in conjunction with user input as it performs no verification. +* `set_signed_id(String: signed_id, String: secret)`: Overwrites/Sets session id for current request session. + * **Returns** `Session` + * **Note** this method is **recommended** over `set_id` as it will first unsign/verify the provided signed id and then update the state of current session. + * `secret` is **optional** as this method uses the underlying `SessionEngine.cookie.secret` by default. +* `set_duration(Number: duration)`: Sets a custom session lifetime duration for current session. + * **Returns** `Session` + * **Note** this method stores the custom duration value as a part of the session data in a prefix called `__cust_dur`. +* `start()`: Starts session on incoming request and loads session data from storage source. + * **Returns** `Promise`. +* `roll()`: Rolls current session's id by migrating current session data to a new session id. + * **Returns** `Promise` -> `Boolean` +* `touch()`: Updates current session's expiry timestamp in storage. + * **Returns** `Promise` + * **Note** This method is automatically called at the end of each request when `automatic_touch` is enabled in `SessionEngine` options. +* `destroy()`: Destroys current session from storage and set's cookie header to delete session cookie. + * **Returns** `Promise` +* `set(String: name, Any: value)`: Sets session data value. You set multiple values by passing an `Object` parameter. + * **Returns** `Promise` + * **Single Example:** `session.set('id', 'some_id')` + * **Multiple Example:** `session.set({ id: 'some_id', email: 'some_email' })` +* `reset(Object: data)`: Replaces existing session data values with values from the provided `data` object. + * **Returns** `Promise` +* `get(String: name)`: Returns session data value for specified name. You may **omit** `name` to get **all** session data values. + * **Returns** `Any`, `Object`, `undefined` + * **Get One Example**: `session.get('email');` will return the session data value for `email` or `undefined` if it is not set. + * **Get All Example**: `session.get()` will return all session data values in an `Object`. +* `delete(String: name)`: Deletes session data value at specified name. You may **omit** `name` to delete **all** session data values. + * **Returns** `Promise` \ No newline at end of file diff --git a/docs/SessionEngine.md b/docs/SessionEngine.md new file mode 100644 index 0000000..b1d9b9b --- /dev/null +++ b/docs/SessionEngine.md @@ -0,0 +1,54 @@ +# SessionEngine +Below is a breakdown of the `SessionEngine` object class generated while creating a new session engine instance. A single session engine can be shared across multiple `HyperExpress.Server` instances. + +#### SessionEngine Constructor Options +* `duration`[`Number`]: Specifies the lifetime of sessions in **milliseconds**. + * **Default:** `1000 * 60 * 30` (30 Minutes) +* `automatic_touch`[`Boolean`]: Specifies whether active sessions should be `touched` regardless of data changes upon each request. + * **Default:** `true` +* `cookie`[`Object`]: Specifies session cookie options. + * `name`[`String`]: Cookie Name + * `domain`[`String`]: Cookie Domain + * `path`[`String`]: Cookie Path + * `secure`[`Boolean`]: Adds Secure Flag + * `httpOnly`[`Boolean`]: Adds httpOnly Flag + * `sameSite`[`Boolean`, `'none'`, `'lax'`, `'strict'`]: Cookie Same-Site Preference + * `secret`[`String`]: Specifies secret value used to sign/authenticate session cookies. +* **Note!** a strong and unique string is required for `cookie.secret`. + +### SessionEngine Instance Properties +| Property | Type | Description | +| :-------- | :------- | :------------------------- | +| `middleware` | `Function` | Middleware handler to be used with `HyperExpress.use()`. | + +#### SessionEngine Methods +* `use(String: type, Function: handler)`: Binds a handler for specified operation `type`. + * **Note** you must use your own storage implementation in combination with available operations below. + * **Supported Operations:** + * [`read`]: Must read and return session data as an `Object` from your storage. + * **Parameters**: `(Session: session) => {}`. + * **Expects** A `Promise` which then resolves to an `Object` or `undefined` type. + * **Required** + * [`touch`]: Must update session expiry timestamp in your storage. + * **Parameters**: `(Session: session) => {}`. + * **Expects** A `Promise` which is then resolved to `Any` type. + * **Required** + * [`write`]: Must write session data and update expiry timestamp to your storage. + * **Parameters**: `(Session: session) => {}`. + * You can use `session.stored` to determine if you need to `INSERT` or `UPDATE` for SQL based implementations. + * **Expects** A `Promise` which then resolves to `Any` type. + * **Required** + * [`destroy`]: Must destroy session from your storage. + * **Parameters**: `(Session: session) => {}`. + * **Expects** A `Promise` which then resolves to `Any` type. + * **Required** + * [`id`]: Must return a promise that generates and resolves a cryptographically random id. + * **Parameters**: `() => {}`. + * **Expects** A `Promise` which then resolves to `String` type. + * **Optional** + * [`cleanup`]: Must clean up expired sessions from your storage. + * **Parameters**: `() => {}`. + * **Expects** A `Promise` which then resolves to `Any` type. + * **Optional** + * See [`> [Session]`](./Session.md) for working with the `session` parameter. +* `cleanup()`: Triggers `cleanup` operation handler to delete expired sessions from storage. \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..806dcb6 --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +const SessionEngine = require('./src/components/SessionEngine.js'); +module.exports = SessionEngine; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..48b6ce1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "hyper-express-session", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "uid-safe": "^2.1.5" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + } + }, + "dependencies": { + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=" + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + } + } +} diff --git a/package.json b/package.json index 8a14751..85071a5 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,33 @@ { - "name": "hyper-express-session", - "version": "1.0.0", - "description": "High performance middleware that implements cookie sessions into the HyperExpress webserver.", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/kartikk221/hyper-express-session.git" - }, - "keywords": [ - "high", - "performance", - "cookie", - "session", - "flexible", - "redis", - "sql", - "compatible", - "node" - ], - "author": "kartikk221", - "license": "MIT", - "bugs": { - "url": "https://github.com/kartikk221/hyper-express-session/issues" - }, - "homepage": "https://github.com/kartikk221/hyper-express-session#readme" + "name": "hyper-express-session", + "version": "1.0.0", + "description": "High performance middleware that implements cookie based web sessions into the HyperExpress webserver.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kartikk221/hyper-express-session.git" + }, + "keywords": [ + "high", + "performance", + "cookie", + "session", + "flexible", + "redis", + "sql", + "compatible", + "node" + ], + "author": "kartikk221", + "license": "MIT", + "bugs": { + "url": "https://github.com/kartikk221/hyper-express-session/issues" + }, + "homepage": "https://github.com/kartikk221/hyper-express-session#readme", + "dependencies": { + "uid-safe": "^2.1.5" + } } diff --git a/src/components/Session.js b/src/components/Session.js new file mode 100644 index 0000000..a8837bf --- /dev/null +++ b/src/components/Session.js @@ -0,0 +1,404 @@ +class Session { + // Session Core Data + #id; + #request; + #response; + #signed_id; + #session_data = {}; + #session_engine; + #prefixes = { + duration: '__cust_dur', + }; + + // Session State Booleans + #parsed_id = false; + #ready = false; + #from_database = false; + #persist = false; + #destroyed = false; + + constructor(request, response, session_engine) { + // Store request, response and session engine object to be used by instance throughout operation + this.#request = request; + this.#response = response; + this.#session_engine = session_engine; + + // Bind a hook on 'send' event for performing closure at the end of request + response.hook('send', () => this._perform_closure()); + } + + /** + * This method asynchronously generates a strong cryptographically random session id. + * + * @returns {Promise} Promise -> String + */ + async generate_id() { + return await this.#session_engine.methods.id(); + } + + /** + * This method sets the current session's id to provided session_id. + * Note! This method does not perform any verification on provided session_id + * and thus is not recommended to be used with any user a provided id. + * + * @param {String} id + * @returns {Session} Session (chainable) + */ + set_id(session_id) { + if (typeof session_id !== 'string') throw new Error('set_id(id) -> id must be a string'); + this.#id = session_id; + this.#parsed_id = true; + return this; + } + + /** + * This method sets the current session's id to provided signed session id. + * Note! This method is recommended over .set_id() as this method will attempt to + * unsign the the provided id and thus verifies user input. + * + * @param {String} signed_id Signed Session ID + * @param {String} secret Optional (Utilizes SessionEngine.options.cookie.secret by default) + * @returns {Boolean} + */ + set_signed_id(signed_id, secret) { + // Attempt to unsign provided id and secret with fallback to Session Engine secret + const final_secret = secret || this.#session_engine.options.cookie.secret; + const unsigned_id = this.#request.unsign(signed_id, final_secret); + + // Return false if unsigning process fails, likely means bad signature + if (unsigned_id === false) return false; + + // Set provided unsigned/signed_id to Session state + this.#id = unsigned_id; + this.#signed_id = signed_id; + this.#parsed_id = true; + return true; + } + + /** + * This method is used to change the duration of current session to a custom value in milliseconds. + * + * @param {Number} duration In Milliseconds + * @returns {Session} Session (Chainable) + */ + set_duration(duration) { + // Ensure provided duration is a valid number in milliseconds + if (typeof duration !== 'number' || duration < 1) + throw new Error( + 'SessionEngine: Session.set_duration(duration) -> duration must be a valid number in milliseconds.' + ); + + // Store custom duration as a part of the session data + return this.set(this.#prefixes.duration, duration); + } + + /** + * This method is used to start a session for incoming request. + * Note! This method is asynchronous as it performs the 'read' operation to read session data. + * + * @returns {Promise} Promise + */ + async start() { + // Return if session has already started + if (this.#ready) return; + + // Retrieve session id to determine if a session cookie was sent with request + const session_id = this.id; + if (typeof session_id !== 'string' || session_id.length == 0) { + // Generate a new session id since no session cookie was sent with request + this.#id = await this.generate_id(); + this.#parsed_id = true; + this.#ready = true; + return; // Return since this is a brand new session and we do not need to perform a 'read' + } + + // Perform 'read' operation to retrieve any associated data for this session + const session_data = await this.#session_engine.methods.read(this); + if (session_data && typeof session_data == 'object') { + this.#from_database = true; + this.#session_data = session_data; + } else { + // This will be useful to user for choosing between INSERT or UPDATE operation during 'write' operation + this.#from_database = false; + } + + // Mark session as ready so rest of the methods can be used + this.#ready = true; + } + + /** + * Throws an Error alerting user for a session not being started for ready only methods. + * @private + */ + _session_not_started(method) { + throw new Error( + 'SessionEngine: Session was not started. Please call Request.session.start() before calling Request.session.' + + method + + '()' + ); + } + + /** + * Rolls current session's id to a new session id. + * Note! This operation performs 2 underlying operations as it first + * deletes old session and then persists session data under new session id. + * + * @returns {Promise} Promise -> Boolean (true || false) + */ + async roll() { + // Throw not started error if session was not started/ready + if (!this.#ready) return this._session_not_started('roll'); + + // Destroy old session if it is from database + if (this.#from_database) await this.destroy(); + + // Generate a new session id for current session + this.#id = await this.generate_id(); + this.#signed_id = null; // Since we generated a new session id, we will need to re-sign + this.#parsed_id = true; + this.#destroyed = false; // This is to override the destroy() method + this.#from_database = false; + this.#persist = true; // This is so the new session persists at the end of this request + return true; + } + + /** + * This method performs the 'touch' operation updating session's expiry in storage. + * + * @returns {Promise} Promise + */ + touch() { + // Return if no session cookie was sent with request + if (typeof this.id !== 'string') return; + + // Invocate touch operation from session engine + return this.#session_engine.methods.touch(this); + } + + /** + * This method is used to destroy the current session. + * Note! This method is asynchronous as it instantly triggers + * the 'destroy' operation causing session to be deleted from storage mechanism. + * + * @returns {Promise} + */ + async destroy() { + // Return if session has already been destroyed + if (this.#destroyed) return; + + // Return if no session cookie was sent with request + if (typeof this.id !== 'string') return; + + // Make sure session has been started before we attempt to destroy it + if (!this.#ready) await this.start(); + + // Perform 'destroy' operation to delete session data from storage + if (this.#from_database) await this.#session_engine.methods.destroy(this); + + // Empty local session data and mark instance to be destroyed + this.#session_data = {}; + this.#destroyed = true; + } + + /** + * This method is used to set one or multiple session data values. + * You may provide a name and value argument to update a single value. + * You may provide an Object of keys/values to update multiple values in one operation. + * + * @param {String|Object} name + * @param {Any} value + * @returns {Session} Session (Chainable) + */ + set(name, value) { + // Ensure session has been started before trying to set values + if (!this.#ready) return this._session_not_started('set'); + + // Update local session data based on provided format + if (typeof name == 'string') { + this.#session_data[name] = value; + } else { + Object.keys(name).forEach((key) => (this.#session_data[key] = name[key])); + } + + // Mark session instance to be persisted + this.#persist = true; + return this; + } + + /** + * This method replaces current session data values with provided data values object. + * + * @param {Object} data + * @returns {Session} Session (Chainable) + */ + reset(data = {}) { + // Ensure data is an object + if (data === null || typeof data !== 'object') + throw new Error('SessionEngine: Session.reset(data) -> data must be an Object.'); + + // Ensure session has been started before trying to set values + if (!this.#ready) return this._session_not_started('set'); + + // Overwrite all session data and mark instance to be persisted + this.#session_data = data; + this.#persist = true; + return this; + } + + /** + * This method is used to retrieve data values from current session. + * You may retrieve all session data values by providing no name. + * + * @param {String} name Optional + * @returns {Any|Object|undefined} + */ + get(name) { + // Ensure session is started before trying to read values + if (!this.#ready) return this._session_not_started('get'); + + // Return all session data if no name is provided + if (name == undefined) return this.#session_data; + + // Return specific session data value if name is provided + return this.#session_data[name]; + } + + /** + * This method is used to delete data values from current session. + * You may delete all session data values by providing no name. + * + * @param {String} name + * @returns {Session} Session (Chainable) + */ + delete(name) { + // Ensure session is started before trying to delete values + if (!this.#ready) return this._session_not_started('delete'); + + // Delete single or all values depending on name parameter + if (name) { + delete this.#session_data[name]; + } else { + this.#session_data = {}; + } + + // Mark instance to be persisted + this.#persist = true; + return this; + } + + /** + * Performs session closure by persisting any data and writing session cookie header. + * @private + */ + async _perform_closure() { + // Set set-cookie header depending on session state + const cookie = this.#session_engine.options.cookie; + if (this.#destroyed) { + // Delete session cookie as session was destroyed + this.#response.delete_cookie(cookie.name); + } else if (typeof this.#signed_id == 'string') { + // Write session cookie without signing to save on CPU operations + this.#response.cookie(cookie.name, this.#signed_id, this.duration, cookie, false); + } else if (typeof this.#id == 'string') { + // Write session cookie normally as we do not have a cached signed id + this.#response.cookie(cookie.name, this.#id, this.duration, cookie); + } + + // Return if session has been destroyed as we have nothing to persist + if (this.#destroyed) return; + + try { + // Execute 'touch' operation if automatic_touch is enabled + const automatic_touch = this.#session_engine.options.automatic_touch; + if (this.#from_database && automatic_touch === true) { + await this.touch(); + } else if (this.#persist) { + // Execute 'write' operation to persist changes + await this.#session_engine.methods.write(this); + } + } catch (error) { + // Pipe error to the global error handler + this.#response.throw_error(error); + } + } + + /* Session Getters */ + + /** + * Parses and returns session id from current request based on session cookie. + * + * @returns {String|undefined} + */ + get id() { + // Return from cache if id has already been parsed once + if (this.#parsed_id) return this.#id; + + // Attempt to read session cookie from request headers + const cookie_options = this.#session_engine.options.cookie; + const signed_cookie_id = this.#request.cookies[cookie_options.name]; + if (signed_cookie_id) { + // Unsign the raw cookie header value + const unsigned_value = this.#request.unsign(signed_cookie_id, cookie_options.secret); + + // Store raw id and signed id locally for faster access in future operations + if (unsigned_value !== false) { + this.#id = unsigned_value; + this.#signed_id = signed_cookie_id; + } + } + + // Mark session id as parsed for faster lookups + this.#parsed_id = true; + return this.#id; + } + + /** + * This method is an alias of .id() except it returns the raw signed id. + * + * @returns {String|undefined} + */ + get signed_id() { + // Check cache for faster lookup + if (this.#signed_id) return this.#signed_id; + + // Retrieve parsed session id and sign it with session engine specified signature + const id = this.id; + const secret = this.#session_engine.options.cookie.secret; + if (id) this.#signed_id = this.#request.sign(id, secret); + + return this.#signed_id; + } + + /** + * Returns whether session is ready and its data has been retrieved. + */ + get ready() { + return this.#ready; + } + + /** + * Returns whether session has already been stored in database or not. + * This is helpful for choosing between INSERT/UPDATE operations for SQL based implementations. + */ + get stored() { + return this.#from_database; + } + + /** + * Returns the current session's lifetime duration in milliseconds. + */ + get duration() { + const default_duration = this.#session_engine.options.duration; + const custom_duration = this.#session_data[this.#prefixes.duration]; + return typeof custom_duration == 'number' ? custom_duration : default_duration; + } + + /** + * Returns the expiry unix timestamp in milliseconds of current session. + */ + get expires_at() { + return Date.now() + this.duration; + } +} + +module.exports = Session; diff --git a/src/components/SessionEngine.js b/src/components/SessionEngine.js new file mode 100644 index 0000000..ab7e7a4 --- /dev/null +++ b/src/components/SessionEngine.js @@ -0,0 +1,138 @@ +const Session = require('./Session.js'); +const UidSafe = require('uid-safe'); +const { wrap_object } = require('../shared/operators.js'); + +class SessionEngine { + #middleware; + #options = { + automatic_touch: true, + duration: 1000 * 60 * 30, + cookie: { + name: 'default_sess', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'none', + secret: null, + }, + }; + + /** + * @param {Object} options SessionEngine Options + * @param {Number} options.duration Session lifetime duration in milliseconds. Default: 1000 * 60 * 30 (30 Minutes) + * @param {Boolean} options.automatic_touch Specifies whether all sessions should automatically be touched regardless of any changes. + * @param {Object} options.cookie Session cookie options + * @param {String} options.cookie.name Session cookie name + * @param {String} options.cookie.path Session cookie path + * @param {Boolean} options.cookie.httpOnly Whether to add httpOnly flag to session cookie + * @param {Boolean} options.cookie.secure Whether to add secure flag to session cookie + * @param {String|Boolean} options.cookie.sameSite Session cookie sameSite directive + * @param {String} options.cookie.secret Session cookie signature secret. Note! this is required! + */ + constructor(options) { + // Ensure options is a valid object + if (options == null || typeof options !== 'object') + throw new Error('new SessionEngine(options) -> options must be an object.'); + + // Wrap local options object from provided options + wrap_object(this.#options, options); + + // Ensure the session duration is a valid number + const duration = this.#options.duration; + if (typeof duration !== 'number' || duration < 1) + throw new Error( + 'new SessionEngine(options.duration) -> duration must be a valid number in milliseconds.' + ); + + // Ensure user has specified a secret as it is required + const secret = this.#options.cookie.secret; + if (typeof secret !== 'string' || secret.length < 10) + throw new Error( + 'new SessionEngine(options.cookie.secret) -> secret must be a unique and strong random string.' + ); + + // Create and store a middleware function that attaches Session to each request + const session_engine = this; + this.#middleware = (request, response, next) => { + // Bind Session component to each request on the 'session' property + request.session = new Session(request, response, session_engine); + next(); + }; + } + + /** + * This method throws a session engine unhandled operation error. + * + * @private + * @param {String} type + */ + _not_setup_method(type) { + throw new Error( + `SessionEngine '${type}' operation is not being handled. Please use SessionEngine.use('${action}', some_handler) to handle this session engine operation.` + ); + } + + #methods = { + id: () => UidSafe(24), // generates 32 length secure id + touch: () => this._not_setup_method('touch'), + read: () => this._not_setup_method('read'), + write: () => this._not_setup_method('write'), + destroy: () => this._not_setup_method('destroy'), + cleanup: () => this._not_setup_method('cleanup'), + }; + + /** + * This method is used to specify a handler for session engine operations. + * + * @param {String} type [id, touch, read, write, destroy] + * @param {function(Session):void} handler + * @returns {SessionEngine} + */ + use(type, handler) { + // Ensure type is valid string that is supported + if (typeof type !== 'string' || this.#methods[type] == undefined) + throw new Error( + 'SessionEngine.use(type, handler) -> type must be a string that is a supported operation.' + ); + + // Ensure handler is an executable function + if (typeof handler !== 'function') + throw new Error('SessionEngine.use(type, handler) -> handler must be a Function.'); + + // Store handler and return self for chaining + this.#methods[type] = handler; + return this; + } + + /** + * Triggers 'cleanup' operation. + */ + cleanup() { + return this.#methods.cleanup(); + } + + /* SessionEngine Getters */ + + /** + * SessionEngine constructor options. + */ + get options() { + return this.#options; + } + + /** + * SessionEngine operation methods. + */ + get methods() { + return this.#methods; + } + + /** + * SessionEngine middleware function to be passed into HyperExpress.use() method. + */ + get middleware() { + return this.#middleware; + } +} + +module.exports = SessionEngine; diff --git a/src/shared/operators.js b/src/shared/operators.js new file mode 100644 index 0000000..5afa729 --- /dev/null +++ b/src/shared/operators.js @@ -0,0 +1,21 @@ +/** + * Writes values from focus object onto base object. + * + * @param {Object} obj1 Base Object + * @param {Object} obj2 Focus Object + */ +function wrap_object(original, target) { + Object.keys(target).forEach((key) => { + if (typeof target[key] == 'object') { + if (Array.isArray(target[key])) return (original[key] = target[key]); // lgtm [js/prototype-pollution-utility] + if (original[key] === null || typeof original[key] !== 'object') original[key] = {}; + wrap_object(original[key], target[key]); + } else { + original[key] = target[key]; + } + }); +} + +module.exports = { + wrap_object, +};