UNPKG

flowstate

Version:

Per-request state management middleware.

508 lines (442 loc) 18.4 kB
// Module dependencies. var uri = require('url') , State = require('../state') , swizzleRedirect = require('../utils/swizzle-redirect') , swizzleRender = require('../utils/swizzle-render') , swizzleEnd = require('../utils/swizzle-end') , merge = require('utils-merge') , utils = require('../utils') , validate = require('../validate') , debug = require('debug')('flowstate') , qs = require('querystring') , SessionStore = require('../store/session'); var uid = require('uid2'); /** * Create state middleware with the given `options`. * * This middleware is used to load state associated with a request. The state * will be made available at `req.state`. * * HTTP is a stateless protocol. In order to support stateful interactions, the * client and server need to operate in coordination. The server is responsible * for persisting state associated with a request. The client is responsible for * indicating that state in subsequent requests to the server (via a `state` * parameter in the query or body, by default). The server then loads the * previously persisted state in order to continue processing the transaction. * * @public * @name module:flowstate * @param {Object} [options] * @param {string[]} [options.mutationMethods=['POST', 'PUT', 'PATCH', 'DELETE']] - * An array of methods for which request handling is expected to * complete a state. * @param {function} [options.getHandle] - Function that the middleware will * invoke to read the state handle from the request. The function is * called as `getHandle(req)` and is expected to return the handle as a * string. * * The default value is a function that reads the handle from the following * locations, in order: * * - `req.query.state` - a built-in from Express.js to read from the URL query * string. * - `req.body.state` - typically generated by the `body-parser` module. * @param {function} [options.genh] - Function to call to generate a new session * handle. Provide a function that returns a string that will be used * as a session handle. The default value is a function which uses the * `uid2` library to generate IDs. * @param {Store} [options.store] - The state store instance, defaults to a new * `{@link SessionStore}` instance. * @return {function} */ module.exports = function(options) { options = options || {} var store = options.store || new SessionStore(options); // TODO: Rename this to `handle` to match csurf conventions var getHandle = options.getHandle || function(req) { return (req.query && req.query.state) || (req.body && req.body.state); } var validateRedirect = validate.origin; var generateHandle = options.genh || function() { return uid(8); } var mutationMethods = options.mutationMethods === undefined ? ['POST', 'PUT', 'PATCH', 'DELETE'] : options.mutationMethods; // TODO: add back support for a `required` options, which forces a matching state targetting location return function state(req, res, next) { // self-awareness if (req.state) { return next(); } // expose store req.stateStore = store; /** * Add state to be made available on a request for `url`. * * @public * @name ServerResponse#pushState * @function * @param {object} data * @param {string} url */ req.pushState = function(data, url, options, cb) { if (typeof options == 'function') { cb = options; options = undefined; } options = options || {}; /* var immediate = false; if (typeof options == 'boolean') { immediate = true; options = {}; } */ // TODO: only support handle as option? data.location = uri.resolve(utils.originalURL(req), url); // TODO: Should this be taking the top of the state stack?? In case multiple pushes? if (req.state.returnTo && !req.state.external) { data.returnTo = req.state.returnTo; } if (req.state.state) { data.state = req.state.state; } var state = new State(req, data); req._stateStack.push(state); if (cb) { // TODO: Why this typeof check? if (typeof store.set == 'function') { state.handle = options.handle || generateHandle(); } state.save(options, function(err) { if (err) { return cb(err); } return cb(null, state.handle); }); } // TODO: maybe return the new state here, add methods for controlling params } /** * Remove a previously added, but uncommited, state. * * @public * @name ServerResponse#popState * @function */ req.popState = function() { if (req._stateStack.length == 1) { return; } // don't pop current state return req._stateStack.pop(); } /** * Resume prior state, if any, by redirecting its URL. * * @public * @name ServerResponse#resumeState * @function * @param {object} [yields] */ res.resumeState = function(yields, cb) { if (typeof yields == 'function') { cb = yields; yields = undefined; } req.state.complete(); if (!req.state.returnTo) { return cb(); } var loc = uri.parse(req.state.returnTo, true); delete loc.search; // TODO: test case for preserving query params if (yields) { merge(loc.query, yields); } req.state.returning(); this.redirect(uri.format(loc)); } /** * Redirect to the given `url` with optional response `status` defaulting to * 302. * * This function calls {@link https://expressjs.com/en/4x/api.html#res.redirect `redirect`}, * which is enhanced by {@link https://expressjs.com/ Express} on {@link https://nodejs.org/ Node.js}'s * {@link https://nodejs.org/api/http.html#http_class_http_serverresponse `http.ServerResponse`}. * * Prior to redirecting, any uncommited state is commited. Either a * `return_to` or `state` query parameter will be added to the given `url` * to represent the state. The state is then available to the ensuing * request to `url`. * * @public * @name ServerResponse#redirect * @function * @param {number} [status=302] * @param {string} url */ // swizzle redirect to commit the state swizzleRedirect(res, function(url, cb) { if ((mutationMethods.indexOf(req.method) != -1) && req.state.isComplete() === undefined) { req.state.complete(); } commit2(function(err, nstate) { if (err) { return next(err); } var l = uri.parse(url, true); delete l.search; if (nstate) { var returnTo = nstate.location; var state = nstate.handle; if (!returnTo && !state) { return cb(); } // Don't put a return_to on a external link // TODO: rename so to validOrigin var so = returnTo && validate.origin(uri.resolve(returnTo, url), req); if (returnTo && so) { l.query.return_to = returnTo; } if (state) { l.query.state = state; } return cb(null, uri.format(l)); } if (!req.state.handle) { if (req.state.external && req.state.isComplete()) { // TODO: clean this up to remove this empty branch } else if (req.state.returnTo) { if (uri.resolve(req.state.returnTo, url) !== req.state.returnTo && !req.state.isReturning()) { l.query.return_to = req.state.returnTo; } if (req.state.state) { l.query.state = req.state.state; } } } else { // TODO: what is correct here? var currentlyAt = utils.originalURLWithoutState(req); //var currentlyAt = utils.originalURLWithoutQuery(req); if (uri.resolve(currentlyAt, url) !== currentlyAt) { l.query.return_to = currentlyAt; } l.query.state = req.state.handle; } return cb(null, uri.format(l)); }); /* commit(function(err, returnTo, state) { if (err) { return next(err); } if (!returnTo && !state) { return cb(); } var l = uri.parse(url, true); delete l.search; if (returnTo && !preventReturnTo) { l.query.return_to = returnTo; } if (state) { l.query.state = state; } return cb(null, uri.format(l)); }); */ }); /** * Render `view` with the given `options` and optional `callback`. When a * callback function is given a response will _not_ be made automatically, * otherwise a response of _200_ and _text/html_ is given. * * This function calls {@link https://expressjs.com/en/4x/api.html#res.render `render`}, * which is enhanced by {@link https://expressjs.com/ Express} on {@link https://nodejs.org/ Node.js}'s * {@link https://nodejs.org/api/http.html#http_class_http_serverresponse `http.ServerResponse`}. * * Prior to redirecting, any uncommited state is commited. Either a * `return_to` or `state` variable will be set on {@link https://expressjs.com/en/4x/api.html#res.locals locals} * to represent the state. The view can then make the state available to * subsequent requests initiated via links, forms, or other methods. * * @public * @name ServerResponse#render * @function * @param {string} view * @param {object} [options] * @param {function} [callback] */ // swizzle render to commit the state swizzleRender(res, function(cb) { // TODO: dont' complete the state if it is flagged to continue // TODO: Check if the handler explicilty completed before doing this, add test case if ((mutationMethods.indexOf(req.method) != -1) && (Math.floor(res.statusCode / 100) == 2)) { req.state.complete(); } commit2(function(err, nstate) { if (err) { return next(err); } // FIXME: This should call cb? if (nstate) { console.log('TODO: handle push state on render'); return; } // TODO: handle case where there is an nstate (via pushState) if (!req.state.handle) { // The request handler is rendering without setting any state that // was persisted for subsequent requests. This typically occurs under // two situations: // // 1. State was initialized to eventually redirect to a URL with // (optional) state using `return_to` and (optional) `state` // parameters. No modifications were made to this initial state. // 2. Previously persisted state intended for this endpoint was // loaded using `state` parameter. Processing of this this state // was completed. // // In either case, the current state set at `req.state` is complete, // and the location (and state) to eventually redirect to needs to be // propagated to the next request. Propagation is done by setting // `returnTo` and `state` properties on `res.locals`. It is expected // that the view being rendered will add these values to any necessary // links and/or forms to ensure the state flows through subsequent // user interactions. if (req.state.returnTo) { res.locals.returnTo = req.state.returnTo; // TODO: test case for not populating initial state with an invalid state // when return_to is not present. if (req.state.state) { res.locals.state = req.state.state; } } } else { // Processing of this request created state // TODO: document this more res.locals.state = req.state.handle; } return cb(); }); /* commit(function(err, returnTo, state) { if (err) { return next(err); } // FIXME: This should call cb? if (returnTo) { res.locals.returnTo = returnTo; } if (state) { res.locals.state = state; } cb(); }); */ }); swizzleEnd(res, function(cb) { commit2(function() { cb(); }); }); // To doc: a new state will have "returnTo" and "state", Such a state doesn't // need to be serialized, since it can be passed as URL parameters. function generate(url, state) { var data = { location: url }; // TODO: clean this up and handle external states in a clear condition // TODO: add tests for preserving query params // TODO: test case for not adding state or parsing params when external var returnTo = options.external ? utils.originalURL(req) : ((req.query && req.query.return_to) || (req.body && req.body.return_to) || (req.header ? req.header('referer') : undefined)); if (returnTo && validateRedirect(returnTo, req)) { data.returnTo = returnTo; if (state && !options.external) { data.state = state; } } req.state = new State(req, data, undefined, options.external); req._stateStack = [ req.state ]; } function inflate(data, h) { req.state = new State(req, data, h); req._stateStack = [ req.state ]; } // TODO: dedupe this with commit below, if the same function works in all places function commit2(cb) { var stack = req._stateStack , i = 0; function iter(err, nstate) { if (err) { return cb(err); } // TODO: Test case for this var state = stack[i++]; if (!state) { return cb(null, i == 2 ? undefined : nstate); } if (state.isComplete()) { debug('destroying %O (%s)', state, state.handle); // TODO: optimization: don't call destroy if its not actually persisted (doesn't have. ahandle) state.destroy(function(err) { if (err) { return iter(err); } debug('destroyed'); iter(null, state); }); } else if (state.isModified() || (i > 1 && !state.isSaved())) { // FIXME: the i>1 condition gets pushed states (whcih are not modified but need saving) //. make this more obvious by adding a method to check if (!state.handle && (typeof store.set == 'function')) { state.handle = generateHandle(); } state.save(function(err) { if (err) { return iter(err); } debug('saved (%s)', state.handle); iter(null, state); }); } else { iter(null, state); } } iter(); } // TODO: dead code, can be removed /* function commit(cb) { var stack = req._stateStack , i = 0; function iter(err, returnTo, resumeState) { if (err) { return cb(err); } // TODO: Test case for this var state = stack[i++]; if (!state) { return cb(null, returnTo, resumeState); } if (state.isComplete()) { debug('destroying %O (%s)', state, state.handle); state.destroy(function(err) { if (err) { return iter(err); } debug('destroyed'); iter(null, state.returnTo, state.state); }); } else if (state.isModified() || (i > 1 && !state.isSaved())) { debug('saving %O (%s)', state, state.handle); if (!state.handle && (typeof store.set == 'function')) { state.handle = generateHandle(); } state.save(function(err) { if (err) { return iter(err); } debug('saved (%s)', state.handle); iter(null, state.location, state.handle); }); } else if (state.isNew()) { iter(null, state.returnTo, state.state); } else { // current if (state.isContinue()) { return iter(null, state.location, state.handle); } iter(null, undefined, state.handle); } } iter(); } */ var url = utils.originalURLWithoutQuery(req); var h = getHandle(req); // Create a new "uninitialized" state if the request didn't send a state // handle, or the endpoint is configured to consider any state as external // to this application. // // External state is typically relayed by third-party applications when // making a request as part of a federated protocol, such as OAuth 2.0 or // OpenID Connect. In such cases, the `state` parameter does not indentify // state associated with this application, but rather a parameter that is // expected to be echo'd back to the calling application so that it can // subsequently load state when the user is redirected back. if (!h || options.external) { generate(url); next(); return; } // TODO: can optimize this by not getting state when return_to parameter is present //. YES, likely a good idea // Attempt to load any previously serialized state identified by the state // handle. store.get(req, h, function(err, state) { if (err) { return next(err); } // No previously serialized state was found for the given state handle. // Ignore the parameter and create a new "uninitialized" state. The // uninitialized state will preserve the state handle if it is intended // to be passed as a parameter to the redirect URL, if any. if (!state) { generate(url, h); next(); return; } if (state.location !== url) { // The loaded state is not intended for this endpoint. Ignore the state // and create a new "uninitialized" state. The uninitialized state will // preserve the state handle if it is intended to be passed as a // parameter to the redirect URL, if any. generate(url, h); next(); return; } inflate(state, h); next(); }); }; };