UNPKG

apostrophe

Version:

The Apostrophe Content Management System.

791 lines (720 loc) 31 kB
// This module initializes the Express framework, which Apostrophe // uses and extends to implement both API routes and page-serving routes. // The Express `app` object is made available as `apos.app`, and // the `express` object itself as `apos.express`. You can add // Express routes directly in your modules via `apos.app.get`, // `apos.app.post`, etc., however be sure to also check // out the [route method](/reference/modules/apostrophe-module#route-method-path-fn) available // in all modules for a cleaner way to implement API routes. Adding // routes directly to the Express app object is still sometimes useful when // the URLs will be public. // // This module also adds a number of standard middleware functions // and implements the server side of CSRF protection for Apostrophe. // // ## Options // // ### `baseUrl` (GLOBAL OPTION, NOT SET FOR THIS SPECIFIC MODULE) // // As a convenience, `req.absoluteUrl` is set to the absolute URL of // the current request. If the `baseUrl` option **at the top level, // not for this specific module** is set to a string // such as `http://mysite.com`, any site-wide prefix and `req.url` are // appended to that. Otherwise the absolute URL is constructed based // on the browser's request. Setting the `baseUrl` global option is // necessary for reasonable URLs when generating markup from a // command line task. // // ### `address` // // Apostrophe listens for connections on all interfaces (`0.0.0.0`) // unless this option is set to another address. // // In any case, if the `ADDRESS` environment variable is set, it is // used instead. // // ### `port` // // Apostrophe listens for connections on port `3000` unless this // option is set to another port. // // In any case, if the `PORT` environment variable is set, it is used // instead. // // ### `bodyParser` // // The `json` and `urlencoded` properties of this object are merged // with Apostrophe's default options to be passed to the `body-parser` // npm module's `json` and `urlencoded` flavors of middleware. // // ### `prefix` *(a global option, not a module option)* // // This module implements parts of the sitewide `prefix` option, which is a global // option to Apostrophe not specific to this module. If a `prefix` such // as `/blog` is present, the site responds with its home page // at `/blog` rather than `/`. All calls to `res.redirect` are adjusted // accordingly, and supporting code in other modules adjusts AJAX calls // made by jQuery as well, so that your code does not have to be // "prefix-aware" in order to work. // // ### `afterListen` *(a global option, not a module option)* // // If Apostrophe was configured with an `afterListen` option, that // function is invoked after the site is ready to accept connections. // An error will be passed if appropriate. // // ### `session` // // Properties of the `session` option are passed to the // [express-session](https://npmjs.org/package/express-session) module. // If each is not otherwise specified, Apostrophe enables these defaults: // // ```javascript // { // // Do not save sessions until something is stored in them. // // Greatly reduces aposSessions collection size // saveUninitialized: false, // // The mongo store uses TTL which means we do need // // to signify that the session is still alive when someone // // views a page, even if their session has not changed // resave: true, // // Always update the cookie, so that each successive // // access revives your login session timeout // rolling: true, // secret: 'you should have a secret', // cookie: { // path: '/', // httpOnly: true, // secure: false, // // Default login lifetime between requests is one day // maxAge: 86400000 // }, // store: (instance of connect-mongo/es5 provided by Apostrophe) // } // ``` // // If you want to use another session store, you can pass an instance, // but it's easier to let Apostrophe do the work of setting it up: // // session: { // store: { // name: 'connect-redis', // options: { // // redis-specific options here // } // } // } // // Just be sure to install `connect-redis`, or the store of your choice, // as an npm dependency. // // ### `csrf` // // By default, Apostrophe implements Angular-compatible [CSRF protection](https://en.wikipedia.org/wiki/Cross-site_request_forgery) // via an `XSRF-TOKEN` cookie. The `apostrophe-assets` module pushes // a call to the browser to set a jQuery `ajaxPrefilter` which // adds an `X-XSRF-TOKEN` header to all requests, which must // match the cookie. This is effective because code running from // other sites or iframes will not be able to read the cookie and // send the header. // // All non-safe HTTP requests (not `GET`, `HEAD`, `OPTIONS` or `TRACE`) // automatically receive this proection via the csrf middleware, which // rejects requests in which the CSRF token does not match the header. // // If the `csrf` option is set to `false`, CSRF protection is // disabled (NOT RECOMMENDED). // // If the `csrf` option is set to an object, you can configure // individual exceptions: // // ```javascript // csrf: { // exceptions: [ '/cheesy-post-route' ] // } // ``` // // Exceptions may use minimatch wildcards (`*` and `**`). They can // also be regular expression objects. // // You may need to use this feature when implementing POST form submissions that // do not use AJAX and thus don't send the header. We recommend using // `$.post` or `$.jsonCall` for your forms, which eliminates this issue. // // There is also a `minimumExceptions` option, which defaults // to `[ /login ]`. The login form is the only non-AJAX form // that ships with Apostrophe. XSRF protection for login forms // is unnecessary because the password itself is unknown to the // third party site; it effectively serves as an XSRF token. // // You can also pass options to the Express [`res.cookie`](https://expressjs.com/en/api.html#res.cookie) call that sets the cookie: // // ```javascript // csrf: { // exceptions: [ '/cheesy-post-route' ], // cookie: { // // Send it only if the request is HTTPS // secure: true // }, // // Disable storing a true random CSRF token in sessions for all site visitors. // // Some protection is still provided via a well-known token and the Same Origin Policy. // // Logged-in users always get a true random CSRF token in their session. // // This setting is recommended because otherwise a session must be stored for // // 100% of site accesses. // disableAnonSession: true // } // ``` // // Do not set the `httpOnly` flag as this will prevent legitimate same-origin // JavaScript from adding it to requests. // // ### middleware // // If a `middleware` array is present, those functions are added // as Express middleware by the `requiredMiddleware` method, immediately // after Apostrophe's standard middleware. // // ## Optional middleware: `apos.middleware` // // This module adds a few useful but optional middleware functions // to the `apos.middleware` object for your use where appropriate: // // ### `apos.middleware.files` // // This middleware function accepts file uploads and makes them // available via `req.files`. See the // [connect-multiparty](https://npmjs.org/package/connect-multiparty) npm module. // This middleware is used by [apostrophe-attachments](/reference/modules/apostrophe-attachments). // // ## Module-specific middleware // // In addition, this module will look for an `expressMiddleware` property // in EVERY module. If such a property is found, it will be invoked as // middleware on ALL routes, after the required middleware (such as the body parser) and // before the configured middleware. If the property is an array, all of the functions // in the array are invoked as middleware. // // If `expressMiddleware` is a non-array object, it must have a `middleware` // property containing a function or an array of functions, and it may also have a // `before` property containing the name of another module. The function(s) in the // `middleware` property will be run before those for the named module. // // If you need to run the middleware very early, the object may also have a // `when` property, which may be set to `beforeRequired` (the absolute // beginning, before even req.body is available), `afterRequired` (after all // middleware shipped with apostrophe-express but before all middleware passed // to it as options), or `afterConfigured` (the default, with other module // middleware). var fs = require('fs'); var _ = require('@sailshq/lodash'); var minimatch = require('minimatch'); var async = require('async'); var enableDestroy = require('server-destroy'); module.exports = { afterConstruct: function(self) { self.createApp(); self.prefix(); // for bc make sure this method can accept an argument if (self.useModuleMiddleware.length === 1) { self.useModuleMiddleware('beforeRequired'); } self.requiredMiddleware(); // No bc check needed because this is the original place we called it self.useModuleMiddleware('afterRequired'); self.configuredMiddleware(); if (self.useModuleMiddleware.length === 1) { self.useModuleMiddleware('afterConfigured'); } self.optionalMiddleware(); self.addListenMethod(); if (self.options.baseUrl && (!self.apos.baseUrl)) { self.apos.utils.error('WARNING: you have baseUrl set as an option to the `apostrophe-express` module.'); self.apos.utils.error('Set it as a global option (a property of the main object passed to apostrophe).'); self.apos.utils.error('When you do so other modules will also pick up on it and make URLs absolute.'); } }, construct: function(self, options) { var express = require('express'); var bodyParser = require('body-parser'); var expressSession = require('express-session'); var connectFlash = require('connect-flash'); var cookieParser = require('cookie-parser'); // Create Apostrophe's `apos.app` and `apos.express` objects self.createApp = function() { self.apos.app = self.apos.baseApp = express(); self.apos.express = express; }; // Patch Express so that all calls to `res.redirect` honor // the global `prefix` option without the need to make each // call "prefix-aware" self.prefix = function() { if (self.apos.prefix) { // Use middleware to patch the redirect method to accommodate // the prefix. This is cleaner than patching the prototype, which // breaks if you have multiple instances of Apostrophe, such as // in our unit tests. Also make res.rawRedirect available for // edge cases where a new URL is built from a URL that is // already prefixed. self.apos.app.use(function(req, res, next) { res.rawRedirect = res.redirect; res.redirect = function(status, url) { if (arguments.length === 1) { url = status; status = 302; } if (!url.match(/^[a-zA-Z]+:/)) { url = self.apos.prefix + url; } return res.rawRedirect(status, url); }; return next(); }); self.apos.baseApp = express(); self.apos.baseApp.use(self.apos.prefix, self.apos.app); } else { // res.rawRedirect must be available whether prefix is present or not self.apos.app.use(function(req, res, next) { res.rawRedirect = res.redirect; return next(); }); } }; // Standard middleware. Creates the `req.data` object, so that all // code wishing to eventually add properties to the `data` object // seen in Nunjucks templates may assume it already exists self.createData = function(req, res, next) { if (!req.data) { req.data = {}; } return next(); }; // Establish Express sessions. See [options](#options) self.sessions = function() { var Store; self.options.session = self.options.session || {}; var sessionOptions = self.options.session; _.defaults(sessionOptions, { // Do not save sessions until something is stored in them. // Greatly reduces aposSessions collection size saveUninitialized: false, // The mongo store uses TTL which means we do need // to signify that the session is still alive when someone // views a page, even if their session has not changed resave: true, // Always update the cookie, so that each successive // access revives your login session timeout rolling: true, secret: 'you should have a secret', name: self.apos.shortName + '.sid', cookie: {} }); _.defaults(sessionOptions.cookie, { path: '/', httpOnly: true, secure: false, // Default login lifetime between requests is one day maxAge: 86400000 }); if (sessionOptions.secret === 'you should have a secret') { self.apos.utils.error('WARNING: No session secret provided, please set the `secret` property of the `session` property of the apostrophe-express module in app.js'); } if (!sessionOptions.store) { sessionOptions.store = {}; } if (sessionOptions.store.createSession) { // Already an instantiated store object. // Duck typing: don't be picky about who constructed who } else { if (!sessionOptions.store.options) { sessionOptions.store.options = {}; } // Some stores will flip out if you pass them a mongo db as an option, // but try to help all flavors of connect-mongo var name = sessionOptions.store.name; if ((!name) || (name.match(/^connect-mongo/))) { if (!sessionOptions.store.options.db) { sessionOptions.store.options.db = self.apos.db; } } if (!sessionOptions.store.name) { // baked in legacy compatible version Store = require('./lib/connect-mongo/es5.js')(expressSession); } else { // require from project's dependencies Store = self.apos.root.require(sessionOptions.store.name)(expressSession); } sessionOptions.store = new Store(sessionOptions.store.options); } // Exported for the benefit of code that needs to // interoperate in a compatible way with express-sessions self.sessionOptions = sessionOptions; self.apos.app.use(expressSession(sessionOptions)); }; // Install all standard middleware: // // * Create the `req.data` object on all requests // * Implement Express sessions // * Add the cookie parser // * Angular-style CSRF protection // * Extended body parser (`req.body` supports nested objects) // * JSON body parser (useful with `$.jsonCall`) // * Flash messages (see [connect-flash](https://github.com/jaredhanson/connect-flash)) // * Internationalization (see [apostrophe-i18n](apostrophe-i18n.md)) // * `req.absoluteUrl` always available (also see [baseUrl](#baseUrl)) // self.requiredMiddleware = function() { self.apos.app.use(self.createData); self.sessions(); // First so self.csrf can use it. self.apos.app.use(cookieParser()); // Allow the options for bodyParser to be customized // in app.js var bodyParserOptions = _.extend({ json: { limit: '16mb' }, urlencoded: { extended: true } }, options.bodyParser); // extended: true means that people[address[street]] works // like it does in a PHP urlencoded form. This has a cost // but is too useful and familiar to leave out. -Tom and Ben self.apos.app.use(bodyParser.urlencoded(bodyParserOptions.urlencoded)); self.apos.app.use(bodyParser.json(bodyParserOptions.json)); self.apos.app.use(connectFlash()); self.apos.app.use(self.apos.i18n.init); self.apos.app.use(self.apos.modules['apostrophe-i18n'].namespacesMiddleware); self.apos.app.use(self.absoluteUrl); self.apos.app.use(self.htmlPageId); // self.apos.app.use(function(req, res, next) { // self.apos.utils.log('URL: ' + req.url); // return next(); // }); }; // For bc, in case an existing override of `useModuleMiddleware` doesn't know better self.moduleMiddleware = []; // Broken down by when it will run self.moduleMiddlewareByWhen = { beforeRequired: [], afterRequired: [], afterConfigured: [] }; // Implement middleware added via self.expressMiddleware properties in modules. self.useModuleMiddleware = function(when) { self.apos.app.use(function(req, res, next) { return async.eachSeries(self.moduleMiddlewareByWhen[when], function(fn, callback) { return fn(req, res, function() { return callback(null); }); }, next); }); }; self.configuredMiddleware = function() { if (options.middleware) { _.each(options.middleware, function(fn) { self.apos.app.use(fn); }); } }; // Enable CSRF protection middleware. See // `compileCsrfExceptions` for details on how to // exclude a route from this. self.enableCsrf = function() { // The kernel of apostrophe in browserland needs this info, so // make it conveniently available to the assets module when it // picks a few properties from apos to boot that up self.apos.csrfCookieName = (self.options.csrf && self.options.csrf.name) || self.apos.shortName + '.csrf'; self.compileCsrfExceptions(); }; // Compile CSRF exceptions, which may be regular expression objects or // "minimatch" strings using the * and ** wildcards. They are // taken from `options.csrf.exceptions`. `/login`, `/password-reset` and `/password-reset-request` // are exceptions by default, which can be overridden via `options.csrf.minimumExceptions`. // Also invokes the `csrfExceptions` Apostrophe event, passing // the array of exceptions so it can be added to or otherwise modified // by modules such as `apostrophe-headless`. self.compileCsrfExceptions = function() { var list = (self.options.csrf && self.options.csrf.minimumExceptions) || [self.apos.login.getLoginUrl(), '/login', '/password-reset', '/password-reset-request']; list = list.concat((self.options.csrf && self.options.csrf.exceptions) || []); self.apos.emit('csrfExceptions', list); self.csrfExceptions = _.map(list, function(e) { if (e instanceof RegExp) { return e; } return minimatch.makeRe(e); }); }; // Angular-compatible CSRF protection middleware. On safe requests (GET, HEAD, OPTIONS, TRACE), // set the XSRF-TOKEN cookie if missing. On unsafe requests (everything else), // make sure our jQuery `ajaxPrefilter` set the X-XSRF-TOKEN header to match the // cookie. // // This works because if we're running via a script tag or iframe, we won't // be able to read the cookie. // // [See the Angular docs for further discussion of this strategy.](https://docs.angularjs.org/api/ng/service/$http#cross-site-request-forgery-xsrf-protection) self.csrf = function(req, res, next) { if (_.find(self.csrfExceptions || [], function(e) { return req.url.match(e); })) { return next(); } return self.csrfWithoutExceptions(req, res, next); }; // See the `csrf` middleware method. This middleware method // performs the actual CSRF check, without checking for exceptions // first. It does check for and allow safe methods. This // method is useful when you have made your own determination // that this URL should be subject to CSRF. self.csrfWithoutExceptions = function(req, res, next) { var token; // OPTIONS request cannot set a cookie, so manipulating the session here // is not helpful. Do not attempt to set XSRF-TOKEN for OPTIONS if (req.method === 'OPTIONS') { return next(); } // Safe request establishes XSRF-TOKEN in session if not set already if ((req.method === 'GET') || (req.method === 'HEAD') || (req.method === 'TRACE')) { token = req.session && req.session['XSRF-TOKEN']; if (!token) { if (!req.user && (self.options.csrf && self.options.csrf.disableAnonSession)) { token = 'csrf-fallback'; } else { token = self.apos.utils.generateId(); req.session['XSRF-TOKEN'] = token; } } // Always reset the cookie so that if its lifetime somehow detaches from // that of the session cookie we're still OK res.cookie(self.apos.csrfCookieName, token, (self.options.csrf && self.options.csrf.cookie) || {}); } else { // All non-safe requests must be preceded by a safe request that establishes // the CSRF token, both as a cookie and in the session. Otherwise a user who is logged // in but doesn't currently have a CSRF token is still vulnerable. // See options.csrf.exceptions if (!req.user && (self.options.csrf && self.options.csrf.disableAnonSession)) { if (req.get('X-XSRF-TOKEN') !== 'csrf-fallback') { res.statusCode = 403; warn(); return res.send('forbidden'); } } else if ((!req.cookies[self.apos.csrfCookieName]) || (req.get('X-XSRF-TOKEN') !== req.cookies[self.apos.csrfCookieName]) || (req.session['XSRF-TOKEN'] !== req.cookies[self.apos.csrfCookieName])) { res.statusCode = 403; warn(); return res.send('forbidden'); } } return next(); function warn() { self.apos.utils.warnDevOnce('csrf', '\n⚠️ A request for ' + req.url + ' was rejected\ndue to the absence of a CSRF token. If you experience this while editing\nnormally with Apostrophe during development, try logging out and logging in\nagain. If you experience it accessing a custom route, please read:\n\nhttps://apos.dev/csrf'); } }; // Establish optional middleware functions as properties // of the `apos.middleware` object. Currently just `apos.middleware.files`. self.optionalMiddleware = function() { // Middleware that is not automatically installed on // every route but is recommended for use in your own // routes when needful self.apos.middleware = { files: require('connect-multiparty')() }; }; // Establish the `apos.listen` method, which Apostrophe will invoke // at the end of its initialization process. self.addListenMethod = function() { self.apos.listen = function() { var port; var address; try { if (self.options.forcePort !== undefined) { port = self.options.forcePort; } else if (self.options.port !== undefined) { port = self.options.port; } if (!self.options.forcePort) { if (process.env.PORT) { port = process.env.PORT; } else { if (fs.existsSync(self.apos.rootDir + '/data/port')) { // Stagecoach option port = fs.readFileSync(self.apos.rootDir + '/data/port', 'UTF-8').replace(/\s+$/, ''); } } } if (port === undefined) { port = 3000; self.apos.utils.log("I see no data/port file, port option, forcePort option, or PORT environment variable,\ndefaulting to port " + port); } if ((typeof port) !== 'number') { port = Number.isNaN(parseInt(port)) ? port : parseInt(port); } if (self.options.forceAddress) { address = self.options.forceAddress; } else if (self.options.address) { address = self.options.address; } if (!self.options.forceAddress) { if (process.env.ADDRESS) { address = process.env.ADDRESS; } else { if (fs.existsSync(self.apos.rootDir + '/data/address')) { // Stagecoach option address = fs.readFileSync(self.apos.rootDir + '/data/address', 'UTF-8').replace(/\s+$/, ''); } } } if (address === undefined) { address = false; self.apos.utils.log('I see no data/address file, address option, forceAddress option, or ADDRESS environment variable,\nlistening on all interfaces'); } if (address !== false) { self.server = self.apos.baseApp.listen(port, address); self.server.on('listening', listening); } else { self.server = self.apos.baseApp.listen(port); self.server.on('listening', listening); } } catch (e) { if (self.apos.options.afterListen) { return self.apos.options.afterListen(e); } else { self.apos.utils.error(e); process.exit(1); } } function listening() { enableDestroy(self.server); self.port = self.server.address().port; self.address = address; if (address) { self.apos.utils.log('Listening on http://' + address + ':' + port); } else { self.apos.utils.log('Listening at http://localhost:' + port); } if (self.apos.options.afterListen) { return self.apos.options.afterListen(null); } } self.server.on('error', function(err) { if (self.apos.options.afterListen) { return self.apos.options.afterListen(err); } }); }; }; // Invoked by `callAll` when `apos.destroy` is called. // Destroys the HTTP server object, freeing the port. self.apostropheDestroy = function(callback) { if (!(self.server && self.server.destroy)) { return setImmediate(callback); } return self.server.destroy(callback); }; // Standard middleware. Sets the `req.absoluteUrl` property for all requests, // based on the `baseUrl` option if available, otherwise based on the user's // request headers. The global `prefix` option and `req.url` are then appended. // // `req.baseUrl` and `req.baseUrlWithPrefix` are also made available, and all three // properties are also added to `req.data` if not already present. // // The `baseUrl` option should be configured at the top level, for Apostrophe itself, // NOT specifically for this module, but for bc the latter is also accepted in this // one case. For a satisfyingly global result, set it at the top level instead. self.absoluteUrl = function(req, res, next) { self.addAbsoluteUrlsToReq(req); next(); }; // Makes the `Apostrophe-Html-Page-Id` header available // as `req.htmlPageId`. This header is passed by // all jQuery AJAX requests made by Apostrophe. It // contains a unique identifier just for the current // webpage in the browser; that is, navigating to a new // page always generates a *new* id, the same page in two tabs // will have *different* ids, etc. This makes it easy to // identify requests that come from the "same place" // for purposes of conflict resolution and locking. // (Note that conflicts can occur between two tabs // belonging to the same user, so a session ID is not enough.) self.htmlPageId = function(req, res, next) { req.htmlPageId = req.header('Apostrophe-Html-Page-Id'); next(); }; // Sets the `req.absoluteUrl` property for all requests, // based on the `baseUrl` option if available, otherwise based on the user's // request headers. The global `prefix` option and `req.url` are then appended. // // `req.baseUrl` and `req.baseUrlWithPrefix` are also made available, and all three // properties are also added to `req.data` if not already present. // // The `baseUrl` option should be configured at the top level, for Apostrophe itself, // NOT specifically for this module, but for bc the latter is also accepted in this // one case. For a satisfyingly global result, set it at the top level instead. // // If you want reasonable URLs in req objects used in tasks you must // set the `baseUrl` option for Apostrophe. self.addAbsoluteUrlsToReq = function(req) { req.baseUrl = (self.apos.baseUrl || self.options.baseUrl || (req.protocol + '://' + req.get('Host'))); req.baseUrlWithPrefix = req.baseUrl + self.apos.prefix; req.absoluteUrl = req.baseUrlWithPrefix + req.url; _.defaults(req.data, _.pick(req, 'baseUrl', 'baseUrlWithPrefix', 'absoluteUrl')); }; // Locate modules with middleware and add it to the list. // Also compile the CSRF exceptions, late, so that other // modules can respond to the `csrfExceptions` event. self.afterInit = function() { self.enableCsrf(); self.findModuleMiddleware(); }; // Locate modules with middleware and add it to the list self.findModuleMiddleware = function() { var labeledList = []; var obj; _.each(self.apos.modules, function(module, name) { if (!module.expressMiddleware) { return; } var prop = module.expressMiddleware; if (Array.isArray(prop) || (typeof (prop) === 'function')) { obj = { middleware: prop }; } else { obj = prop; } obj.module = name; // clone so we can safely delete a property labeledList.push(_.clone(obj)); }); do { var beforeIndex = _.findIndex(labeledList, function(item) { return item.before; }); if (beforeIndex === -1) { break; } var item = labeledList[beforeIndex]; var otherIndex = _.findIndex(labeledList, { module: item.before }); if ((otherIndex !== -1) && (otherIndex < beforeIndex)) { labeledList.splice(beforeIndex, 1); labeledList.splice(otherIndex, 0, item); } delete item.before; } while (true); _.each(labeledList, function(item) { var prop = item.middleware; var when = item.when || 'afterRequired'; if (Array.isArray(prop)) { self.moduleMiddlewareByWhen[when] = self.moduleMiddlewareByWhen[when].concat(prop); // bc self.moduleMiddleware = self.moduleMiddleware.concat(prop); } else if (typeof (prop) === 'function') { self.moduleMiddlewareByWhen[when].push(prop); // bc self.moduleMiddleware.push(prop); } }); }; } };