flowstate
Version:
Per-request state management middleware.
508 lines (442 loc) • 18.4 kB
JavaScript
// 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();
});
};
};